デプロイとスケール
サーバーは動いています。次に必要なのは本物のホスト名と、その背後で動く複数のワーカーです。
そのほとんどは MCP の管轄外です。ASGI サーバー、プロセスマネージャー、ロードバランサーは各自で用意します。このページにあるのは、本当に MCP の管轄に入るものだけを集めた短いリストです。すべてのデプロイの関門となる設定が 1 つと、「複数のワーカー」によって SDK の動作が変わる箇所が 2 つです。
まず確認すべきこと:Host の許可リスト
streamable_http_app() は、どのホスト名の背後で配信されるかを知ることができません。そのため、最も安全な答えである localhost を前提にします。transport_security= を指定しないと、アプリは DNS リバインディング保護を有効にし、Host ヘッダーが 127.0.0.1:<port>、localhost:<port>、[::1]:<port> のいずれかであるリクエストだけを受け付けます。Origin ヘッダーがある場合は、同じものの http:// 形式でなければなりません。手元のマシンではこれがまさに正しい動作です。悪意のある Web ページが、127.0.0.1 にリバインドした DNS 名を通じてローカルサーバーを操作するのを防ぎます。
本物のホスト名の背後にデプロイすると、同じデフォルトが、別途指示するまですべてのリクエストを拒否します。このチェックは MCP に関わるどんな処理よりも前に実行されるので、自分で作ったものは一切参照されません。
421 Misdirected Request Invalid Host header the Host is not in the allowlist
403 Forbidden Invalid Origin header the Origin is not in the allowlist
解決策は transport_security= です。実際に配信するものを許可リストに入れます。
from mcp.server import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
mcp = MCPServer("Notes")
@mcp.tool()
def add_note(text: str) -> str:
"""Save a note."""
return f"Saved: {text}"
security = TransportSecuritySettings(
allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
allowed_origins=["https://app.example.com"],
)
app = mcp.streamable_http_app(transport_security=security)
allowed_hostsのエントリは完全一致の文字列です。"mcp.example.com"はポートなしのHostヘッダーに一致し、"mcp.example.com:*"は任意のポートに一致します。両方を並べてください。allowed_originsが意味を持つのはブラウザーに対してだけです。ほかにOriginを送るものはないからです。これは 既存のアプリに追加する で扱う CORS 設定と対になる、サーバー側の設定です。- すでに
Hostヘッダーを制御しているリバースプロキシの背後では、チェックを無効にするのが実態に即した設定です。TransportSecuritySettings(enable_dns_rebinding_protection=False)とします。 - localhost 以外の
host=(たとえばhost="mcp.example.com")を渡しても、そのホスト名は許可リストに入りません。localhost のデフォルトが保護を有効にするのを止めるだけで、その結果あらゆる Host と Origin が受け付けられます。意図はtransport_security=で明示してください。
Check
transport_security=security 引数を削除して、そのままアプリをデプロイしてみてください。起動し、/mcp にルーティングされ、そしてすべてのリクエスト(素の curl からのものも含めて)が次のように返ってきます。
HTTP/1.1 421 Misdirected Request
Invalid Host header
この文言はクライアント側では見つかりません。421 は JSON-RPC エラーではなくプレーンテキストの HTTP レスポンスなので、MCP クライアントは汎用的なトランスポートエラーを送出します。気に入らなかったホスト名はサーバーのログに、警告として 1 行出るだけです。デプロイしたばかりのサーバーがすべての接続を拒否するなら、そうでないと証明されるまでは Host の許可リストが原因です。トラブルシューティング もここから始まります。
ワーカーと、スティッキーにする必要があるのは誰か
ホスト名が応答するようになったら、その背後に複数のワーカーを置きます。そのための SDK の設定項目はありません。Starlette アプリは、どんな ASGI アプリとも同じ方法でスケールします。fork の仕方を知っているものにオブジェクトを渡すだけです。
uvicorn server:app --workers 4
プロセスは 4 つ、ソケットは 1 つです。そしてここで、すべてのデプロイが答えなければならない問いが出てきます。リクエストは、直前のリクエストを受けたワーカーに届かなければならないのか。
2026-07-28 プロトコルを話すクライアントについては、答えはノーです。モダンなリクエストは、自己完結した 1 つの POST です。その前に initialize のハンドシェイクはなく、レスポンスに Mcp-Session-Id は付かず、2 つ目のリクエストが「戻ってくる」先もありません。どのワーカーにルーティングしてもかまいません。
これは有効にするモードではありません。stateless_http=True がそう見えるかもしれませんが、トランスポートは MCP-Protocol-Version リクエストヘッダーでルーティングし、モダンなリクエストをモダンなハンドラーに渡して、return します。stateless_http を読む行は、その return の「後」にあります。2026-07-28 の経路でフラグが無視されるのではなく、そもそも到達しないのです。stateless_http はレガシー側の経路だけの設定項目であり、モダンな経路は構造上セッションを持ちません。
仕様バージョン 2025-11-25 以前のレガシークライアントについては、答えはそのフラグ次第です。
| クライアントのプロトコルバージョン | セッション | ロードバランサーがすべきこと |
|---|---|---|
| 2026-07-28 | なし。Mcp-Session-Id は設定されません。 |
何もなし。どのワーカーもどのリクエストでも処理できます。 |
| 2025-11-25 以前(デフォルト) | Mcp-Session-Id。1 つのワーカーのメモリに保持されます。 |
スティッキーセッション。別のワーカーに届いた後続リクエストは 404 "Session not found" になります。 |
2025-11-25 以前、stateless_http=True を指定 |
なし。 | 何もなし。代償は、サーバーからクライアントへのバックチャネル(back-channel)、つまりサンプリング、プッシュ型のエリシテーション(elicitation)、roots/list と、再開可能性です。 |
スティッキーセッションと、レガシー側の経路の代償については、専用のページ レガシークライアントへの対応 があります。2 つの世代そのものについては プロトコルバージョン を参照してください。ここで重要なのは答えの形です。2026-07-28 ではすでにステートレスであり、設定するものは何もありません。
このページの残りは、ステートレスになっても解決しない 2 つの事柄です。
ワーカーをまたぐ requestState
マルチラウンドトリップ(multi-round-trip) のツールは、クライアントが取りに行かなければならないもの(確認、選択、資格情報)を必要とします。そのため答えの代わりに質問を返し、リトライで完了します。2 つのラウンドの間、クライアントはサーバーが発行した不透明な request_state トークンを保持します。リトライ時には、サーバーがそのトークンをもう一度開けなければなりません。
では、どの鍵で封印されているのでしょうか。デフォルトでは、サーバーが構築時に os.urandom(32) で生成した鍵です。--workers 4 では、4 つのプロセスで 4 回構築されます。つまり 4 つの異なる鍵があり、どこにも書き出されず、共有もされず、再起動すれば消えます。
次は、何も設定していないサーバー上で、実行前に確認を取るツールです。
from mcp.server.mcpserver import Context, MCPServer
from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
CONFIRM = ElicitRequest(
params=ElicitRequestFormParams(
message="Issue this refund?",
requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]},
)
)
def make_server() -> MCPServer:
"""Every worker process builds one of these, once, at import."""
mcp = MCPServer("billing")
@mcp.tool()
async def refund(amount: int, ctx: Context) -> str | InputRequiredResult:
"""Refund an amount, once a human has confirmed it."""
if ctx.input_responses is None:
return InputRequiredResult(input_requests={"ok": CONFIRM}, request_state=f"refund:{amount}")
answer = (ctx.input_responses or {}).get("ok")
if not isinstance(answer, ElicitResult) or answer.action != "accept" or not (answer.content or {}).get("ok"):
return "refund cancelled"
return f"refunded ${amount}"
return mcp
1 回目のラウンドはワーカー A に届きます。ワーカー A は自分の鍵で refund:120 を封印し、トークンを返します。クライアントは質問を人に提示し、承諾を得て、リトライします。リトライはまったく新しい HTTP リクエストです。
Check
そのリトライがワーカー B に届いたとします。B は自分が発行していないトークンの開封を試み、できず、ラウンド全体を拒否します。refund は呼び出されず、クライアントは JSON-RPC エラーを受け取ります。
{
"code": -32602,
"message": "Invalid or expired requestState",
"data": {"reason": "invalid_request_state"}
}
このメッセージは固定です。期限切れでも、改ざんされていても、別の引数に対してリプレイされていても、あるいは(実際のデプロイで群を抜いて多い原因である)兄弟ワーカーが封印したものであっても、クライアントには毎回同じことが伝えられます。そのため、どのチェックに失敗したかは通信上には現れません。本当の理由は、サーバーのログに出る 1 行の WARNING です。
requestState rejected on tools/call: unknown key
ワーカーが 1 つなら動いていたマルチラウンドトリップのツールが、2 つにしたとたん「ときどき」失敗し始めたなら、原因はこれです。両方のラウンドは依然として同じプロセスに届かなければならないので、ロードバランサーがそれらを引き離すのとちょうど同じ頻度で失敗します。
2 つのラウンドは独立した 2 つの HTTP リクエストであり、ごくありふれたことがいくつもそれらを引き離します。リクエスト単位で振り分けるプロキシ、間で切れた接続、デプロイや再起動、request_state を永続化してまったく別のプロセスから再開するクライアント(ループを自分で回す)などです。どれも「別のワーカー」です。
解決策は引数 1 つです。ただし、それは 2 つの部分からなります。
from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity
from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
CONFIRM = ElicitRequest(
params=ElicitRequestFormParams(
message="Issue this refund?",
requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]},
)
)
def make_server(key: str) -> MCPServer:
"""Every worker process: the same key, and the same name."""
mcp = MCPServer("billing", request_state_security=RequestStateSecurity(keys=[key]))
@mcp.tool()
async def refund(amount: int, ctx: Context) -> str | InputRequiredResult:
"""Refund an amount, once a human has confirmed it."""
if ctx.input_responses is None:
return InputRequiredResult(input_requests={"ok": CONFIRM}, request_state=f"refund:{amount}")
answer = (ctx.input_responses or {}).get("ok")
if not isinstance(answer, ElicitResult) or answer.action != "accept" or not (answer.content or {}).get("ok"):
return "refund cancelled"
return f"refunded ${amount}"
return mcp
keys=[...]は、誰もが見つけるほうの部分です。すべてのインスタンスに同じシークレット(少なくとも 32 バイト)を与えれば、どのインスタンスも兄弟が発行したものを開封できます。keys[0]が封印し、リストのすべての鍵が開封できます。これがローテーション用のリングであり、ダウンタイムなしで回す方法は 鍵のローテーション にあります。- サーバーの名前は、ほとんど誰も見つけないほうの部分であり、鍵を共有してもインスタンスをまたぐリトライが失敗し続ける理由です。封印されたトークンはすべて、サーバーの
nameを audience クレームとして持ち、戻ってくるときに厳密にチェックされます。同じコードから構築された 2 つのインスタンスは同じ名前を持つので、これに気づくことはありません。名前を分けると(MCPServer(f"billing-{POD}")は可観測性の作法としてよさそうに見えます)、鍵を共有していようといまいと、インスタンスをまたぐリトライはすべて上とまったく同じように拒否されます。ログにはunknown keyの代わりにaudienceと出ますが、クライアントには違いがわかりません。
シークレットは一度だけ生成し、同じ値をすべてのインスタンスに渡します。次は、32 バイト未満を渡したときに SDK 自身のエラーメッセージが実行を促すコマンドです。
python -c "import secrets; print(secrets.token_hex(32))"
鍵も同じ、そして名前も同じ
マルチインスタンスのデプロイでは、両方を共有しなければなりません。インスタンスごとの名前が欠かせないのであれば、代わりにフリート全体に明示的な audience を 1 つ与えます。RequestStateSecurity(keys=[...], audience="billing") とすれば、どのインスタンスも、何という名前であっても "billing" で発行し、受け付けます。
封印に関するそれ以外のすべて、つまり何をバインドするか、ラウンドごとの ttl(デフォルトで 600 秒)、独自のコーデックの持ち込み、未設定のデフォルトが stdio ではまさに正しい理由については、requestState の保護 を参照してください。このページの貢献は、2 項目のチェックリストに尽きます。「鍵も同じ、名前も同じ」、これだけです。
Info
InputRequiredResult を一度もタイプしたことがなくても、この経路には乗っています。パラメーターに Resolve(...)(依存関係)を使うツールはマルチラウンドトリップのツールであり、SDK がその request_state を代わりに発行して封印します。デフォルトの鍵も同じ、ワーカーをまたいだときの失敗も同じ、解決策も同じです。
レプリカをまたぐ変更通知
クライアントの subscriptions/listen ストリームは 1 つの長寿命なレスポンスなので、その一生の間 1 つのレプリカに固定されます。別のレプリカで発行された ctx.notify_resource_updated(...) は、そこに届かなければなりません。
両者の継ぎ目が SubscriptionBus です。サーバーに与えたバスが、すべての publish の送り先であり、開いているすべてのストリームの待ち受け先です。ですから、すべてのレプリカに同じバスを渡します。
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.subscriptions import SubscriptionBus
NOTES = {"todo": "buy milk"}
def make_server(bus: SubscriptionBus) -> MCPServer:
"""Every replica gets its own server object; all of them hold the same bus."""
mcp = MCPServer("Notebook", subscriptions=bus)
@mcp.resource("note://{name}")
def note(name: str) -> str:
"""One note, by name."""
return NOTES[name]
@mcp.tool()
async def edit_note(name: str, text: str, ctx: Context) -> str:
"""Replace a note's text."""
NOTES[name] = text
await ctx.notify_resource_updated(f"note://{name}")
return "saved"
return mcp
ファンアウトは、ストリームがどのサーバーオブジェクトに紐づいているかを一切気にしません。1 つの InMemorySubscriptionBus を共有する 2 つのサーバーは、すでにこのように振る舞います。一方で listen ストリームを開き、もう一方で edit_note を実行すれば、ストリームにそれが届きます。このインメモリのバスがまたげるのは 1 つのプロセス内のサーバーオブジェクトだけなので、これはモデルであって、デプロイ方法ではありません。
- 本物のプロセスをまたぐ場合、SDK には役に立つバスが同梱されていません。
SubscriptionBusは 2 つのメソッド(publishとsubscribe)からなるProtocolであり、自前の pub/sub バックエンド(Redis、NATS、そのほかすでに運用しているもの)の上に実装して、MCPServer(subscriptions=...)として渡します。スケッチと契約は サブスクリプション にあります。 - バスが運ぶのは 4 種類の小さな型付きイベントであり、JSON-RPC ではありません。確認応答、フィルタリング、ストリームのライフサイクルは SDK に残るので、バスがプロトコルを壊すことはできません。できるのはプロセス間でイベントを運ぶことだけです。
- ストリームは再開可能ではなく、イベントはリプレイされません。レプリカを失えばそのストリームは切れ、クライアントは listen し直し、取得し直します。共有すべきイベントストアはなく、ほかに設定するものもありません。スケールアウトが本当に「同じことを増やすだけ」で済むのは、ここだけです。
SDK が提供しないもの
MCPServer はプロトコルの実装であり、アプリケーションサーバーではありません。次に探しに行くであろうデプロイ用の設定項目は、意図的に存在しません。
workers=はありません。mcp.run("streamable-http")はちょうど 1 つの uvicorn プロセスを起動し、それ以上起動することは決してありません。マルチプロセスにするには、streamable_http_app()を、すでに ASGI のデプロイに使っているもの(uvicorn --workers、gunicorn、プラットフォームのプロセスマネージャー)に渡します。このページは意図的に、それらのどれのチュートリアルにもなっていません。それぞれのドキュメントのほうが、ここに写しを置くより優れているからです。- ヘルスチェック用のルートはありません。 答えは
@mcp.custom_route("/health", methods=["GET"])に尽きます。そして、サーバーの残りが認証付きであっても、これは決して認証されません。これは liveness プローブには正しく、非公開のものには不適切です。既存のアプリに追加する に例があります。 - 本番用の設定オブジェクトはありません。 タイムアウト、TLS、グレースフルシャットダウン、接続数の上限を書き込む場所は
MCPServerのどこにもありません。どれもその仕事ではないからです。それらは ASGI サーバーの領分であり、そこで設定します。コンストラクターが実際に受け取る少数の設定については サーバーの実行 で扱っています。 - 同梱の
EventStoreはなく、2026-07-28 ではその使い道もありません。 再開可能性はレガシーのステートフルな経路の機能です。モダンなやり取りは POST が 1 つ、レスポンスが 1 つで、再開するものは何もありません。
まとめ
- デフォルトでは、このアプリは localhost 宛てのリクエストにだけ応答します。
transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])が公開時の関門です。これを渡すまでは、本物のホスト名の背後ではすべてのリクエストが421になり、理由はサーバーのログにしか出ません。 - 2026-07-28 ではセッションはなく、ロードバランサーがスティッキーにすべき対象もありません。
stateless_http=Trueがレガシー専用の設定項目なのは、モダンなリクエストはこのフラグが読まれる前にルーティングされ、応答されるからです。 - デフォルトの
requestStateの鍵は、プロセスごとに生成されるos.urandom(32)です。別のワーカーに届いたマルチラウンドトリップのリトライは、-32602"Invalid or expired requestState" で失敗します。 - 解決策は
RequestStateSecurity(keys=[...])と、すべてのインスタンスで同じサーバー名にすることです。名前はトークンのデフォルトの audience クレームです。鍵も同じ、名前も同じ。 - 変更通知は、共有された 1 つの
SubscriptionBusを通じてレプリカをまたぎます。SDK の唯一の実装はプロセス内のものです。自前の pub/sub 上に 2 メソッドのProtocolを書くのは、自分の仕事です。 workers=も、ヘルスチェック用のルートも、本番用の設定オブジェクトもありません。ASGI サーバーは自分で用意してください。
本物のホスト名の前に必要なもう 1 つのものはトークンです。認可 に進んでください。