docs: publish translated docs in twelve languages and the tool that maintains them (#3280)

This commit is contained in:
Max 2026-08-14 17:07:34 +01:00 • committed by GitHub
commit 52ad0a8876
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 89737 additions and 54 deletions

130
i18n/ja/pages/run/asgi.md Normal file
View file

@ -0,0 +1,130 @@
---
translation:
sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3]
tool: 1
---
# 既存のアプリに組み込む {#add-to-an-existing-app}
`mcp.run("streamable-http")` は Web サーバーを起動してくれます。ただ、そうしたくない場合もあります。MCP サーバーがより大きな Web アプリケーションの一部である場合や、すでに ASGI のデプロイ環境がある場合です。
そのために、`mcp.streamable_http_app()` は **Starlette アプリケーション**を返します。
Starlette アプリは ASGI アプリなので、ASGI をホストできるもの(uvicorn、Hypercorn、別の Starlette、FastAPI)なら何でも MCP サーバーをホストできます。
## アプリ {#the-app}
```python title="server.py" hl_lines="12"
--8<-- "docs_src/asgi/tutorial001.py"
```
`app` は普通の ASGI アプリケーションです。任意の ASGI サーバーに渡せます。
```console
uvicorn server:app
```
MCP エンドポイントは `/mcp` にあるので、クライアントは `http://127.0.0.1:8000/mcp` に接続します。
このアプリには最初から 2 つのものが備わっています。
* ルートが 1 つ(`/mcp`)。Streamable HTTP のエンドポイントです。
* **ライフスパン**。`mcp.session_manager` を起動します。これは、稼働中のすべてのセッションのバックグラウンド処理を管理するオブジェクトです。
アプリを単体で動かす(`uvicorn server:app`)なら、どちらも意識することはありません。
!!! tip
`streamable_http_app()` は `mcp.run("streamable-http", ...)` と同じキーワード引数を受け取ります。ただし `port` は除きます。ポートはアプリを配信する側のものだからです。`host` は引き続き受け付けますが、ここでは何もバインドしません。実際に何を制御するのかは **[デプロイとスケール](deploy.md)** で説明しています。オプションそのものは **[サーバーの実行](index.md)** で扱っています。
`mcp.sse_app()` は、すでに置き換えられた SSE トランスポート向けに同じものを提供します。
## 指定しない限り localhost のみ {#localhost-only-until-you-say-otherwise}
デフォルトでは、このアプリは localhost 宛てのリクエストに**だけ**応答します。`streamable_http_app()` は自分がどのホスト名の背後で配信されるのか知りようがないため、もっとも安全な許可リストで DNS リバインディング保護を有効にします。手元のマシンではまさにそれが正解です。実際のホスト名の背後にデプロイすると、`transport_security=` に実際に配信するホストの許可リストを渡すまで、**すべてのリクエストが `421 Misdirected Request` で拒否されます**。作成したものは何ひとつ参照すらされません。その許可リストをはじめ、動くアプリと実際のホスト名との間にあるものすべてについては、**[デプロイとスケール](deploy.md)** を参照してください。
## マウントする {#mounting-it}
MCP サーバーがより大きなアプリケーションの「一部」になった瞬間、このアプリは `Mount` の中に置くことになります。そしてそうした瞬間、ライフスパンは自分で面倒を見るべきものになります。
```python title="server.py" hl_lines="18-21 25-26"
--8<-- "docs_src/asgi/tutorial002.py"
```
* `Mount("/", ...)` とデフォルトの `/mcp` パスの組み合わせで、エンドポイントは `/mcp` のままです。Starlette はルートを順に試し、`Mount("/")` は**すべての**パスにマッチします。そのため、自前のルートはリストの中でこれより「前」に置きます。後ろにあるものには到達できません。
* `lifespan` 関数は、**ホスト**アプリが生きている間ずっと `mcp.session_manager.run()` に入った状態を保ちます。これは誰もが忘れる 1 行です。
* `mcp.session_manager` は `streamable_http_app()` が呼ばれた「後」でしか存在しません。ルートをモジュールレベルで組み立て、マネージャーにはライフスパンの中でだけ触れているのはそのためです。
Starlette の `Host` ルートも同じように動きます。`Mount("/", ...)` を `Host("mcp.example.com", ...)` に差し替えれば、パスではなくホスト名でルーティングできます。ライフスパンのルールは変わりませんし、トランスポートセキュリティのルールも変わりません。`Host("mcp.example.com", ...)` ルートはそのホスト名宛てのリクエストしか受け取りませんが、トランスポート自身の Host 許可リスト(**[デプロイとスケール](deploy.md)**)は依然として先に実行されます。そこに `"mcp.example.com"` がなければ、このルートはすべてのリクエストに `421` で応答します。
!!! warning "ライフスパンはホストアプリのもの"
`streamable_http_app()` は、返す Starlette のライフスパンに `session_manager.run()` を組み込みますが、**マウントされたサブアプリケーションのライフスパンは決して実行されません**。アプリをマウントすると、その組み込みのライフスパンはデッドコードになります。ASGI スタックの最上位にあるアプリが、自身のライフスパンで `mcp.session_manager.run()` に入らなければなりません。
!!! check
`lifespan=lifespan` の行を削除してサーバーを起動してみてください。起動します。ルートも解決されます。そして `/mcp` への最初のリクエストが次のエラーで失敗します。
```text
RuntimeError: Task group is not initialized. Make sure to use run().
```
セッションマネージャーを起動するのは、その `run()` だけです。
## 2 つのサーバー、1 つのアプリ {#two-servers-one-app}
各 `MCPServer` は、それぞれ独自のセッションマネージャーを持つ独立したアプリです。好きなだけマウントし、すべてのマネージャーに 1 つのホストのライフスパンから入ってください。
```python title="server.py" hl_lines="27-30 35-36"
--8<-- "docs_src/asgi/tutorial003.py"
```
* `AsyncExitStack` が両方のマネージャーに入ります。2 つは一緒に起動し、逆順でシャットダウンします。
* エンドポイントは `/notes/mcp` と `/tasks/mcp` です。マウントのプレフィックスにデフォルトのパスを足したものです。
## パスを変える {#changing-the-path}
末尾の `/mcp` は `streamable_http_path` です。これを `"/"` にすると、マウントのプレフィックスがそのまま公開パス全体になります。
```python title="server.py" hl_lines="25"
--8<-- "docs_src/asgi/tutorial004.py"
```
これでクライアントは `/notes/mcp` ではなく `/notes` に接続します。
## ブラウザークライアント向けの CORS {#cors-for-browser-clients}
ブラウザーベースのクライアントには 2 つの許可が必要です。MCP のリクエストヘッダーを**送る**許可と、MCP が返すヘッダーを**読む**許可です。どちらもホストアプリ側の CORS 設定であり、上で触れたトランスポートセキュリティの許可リストもそれと一致している必要があります。
```python title="server.py" hl_lines="27-30 33 35-49"
--8<-- "docs_src/asgi/tutorial005.py"
```
* `allow_headers` は誰もが忘れるほうの半分です。ブラウザーは MCP リクエストのたびに**プリフライト**を行います。`Content-Type: application/json` と `Mcp-*` リクエストヘッダーは CORS のセーフリストに載っていないためです。そして、プリフライトで許可されなかったヘッダーがあれば、ブラウザーはそのリクエストを決して送りません。(`allow_headers=["*"]` でも動きます。Starlette はプリフライトに対して、要求されたものをそのまま返すからです。)
* `expose_headers=["Mcp-Session-Id"]` は読む側の半分です。Streamable HTTP はセッション ID をこのレスポンスヘッダーで返しますが、ブラウザーは CORS で名前を指定して公開しない限り、レスポンスヘッダーを JavaScript から隠します。これがないと、クライアントは 2 回目のリクエストを決して送れません。
* `allow_origins` は MCP ではなく自分で決めることです。厳密に指定し、上の `allowed_origins=` にも同じ内容を反映してください。CORS を強制するのはブラウザーですが、サーバー自身も `Origin` を検査します。トランスポートが信頼しないオリジンは、プリフライトが問題なく通った後でも `403` になります。
* `allow_methods` には Streamable HTTP が使う 3 つのメソッドを列挙します。メッセージを送る `POST`、サーバーからクライアントへのストリームを開く `GET`、セッションを終える `DELETE` です。
## カスタムルート {#custom-routes}
`@mcp.custom_route()` は、同じアプリ上に素の HTTP エンドポイントを登録します。デプロイされたサービスなら必ず必要になるものの、MCP とは何の関係もないもの、たとえばヘルスチェックや OAuth コールバックのためのものです。
```python title="server.py" hl_lines="15-17"
--8<-- "docs_src/asgi/tutorial006.py"
```
* ハンドラーは素の Starlette です。`Request` を受け取って `Response` を返す `async` 関数です。
* `streamable_http_app()` はすべてのカスタムルートを拾います。`app.routes` は今や `/mcp` と `/health` です。
* `GET /health` は `{"status": "ok"}` を返し、MCP はどこにも出てきません。
!!! warning
カスタムルートは**決して認証されません**。サーバーのほかの部分が認証されている場合でもです。これは意図的なものです。ヘルスチェックや OAuth コールバックは、トークンが 1 つも存在しない段階で到達できなければならないからです。非公開のものをカスタムルートの背後に置かないでください。
## まとめ {#recap}
* `mcp.streamable_http_app()` は、`/mcp` というルートを 1 つ持つ Starlette アプリを返します。どの ASGI サーバーでも実行できます。
* デフォルトでは、このアプリは localhost 宛てのリクエストにだけ応答し、実際のホスト名の背後では `transport_security=` に許可リストを渡すまですべてを `421` で拒否します。そこは **[デプロイとスケール](deploy.md)** の担当で、本番環境までの残りの道のりも同様です。
* `Mount`(または `Host`)で、より大きな Starlette や FastAPI のアプリの中に置けます。
* **マウントすると組み込みのライフスパンは無効になります。** ホストアプリのライフスパンで `mcp.session_manager.run()` に入らなければ、最初のリクエストが失敗します。
* 1 つのアプリに複数のサーバーを載せるなら、マウントを複数用意し、すべてのセッションマネージャーに入るライフスパンを 1 つ用意します。
* `streamable_http_path="/"` で、エンドポイントはマウントのプレフィックスそのものに移ります。
* ブラウザークライアントには CORS が必要です。`Mcp-*` リクエストヘッダーのための `allow_headers` と、レスポンスのための `expose_headers=["Mcp-Session-Id"]` です。
* `@mcp.custom_route()` は、認証なしの素の HTTP エンドポイントを `/mcp` の隣に追加します。
サーバーに実際の URL で到達できるようになったら、**[クライアント](../client/index.md)** はサーバーオブジェクトの代わりにその URL を使って接続します。

View file

@ -0,0 +1,122 @@
---
translation:
sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31]
tool: 1
---
# 認可 {#authorization}
Streamable HTTP を使うと、MCP サーバーはごく普通の Web サービスになります。保護の仕方もほかの Web サービスと同じで、OAuth 2.1 のベアラートークンを使います。
OAuth の用語でいえば、サーバーは**リソースサーバー**です。誰かをサインインさせることはなく、トークンを発行することもありません。やることは 1 つだけです。各リクエストの `Authorization` ヘッダーを見て、そこに入っているトークンが有効かどうかを判断します。
このページはサーバー側の話です。認可サーバーを見つけてトークンを取得するクライアントについては、**[OAuth クライアント](../client/oauth-clients.md)**を参照してください。
## 3 つの当事者 {#the-three-parties}
* **認可サーバー**はユーザーをサインインさせ、アクセストークンを発行します。これを自分で書くことはありません。ID プロバイダー(Auth0、Keycloak、Entra、自前のもの)がこれにあたります。
* **リソースサーバー**は MCP サーバーです。リクエストごとにトークンを検証します。
* **クライアント**は、サーバーがどの認可サーバーを信頼しているかを見つけ、そこからトークンを取得し、`Authorization: Bearer <token>` として送り返してきます。
三角形はこれで全部です。このページで扱うのはすべて真ん中の項目です。
## トークンベリファイアー {#a-token-verifier}
有効なトークンがどんな形をしているかについて、SDK は何の前提も持ちません。**`TokenVerifier`** を実装して、こちらから伝えます。
```python title="server.py" hl_lines="12-14 19-24"
--8<-- "docs_src/authorization/tutorial001.py"
```
* `TokenVerifier` は非同期メソッドを 1 つだけ持つプロトコルです。`verify_token` は `Authorization` ヘッダーから取り出した生のトークンを受け取り、有効なら **`AccessToken`** を、無効なら `None` を返します。実装するものはほかにありません。
* この例ではトークンをテーブルから引いています。実際のものは JWT の署名を検証するか、認可サーバーのトークンイントロスペクションエンドポイントを呼び出します。そのコードは自分で書きます。SDK はそれを呼び出すだけです。
* `token_verifier=` と `auth=` は必ずセットで渡します。片方だけ渡すと、`MCPServer(...)` はリクエストを 1 つも処理しないうちに `ValueError` を送出します。
`AuthSettings` はリソースサーバーの表向きの顔です。
* `issuer_url`:トークンを発行する認可サーバー。
* `resource_server_url`:この MCP エンドポイントの公開 URL。トークンが「どの」リソース向けかを示す名前であり、ディスカバリードキュメントが置かれる場所でもあります。
* `required_scopes`:すべてのトークンがこれらをすべて持っている必要があります。
!!! tip
SDK リポジトリの `examples/servers/simple-auth/` には、実際の認可サーバーの [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) エンドポイントを呼び出す `IntrospectionTokenVerifier` があります。本番用のベリファイアーの多くはこの形になります。
## HTTP で得られるもの {#what-you-get-over-http}
認可は HTTP ヘッダーに乗るので、HTTP トランスポートにしか存在しません。デプロイするトランスポートで実行してください。`mcp.run(transport="streamable-http")` とすると `http://127.0.0.1:8000/mcp` で動きます。そのほかについては**[サーバーの実行](index.md)**を参照してください。これでアプリには 2 つのルートができます。
```text
/mcp
/.well-known/oauth-protected-resource/mcp
```
登録したのはツール 1 つです。2 つ目のルートは SDK が用意したものです。
### ディスカバリー {#discovery}
この well-known パスに `GET` すると、`AuthSettings` からそのまま組み立てられた **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata** が返ります。
```json
{
"resource": "http://127.0.0.1:8000/mcp",
"authorization_servers": ["https://auth.example.com/"],
"scopes_supported": ["notes:read"],
"bearer_methods_supported": ["header"]
}
```
このサーバーのことを何も知らないクライアントは、このドキュメントを手がかりに入口を見つけます。`authorization_servers` を読み、そこへトークンを取りに行きます。このドキュメントは 1 行も自分では書いていません。
!!! check
トークンなしで(あるいはベリファイアーが `None` を返したトークンで)`/mcp` を呼び出すと、リクエストは入口で止められます。
```text
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
{"error": "invalid_token", "error_description": "Authentication required"}
```
何もパースされず、ツールも実行されていません。そして `WWW-Authenticate` にある `resource_metadata` のポインターこそが、ディスカバリーを自動化する仕掛けです。401 -> メタデータドキュメント -> 認可サーバー -> トークン -> 再試行、という流れです。
!!! warning
これらはどれも `stdio` を保護しません。パイプには `Authorization` ヘッダーがないので、そこで `token_verifier` が参照されることはありません。`stdio` サーバーのセキュリティ境界は、それを起動したプロセスです。テストで使うインメモリの `Client(mcp)` も同じです。サーバーオブジェクトに直接接続し、認可を含む HTTP レイヤーを丸ごと飛ばします。
## 呼び出し側の ID {#the-callers-identity}
どのハンドラーの中でも、**`get_access_token()`** は現在のリクエストに対してベリファイアーが返した `AccessToken` です。
```python title="server.py" hl_lines="4 32-35"
--8<-- "docs_src/authorization/tutorial002.py"
```
* ツール、リソース、プロンプトのどれでも動き、持ち回る必要のあるものはありません。認証ミドルウェアがリクエストごとにコンテキスト変数へ保存しています。
* 返ってくるのは**ベリファイアーが組み立てたのと同じオブジェクト**です。`client_id`、`scopes`、`subject`、`expires_at`、そして追加で付けた `claims` が入っています。ツールごとのルールはここに掛けます。スコープを読んで、拒否するだけです。
* 認証済みの HTTP リクエストの外では `None` を返します。インメモリと `stdio` では常に `None` です。
`Authorization: Bearer alice-token` を付けて `whoami` を呼び出すと、モデルは次のテキストを読みます。
```text
alice (scopes: notes:read)
```
## SDK がやらない半分 {#the-half-the-sdk-doesnt-do}
SDK が提供するのはリソースサーバーの半分、つまり検証、告知、拒否です。ログインページも、同意画面も、トークンも提供しません。
3 つの当事者すべてが動く様子を見るには、SDK リポジトリの `examples/servers/simple-auth/`(小さな認可サーバーと、このページとまったく同じように構成されたリソースサーバー)を実行し、そこへ `examples/clients/simple-auth-client/` を向けてください。ディスカバリーからトークン取得までの一連の流れを追えます。
!!! info
コンストラクターにはもう 1 つ、`auth_server_provider=` という引数があり、完全な認可サーバーを MCP サーバーの中に埋め込みます。これは MCP の認可仕様が土台にしている AS/RS 分離より前からあるものです。新しく作るサーバーでは使うべきではありません。
認可サーバーは、ユーザーが同意画面をクリックして進む代わりに、企業の ID プロバイダーが署名したアサーションを受け付けることもできます。SDK はこのやり取りの両側をサポートしています。このグラントと、それを提示するクライアントについては、**[ID アサーション](../client/identity-assertion.md)**を参照してください。
## まとめ {#recap}
* Streamable HTTP では、サーバーは OAuth 2.1 の**リソースサーバー**です。トークンを検証しますが、発行することはありません。
* 統合の接点は `TokenVerifier` がすべてです。非同期メソッドが 1 つ、トークンを受け取り、`AccessToken | None` を返します。
* `token_verifier=` と `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` は必ずセットで渡します。
* SDK は [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata を `/.well-known/oauth-protected-resource/...` で公開し、未認証のリクエストには、そこを指す `WWW-Authenticate` ヘッダー付きの 401 で応答します。ディスカバリーの仕組みはこれだけです。
* どのハンドラーでも、`get_access_token()` を呼べば誰が呼び出しているかがわかります。
* 認可は HTTP の関心事です。`stdio` とインメモリクライアントがそれを目にすることはありません。
クライアント側の半分(認可サーバーを見つけてトークンを取得してくれる部分)については、**[OAuth クライアント](../client/oauth-clients.md)**を参照してください。そして、ユーザーに尋ねる代わりに ID を「アサート」するクライアントについては、**[ID アサーション](../client/identity-assertion.md)**を参照してください。

163
i18n/ja/pages/run/deploy.md Normal file
View file

@ -0,0 +1,163 @@
---
translation:
sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a]
tool: 1
---
# デプロイとスケール {#deploy-scale}
サーバーは動いています。次に必要なのは本物のホスト名と、その背後で動く複数のワーカーです。
そのほとんどは MCP の管轄外です。ASGI サーバー、プロセスマネージャー、ロードバランサーは各自で用意します。このページにあるのは、本当に MCP の管轄に入るものだけを集めた短いリストです。すべてのデプロイの関門となる設定が 1 つと、「複数のワーカー」によって SDK の動作が変わる箇所が 2 つです。
## まず確認すべきこと:Host の許可リスト {#before-anything-else-the-host-allowlist}
`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 に関わるどんな処理よりも前に実行されるので、自分で作ったものは一切参照されません。
```text
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=` です。実際に配信するものを許可リストに入れます。
```python title="server.py" hl_lines="2 13-17"
--8<-- "docs_src/deploy/tutorial001.py"
```
* `allowed_hosts` のエントリは完全一致の文字列です。`"mcp.example.com"` はポートなしの `Host` ヘッダーに一致し、`"mcp.example.com:*"` は任意のポートに一致します。両方を並べてください。
* `allowed_origins` が意味を持つのはブラウザーに対してだけです。ほかに `Origin` を送るものはないからです。これは **[既存のアプリに追加する](asgi.md)** で扱う 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` からのものも含めて)が次のように返ってきます。
```text
HTTP/1.1 421 Misdirected Request
Invalid Host header
```
この文言はクライアント側では見つかりません。`421` は JSON-RPC エラーではなくプレーンテキストの HTTP レスポンスなので、MCP クライアントは汎用的なトランスポートエラーを送出します。気に入らなかったホスト名は**サーバー**のログに、警告として 1 行出るだけです。デプロイしたばかりのサーバーがすべての接続を拒否するなら、そうでないと証明されるまでは Host の許可リストが原因です。**[トラブルシューティング](../troubleshooting.md)** もここから始まります。
## ワーカーと、スティッキーにする必要があるのは誰か {#workers-and-who-has-to-be-sticky}
ホスト名が応答するようになったら、その背後に複数のワーカーを置きます。そのための SDK の設定項目はありません。Starlette アプリは、どんな ASGI アプリとも同じ方法でスケールします。fork の仕方を知っているものにオブジェクトを渡すだけです。
```console
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` と、再開可能性です。 |
スティッキーセッションと、レガシー側の経路の代償については、専用のページ **[レガシークライアントへの対応](legacy-clients.md)** があります。2 つの世代そのものについては **[プロトコルバージョン](../protocol-versions.md)** を参照してください。ここで重要なのは答えの形です。2026-07-28 ではすでにステートレスであり、設定するものは何もありません。
このページの残りは、ステートレスになっても解決**しない** 2 つの事柄です。
## ワーカーをまたぐ `requestState` {#requeststate-across-workers}
**[マルチラウンドトリップ(multi-round-trip)](../handlers/multi-round-trip.md)** のツールは、クライアントが取りに行かなければならないもの(確認、選択、資格情報)を必要とします。そのため答えの代わりに質問を返し、リトライで完了します。2 つのラウンドの間、クライアントはサーバーが発行した不透明な `request_state` トークンを保持します。リトライ時には、サーバーがそのトークンをもう一度開けなければなりません。
では、どの鍵で封印されているのでしょうか。デフォルトでは、サーバーが構築時に `os.urandom(32)` で生成した鍵です。`--workers 4` では、4 つのプロセスで 4 回構築されます。つまり 4 つの異なる鍵があり、どこにも書き出されず、共有もされず、再起動すれば消えます。
次は、何も設定していないサーバー上で、実行前に確認を取るツールです。
```python title="server.py" hl_lines="14 20"
--8<-- "docs_src/deploy/tutorial002.py"
```
1 回目のラウンドはワーカー A に届きます。ワーカー A は**自分の**鍵で `refund:120` を封印し、トークンを返します。クライアントは質問を人に提示し、承諾を得て、リトライします。リトライはまったく新しい HTTP リクエストです。
!!! check
そのリトライがワーカー B に届いたとします。B は自分が発行していないトークンの開封を試み、できず、ラウンド全体を拒否します。`refund` は呼び出されず、クライアントは JSON-RPC エラーを受け取ります。
```json
{
"code": -32602,
"message": "Invalid or expired requestState",
"data": {"reason": "invalid_request_state"}
}
```
このメッセージは**固定**です。期限切れでも、改ざんされていても、別の引数に対してリプレイされていても、あるいは(実際のデプロイで群を抜いて多い原因である)兄弟ワーカーが封印したものであっても、クライアントには毎回同じことが伝えられます。そのため、どのチェックに失敗したかは通信上には現れません。本当の理由は、サーバーのログに出る 1 行の `WARNING` です。
```text
requestState rejected on tools/call: unknown key
```
ワーカーが 1 つなら動いていたマルチラウンドトリップのツールが、2 つにしたとたん「ときどき」失敗し始めたなら、原因はこれです。両方のラウンドは依然として同じプロセスに届かなければならないので、ロードバランサーがそれらを引き離すのとちょうど同じ頻度で失敗します。
2 つのラウンドは独立した 2 つの HTTP リクエストであり、ごくありふれたことがいくつもそれらを引き離します。リクエスト単位で振り分けるプロキシ、間で切れた接続、デプロイや再起動、`request_state` を永続化してまったく別のプロセスから再開するクライアント(**[ループを自分で回す](../handlers/multi-round-trip.md#driving-the-loop-yourself)**)などです。どれも「別のワーカー」です。
解決策は引数 1 つです。ただし、それは **2 つ**の部分からなります。
```python title="server.py" hl_lines="1 12 14"
--8<-- "docs_src/deploy/tutorial003.py"
```
* **`keys=[...]`** は、誰もが見つけるほうの部分です。すべてのインスタンスに同じシークレット(少なくとも 32 バイト)を与えれば、どのインスタンスも兄弟が発行したものを開封できます。`keys[0]` が封印し、リストのすべての鍵が開封できます。これがローテーション用のリングであり、ダウンタイムなしで回す方法は **[鍵のローテーション](../handlers/multi-round-trip.md#rotating-keys)** にあります。
* **サーバーの名前**は、ほとんど誰も見つけないほうの部分であり、鍵を共有してもインスタンスをまたぐリトライが失敗し続ける理由です。封印されたトークンはすべて、サーバーの `name` を **audience クレーム**として持ち、戻ってくるときに厳密にチェックされます。同じコードから構築された 2 つのインスタンスは同じ名前を持つので、これに気づくことはありません。名前を分けると(`MCPServer(f"billing-{POD}")` は可観測性の作法としてよさそうに見えます)、鍵を共有していようといまいと、インスタンスをまたぐリトライはすべて上とまったく同じように拒否されます。ログには `unknown key` の代わりに `audience` と出ますが、クライアントには違いがわかりません。
シークレットは一度だけ生成し、同じ値をすべてのインスタンスに渡します。次は、32 バイト未満を渡したときに SDK 自身のエラーメッセージが実行を促すコマンドです。
```console
python -c "import secrets; print(secrets.token_hex(32))"
```
!!! warning "鍵も同じ、そして名前も同じ"
マルチインスタンスのデプロイでは、両方を共有しなければなりません。インスタンスごとの名前が欠かせないのであれば、代わりにフリート全体に明示的な audience を 1 つ与えます。`RequestStateSecurity(keys=[...], audience="billing")` とすれば、どのインスタンスも、何という名前であっても `"billing"` で発行し、受け付けます。
封印に関するそれ以外のすべて、つまり何をバインドするか、ラウンドごとの `ttl`(デフォルトで 600 秒)、独自のコーデックの持ち込み、未設定のデフォルトが `stdio` ではまさに正しい理由については、**[`requestState` の保護](../handlers/multi-round-trip.md#protecting-requeststate)** を参照してください。このページの貢献は、2 項目のチェックリストに尽きます。「鍵も同じ、名前も同じ」、これだけです。
!!! info
`InputRequiredResult` を一度もタイプしたことがなくても、この経路には乗っています。パラメーターに `Resolve(...)`(**[依存関係](../handlers/dependencies.md)**)を使うツールはマルチラウンドトリップのツールであり、SDK がその `request_state` を代わりに発行して封印します。デフォルトの鍵も同じ、ワーカーをまたいだときの失敗も同じ、解決策も同じです。
## レプリカをまたぐ変更通知 {#change-notifications-across-replicas}
クライアントの `subscriptions/listen` ストリームは 1 つの長寿命なレスポンスなので、その一生の間 1 つのレプリカに固定されます。**別の**レプリカで発行された `ctx.notify_resource_updated(...)` は、そこに届かなければなりません。
両者の継ぎ目が `SubscriptionBus` です。サーバーに与えたバスが、すべての publish の送り先であり、開いているすべてのストリームの待ち受け先です。ですから、すべてのレプリカに同じバスを渡します。
```python title="server.py" hl_lines="2 7 9"
--8<-- "docs_src/deploy/tutorial004.py"
```
ファンアウトは、ストリームがどのサーバーオブジェクトに紐づいているかを一切気にしません。1 つの `InMemorySubscriptionBus` を共有する 2 つのサーバーは、すでにこのように振る舞います。一方で listen ストリームを開き、もう一方で `edit_note` を実行すれば、ストリームにそれが届きます。このインメモリのバスがまたげるのは 1 つのプロセス内のサーバーオブジェクトだけなので、これはモデルであって、デプロイ方法ではありません。
* 本物のプロセスをまたぐ場合、**SDK には役に立つバスが同梱されていません。** `SubscriptionBus` は 2 つのメソッド(`publish` と `subscribe`)からなる `Protocol` であり、自前の pub/sub バックエンド(Redis、NATS、そのほかすでに運用しているもの)の上に実装して、`MCPServer(subscriptions=...)` として渡します。スケッチと契約は **[サブスクリプション](../handlers/subscriptions.md#scaling-past-one-process)** にあります。
* バスが運ぶのは 4 種類の小さな型付きイベントであり、JSON-RPC ではありません。確認応答、フィルタリング、ストリームのライフサイクルは SDK に残るので、バスがプロトコルを壊すことはできません。できるのはプロセス間でイベントを運ぶことだけです。
* ストリームは再開可能**ではなく**、イベントはリプレイ**されません**。レプリカを失えばそのストリームは切れ、クライアントは listen し直し、取得し直します。共有すべきイベントストアはなく、ほかに設定するものもありません。スケールアウトが本当に「同じことを増やすだけ」で済むのは、ここだけです。
## SDK が提供しないもの {#what-the-sdk-does-not-give-you}
`MCPServer` はプロトコルの実装であり、アプリケーションサーバーではありません。次に探しに行くであろうデプロイ用の設定項目は、意図的に存在しません。
* **`workers=` はありません。** `mcp.run("streamable-http")` はちょうど 1 つの uvicorn プロセスを起動し、それ以上起動することは決してありません。マルチプロセスにするには、`streamable_http_app()` を、すでに ASGI のデプロイに使っているもの(`uvicorn --workers`、gunicorn、プラットフォームのプロセスマネージャー)に渡します。このページは意図的に、それらのどれのチュートリアルにもなっていません。それぞれのドキュメントのほうが、ここに写しを置くより優れているからです。
* **ヘルスチェック用のルートはありません。** 答えは `@mcp.custom_route("/health", methods=["GET"])` に尽きます。そして、サーバーの残りが認証付きであっても、これは決して認証されません。これは liveness プローブには正しく、非公開のものには不適切です。**[既存のアプリに追加する](asgi.md#custom-routes)** に例があります。
* **本番用の設定オブジェクトはありません。** タイムアウト、TLS、グレースフルシャットダウン、接続数の上限を書き込む場所は `MCPServer` のどこにもありません。どれもその仕事ではないからです。それらは ASGI サーバーの領分であり、そこで設定します。コンストラクターが実際に受け取る少数の設定については **[サーバーの実行](index.md)** で扱っています。
* **同梱の `EventStore` はなく、2026-07-28 ではその使い道もありません。** 再開可能性はレガシーのステートフルな経路の機能です。モダンなやり取りは POST が 1 つ、レスポンスが 1 つで、再開するものは何もありません。
## まとめ {#recap}
* デフォルトでは、このアプリは 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 つのものはトークンです。**[認可](authorization.md)** に進んでください。

150
i18n/ja/pages/run/index.md Normal file
View file

@ -0,0 +1,150 @@
---
translation:
sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0]
tool: 1
---
# サーバーの実行 {#running-your-server}
`mcp.run()` がサーバーを起動します。
決めることはただ 1 つ、**トランスポート**です。サーバーとクライアントの間でバイト列が実際にどうやり取りされるかを指します。
## トランスポートを選ぶ {#pick-a-transport}
| トランスポート | 概要 | 使う場面 |
|---|---|---|
| `stdio` | ホストがファイルをサブプロセスとして起動し、その stdin と stdout を介して通信します。 | ローカルサーバー。デフォルトです。 |
| `streamable-http` | ポートで待ち受ける本物の HTTP サーバーです。 | デプロイするものすべて。 |
| `sse` | 古い HTTP トランスポートです。 | 使いません。 |
!!! warning
SSE は 2025-03-26 のプロトコル改訂で Streamable HTTP に置き換えられました。
`mcp.run(transport="sse")` は今も動作し、専用の `sse_path=` と `message_path=` オプションもありますが、まだ移行していないクライアントのために残されているだけです。新しいものをこの上に作らないでください。
## `mcp.run()` {#mcprun}
```python title="server.py" hl_lines="12-13"
--8<-- "docs_src/run/tutorial001.py"
```
* `run()` は同期的です。サーバーが動いている間ずっとブロックします。
* 引数がなければ、トランスポートは `stdio` です。
* `if __name__ == "__main__":` の下に置くのは、サーバーを読み込むものすべて(`mcp dev`、`mcp run`、`mcp install`、テスト)がこのファイルを **import** するからです。このガードにより、import しただけでサーバーが起動してしまうのを防ぎます。
### stdio {#stdio}
設定するものは何もありません。ホストがファイルを子プロセスとして起動し、その stdin にリクエストを書き込み、stdout からレスポンスを読み取ります。
自分で実行してみると、その結果がわかります。
```console
python server.py
```
何も表示されず、戻ってもきません。ホストが先に話しかけてくるのを stdin で待っているのです。
つまり stdout **が通信路そのもの**だということでもあります。サービス提供中、SDK は通信路をプライベートなディスクリプターに移し、stdout に「フラッシュされた」出力(継承した stdout に書き込むサブプロセスや、フラッシュされた `print()`)を stderr へ振り向けます。そこならストリームを壊すおそれがありません。サービス開始「前」に stdout にフラッシュされた出力(ラッパースクリプトの echo や、バッファリングなしの import 時の print)は、依然として通信路に流れ込みます。終了時にインタープリターが吐き出すまでバッファに溜まったままの `print()` も同様です。本当に必要な出力には `logging` モジュールが適切な手段です。そのハンドラーは各レコードを発生のたびに stderr へフラッシュします。詳しくは **[ロギング](../handlers/logging.md)** を参照してください。
### 試してみる {#try-it}
```console
uv run mcp dev server.py
```
Inspector は本物のホストとまったく同じことをします。`server.py` をサブプロセスとして起動し、stdio で接続します。
ポートは指定していません。そもそもポートがないのです。
## Streamable HTTP {#streamable-http}
同じサーバーを代わりにポートに載せるには、`run()` でトランスポート(とそのオプション)を指定します。
```python title="server.py" hl_lines="13"
--8<-- "docs_src/run/tutorial002.py"
```
この 1 行で Starlette アプリが組み立てられ、uvicorn で配信されます。クライアントは `http://127.0.0.1:3001/mcp` に接続します。
トランスポートごとに固有のキーワード引数があり、すべて `run()` に渡します。
* `host` / `port`:待ち受ける場所です。デフォルトは `127.0.0.1` と `8000` です。
* `streamable_http_path`:MCP エンドポイントの場所です。デフォルトは `/mcp` です。
* `json_response=True`:各 POST に SSE ストリームではなく単一の JSON ボディで応答します。このボディにはレスポンスしか入る余地がありません。そのため、リクエストの途中でクライアントを呼び返すツール(`ctx.elicit()` やサンプリング)は、この区間で `NoBackChannelError` を送出します。進行中の呼び出しに紐づく通知(`ctx.report_progress()` による進捗や呼び出しごとのログメッセージ)は破棄されますが、独立した `GET` ストリームは無関係な通知を引き続き運びます。
* `stateless_http=True`:リクエストごとに新しいトランスポートを作り、セッションを追跡しません。
* `max_request_body_size`:受け付ける POST ボディの最大サイズ(バイト単位)です。デフォルトは 4 MiB で、これより大きいリクエストはパースやセッション作成の前に HTTP 413 を受け取ります。正当な MCP メッセージがこのサイズを超える場合にだけ引き上げてください。
* `event_store`、`retry_interval`、`transport_security`:再開可能性と DNS リバインディング保護です。localhost 以外の場所にデプロイするまでは後回しでかまいません。`transport_security` については **[デプロイとスケール](deploy.md)** で扱います。
!!! warning
トランスポートのオプションは `run()` に渡します。`MCPServer(...)` には**渡しません**。コンストラクターはサーバーが「何であるか」、つまり名前、バージョン、instructions を記述します。`run()` はそれをどう配信するかを記述します。逆にすると、MCP が関わる前に Python が答えを返します。
```text
TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'
```
`run()` は近道です。それ以上のことが必要になった瞬間(既存のアプリの中にサーバーをマウントする、1 つのプロセスで 2 つのサーバーを動かす、ブラウザークライアント向けの CORS)、ASGI アプリを自分で組み立てて任意の ASGI ホストに渡すことになります。それが **[既存のアプリに追加する](asgi.md)** です。
## サーバー設定 {#server-settings}
実行に関することのうち、いくつかはトランスポートとは無関係です。これらはコンストラクター引数です。
```python title="server.py" hl_lines="3"
--8<-- "docs_src/run/tutorial003.py"
```
* `log_level`:`MCPServer(...)` が構築された瞬間に `logging.basicConfig()` に渡されます。これは**ルート**ロガーを設定するため、SDK のロガーだけでなく自分のロガーのレベルも決まります。デフォルトは `"INFO"` です。
* `debug`:HTTP トランスポートが組み立てる Starlette アプリに転送されます。デフォルトは `False` です。
どちらも `mcp.settings` に載り、実行時に読み出せます。
## `mcp` コマンド {#the-mcp-command}
`[cli]` エクストラをインストールすると、これらすべてを包む小さなコマンドラインツールが入ります。
`mcp dev` はサーバーを **MCP Inspector** の下で実行します。
```console
uv run mcp dev server.py
uv run mcp dev server.py --with pandas --with numpy
uv run mcp dev server.py --with-editable .
```
`--with` は組み立てる環境にパッケージを追加し、`--with-editable` は自分のパッケージをそこにインストールします。`PATH` に `npx` が必要です。Inspector は Node.js アプリだからです。
`mcp run` はファイルを import し、サーバーオブジェクト(モジュールレベルの `mcp`、`server`、`app` のいずれか)を見つけて、その `run()` を呼び出します。
```console
uv run mcp run server.py
uv run mcp run server.py:bookshop
```
`:` の接尾辞は、オブジェクトが `mcp`、`server`、`app` 以外の名前のときにそのオブジェクトを指定します。
ここでは `if __name__ == "__main__":` ブロックは決して実行されません。`mcp run` が自分で `run()` を呼び出し、転送するオプションは `--transport` だけです。
`mcp install` はサーバーを **Claude Desktop** に登録し、アプリが代わりに起動してくれるようにします。
```console
uv run mcp install server.py --name "Bookshop"
uv run mcp install server.py -v API_KEY=abc123 -f .env
```
`-v KEY=VALUE` と `-f .env` はそのエントリに環境変数を記録します。Claude Desktop はサーバーを独自のプロセスで起動します。シェルの環境はそこにはありません。
`mcp install` が知っているホストは Claude Desktop だけです。他のホスト(Claude Code、Cursor、VS Code)はそれぞれの設定ファイルに同じ起動コマンドを書きます。それぞれについては **[本物のホストに接続する](../get-started/real-host.md)** に載っています。
`mcp version` はインストールされている SDK のバージョンを表示します。
!!! tip
`mcp dev` と `mcp run` が理解するのは `MCPServer` だけです。低レベルの `Server` で組み立てる場合は、自分で実行します。**[低レベルの Server](../advanced/low-level-server.md)** を参照してください。
## まとめ {#recap}
* **トランスポート**とは、バイト列がサーバーに届く方法です。ローカルのサブプロセスなら `stdio`、ポートなら `streamable-http` です。SSE は置き換えられました。
* `mcp.run()` でトランスポートを選びます。引数がなければ `stdio` で、ブロックします。
* トランスポートのオプション(`host`、`port`、`streamable_http_path` など)はすべて `run()` の引数であり、`MCPServer(...)` の引数ではありません。
* `run()` は `if __name__ == "__main__":` の下に置いてください。サーバーを読み込むものはすべて、まずファイルを import します。
* `log_level=` と `debug=` はコンストラクター引数で、`mcp.settings` に載ります。
* Inspector には `mcp dev`、ファイルの実行には `mcp run`、Claude Desktop には `mcp install`、バージョンには `mcp version` です。
* トランスポートによってサーバーが「何であるか」が変わることはありません。このページの 3 つのファイルはすべて、まったく同じツールを公開しています。
`run()` そのものが限界になるとき(すでに存在するアプリの中にサーバーを置く場合)は **[既存のアプリに追加する](asgi.md)** です。本物のホスト名と複数のワーカーは **[デプロイとスケール](deploy.md)** です。そして、一部のクライアントがまだ仕様バージョン 2025-11-25 以前にとどまっているなら、**[レガシークライアントへの対応](legacy-clients.md)** が朗報です。

View file

@ -0,0 +1,116 @@
---
translation:
sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c]
tool: 1
---
# レガシークライアントへの対応 {#serving-legacy-clients}
MCP のプロトコルには 2 つの世代があります。仕様バージョン `2025-11-25` までの `initialize` ハンドシェイクの世代と、モダンな世代である `2026-07-28` です。この区分そのものについては **[プロトコルバージョン](../protocol-versions.md)** のページで説明しています。
このページが扱うのはその区分のサーバー側ですが、答えは 1 文で済みます。**すでにデプロイしている `streamable_http_app()` が両方に対応します。**
SDK はすべてのリクエストを `MCP-Protocol-Version` ヘッダーで振り分けます。`2026-07-28` を指定したリクエストはモダンなハンドラーに渡ります。ハンドシェイク世代のバージョンを指定したリクエスト、またはヘッダーをまったく持たないリクエスト(2026 年より前のクライアントの `initialize` はこの形で届きます)は、それらのクライアントが期待するトランスポートに渡ります。`initialize` ハンドシェイクもセッションも、すべてそろっています。この振り分けはリクエストごとに、コードが動く前に、1 つのアプリ上で行われます。
つまり、レガシークライアントは「そのために」何かを作る対象ではありません。すでに書いたサーバー「に」接続してくる存在です。設定することは何もありません。
!!! note
文字どおり、何もありません。`legacy=` オプションも、バージョンの許可リストも、ある世代を拒否したり無効にしたりする手段もありません。`streamable_http_app()` にも、`run()` にも、セッションマネージャーにもありません。両方の世代が常に有効です。そのシグネチャの中で世代ごとのスイッチに最も近いものは `stateless_http` で、このページの大半はその話です。
## 1 つのハンドラーで両方の世代 {#one-handler-both-eras}
ユーザーに何かを尋ねる必要があるツールと、それを呼び出す両方の世代のクライアントを示します。
```python title="server.py" hl_lines="24 37-38"
--8<-- "docs_src/legacy_clients/tutorial001.py"
```
`reserve` には、モデルが渡してこなかったものが 1 つ必要です。何冊予約するかです。ツールはそれを `Annotated[..., Resolve(ask_quantity)]` で宣言します(詳しくは **[依存関係](../handlers/dependencies.md)** を参照してください)。`reserve` の中には、バージョンを指定する箇所も、ケイパビリティを確認する箇所も、分岐する箇所もありません。
2 つのクライアントは同じ `mcp` オブジェクトに対して**同時に**開かれています。`mode="legacy"` は `initialize` ハンドシェイクを実行します。2026 年より前のクライアントが開くのとまったく同じ接続です。もう一方はデフォルトのままで、`2026-07-28` になります。
```text
2025-11-25 {'result': "Reserved 2 of 'Dune'."}
2026-07-28 {'result': "Reserved 2 of 'Dune'."}
```
同じサーバー、同じハンドラー、同じ答えです。この機能はこれですべてです。
「どのように」実現されたかは、少し立ち止まって見る価値があります。2 つのクライアントは、まったく異なる 2 つの通信路を通じて同じ質問を受けたからです。`2026-07-28` の接続にはサーバーがリクエストを送るためのチャネルがないため、`Resolve` は質問をツール結果の中に入れて返し、クライアントは答えを添えて呼び出しを再試行しました(**[マルチラウンドトリップ(multi-round-trip)リクエスト](../handlers/multi-round-trip.md)** を参照してください)。`2025-11-25` の接続にはそうした仕組みはありません。そこでは `Resolve` が呼び出しの途中で実際の `elicitation/create` リクエストを送り、待機しました。どちらも自分で書いてはいません。`Resolve` が接続でネゴシエートされたバージョンを読み取って選びます。どちらの場合も、ツール本体が目にするのは `AcceptedElicitation` です。
!!! tip
この世代間の可搬性こそが、`Resolve` が土台とすべき API である理由です。古い兄弟分の `ctx.elicit()`(**[エリシテーション(elicitation)](../handlers/elicitation.md)**)は `elicitation/create` を送ることしかしないため、レガシー接続でしか動きません。`2026-07-28` の接続では呼び出しは失敗します。ツールがまだこれを使っている場合、直し方は上で見たとおりの方法であって、バージョンチェックではありません。
## レガシーセッションのコスト {#what-a-legacy-session-costs-you}
振り分けにコストはかかりません。セッションにはかかります。
`2026-07-28` の接続は**セッションレス**です。各リクエストは独立しており、モダンなハンドラーが `Mcp-Session-Id` を発行することはありません。レガシー接続はその逆です。2026 年より前のクライアントが `initialize` を送った瞬間に、SDK は `Mcp-Session-Id` を発行し、レスポンスヘッダーで返し、その裏に生きたレコードを保持します。クライアントの後続リクエストが見つけられるように、ネゴシエートされたバージョン、開いているストリーム、セッションを駆動するバックグラウンドタスクが記録されます。
そのレコードは**プロセス内の単なる `dict`** です。分散セッションストアはなく、差し込む手段もありません。
ワーカーが 1 つなら、これは表に出ません。2 つになると、これが問題のすべてになります。`Mcp-Session-Id` を持つリクエストが、それを発行していないワーカーに届くと、その辞書には何も見つからず、返るのはツール結果ではなく `404`(`Session not found`)です。したがって、複数のワーカーを動かした瞬間から、**レガシークライアントにはスティッキールーティングが必要です**。セッション内のすべてのリクエストが、そのセッションを開始したプロセスに届かなければなりません。モダンなクライアントにはその必要はありません。スティッキーにすべきセッションがないからです。スティッキー性をはじめ、複数台で動かす際のあらゆることは **[デプロイとスケール](deploy.md)** で扱っています。
!!! warning
`event_store=` は解決策に見えますが、そうではありません。これは**再開可能性**(「同じ」セッションに再接続するクライアントに、取りこぼした SSE イベントを再送する機能)であって、セッションストアではありません。別のプロセスからセッションに到達できるようにはしません。
## 唯一のスイッチ:`stateless_http` {#the-one-knob-stateless_http}
スティッキー性というコストを払いたくないなら、変更できるものがちょうど 1 つだけあります。
```python title="server.py" hl_lines="28"
--8<-- "docs_src/legacy_clients/tutorial002.py"
```
これはページ冒頭のサーバーに、キーワードを 1 つ加えただけのものです。`stateless_http=True` にすると、レガシー経路はリクエストごとの使い捨てセッションを作るようになります。`Mcp-Session-Id` は発行されず、リクエスト間で何も記憶されないため、どのワーカーもどのリクエストにも応答でき、ロードバランサーは好きなように振り分けられます。
これについては、何をするかよりも重要なことが 2 つあります。
**影響するのはレガシー経路だけです。** リクエストは、`stateless_http` が読まれる「前に」バージョンヘッダーで振り分けられるため、モダンな経路がこの設定を目にすることはありません。`2026-07-28` の接続はもともとセッションレスで、どちらの値でもまったく同じです。
**その経路では、サーバーからクライアントへの 2 つのチャネルが両方とも失われます。** 1 回の `POST` の間しか存在しないセッションには、サーバーがリクエストを送り込むストリームも、通知を送り込むスタンドアロンストリームもありません。サーバー起点のリクエストはすべて `NoBackChannelError` を送出します。`ctx.elicit()`、非推奨となったサンプリングとルート(roots)の呼び出し(**[非推奨の機能](../deprecated.md)**)、そしてもちろん、「レガシー」クライアントに質問する `Resolve` もです。通知はエラーにすらなりません。黙って捨てられます。
!!! note
`json_response=True` はそのスイッチではありませんが、「すべての」レガシーセッションで同じコストの半分を負います。1 つの JSON ボディで応答される `POST` にはリクエストスコープのチャネル用のストリームがないため、リクエスト途中の `ctx.elicit()` は同じ `NoBackChannelError` を送出し、そのリクエストに結び付いた通知は捨てられます。セッションのスタンドアロンストリームには影響しません。無関係な通知は引き続き届きます。
!!! check
あえて間違ったことをしてみましょう。`reserve` は、先ほど両方のクライアントに応答したそのツールです。これを `stateless_http=True` でデプロイし、同じ 2 つのクライアントを HTTP で接続して、それぞれから呼び出してください。
モダンなクライアントには引き続き `Reserved 2 of 'Dune'.` が返ります。モダンな経路は変わっていません。
レガシークライアントの呼び出しは、モデルが読める `is_error` の結果としては返ってきません。リクエスト全体が、トップレベルのプロトコルエラーとして失敗します。
```text
mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.
```
`Resolve` は助けにはなりませんでした。`2025-11-25` の接続では `elicitation/create` を送る「しかない」のですが、そのために必要なチャネルこそが、`stateless_http=True` で手放したものです。世代間で可搬なコードは、バックチャネル(back-channel)を必要としないコードではありません。
つまりこれは本物のトレードオフで、レガシー経路にだけ存在します。**セッションありでスティッキーか、ステートレスで一方向か。** ツールがクライアントに呼び返すことが決してないなら、`stateless_http=True` にコストはないので採用すべきです。呼び返すなら、セッションを維持し、ルーティングもスティッキーのままにしてください。
## コードが実際に分岐する場所 {#where-your-code-actually-forks}
ほぼどこにもありません。
ツール、リソース、プロンプト、構造化出力、進捗、エラー。これらはどれも、どの世代から呼ばれたかを気にしません。`initialize` ハンドシェイク、`Mcp-Session-Id`、スタンドアロンストリーム、セッションを終わらせる `DELETE`。これらはすべて SDK の管轄で、ハンドラーが目にすることはありません。対話的な入力は、世代によって通信路上で本当に違いが出る唯一の場所ですが、それを気にしなくて済むように `Resolve` があります。1 つのツールが両方に応答するのを見たばかりです。
残るのはちょうど 1 つ、**変更通知**です。2 つの世代が別々の経路で待ち受けているからです。
* `2026-07-28` のクライアントは `subscriptions/listen` ストリームを開き、サブスクリプションバスを読みます。`ctx.notify_resource_updated()`(および `notify_tools_changed()`、`notify_prompts_changed()`、`notify_resources_changed()`)はそこに、そしてそこに「だけ」発行します。詳しくは **[サブスクリプション](../handlers/subscriptions.md)** を参照してください。
* レガシークライアントは、自分のセッションが開いたままにしているスタンドアロンストリームを読みます。`ctx.session.send_resource_updated()`(および `send_tool_list_changed()` などの仲間)は、リクエストを運んだ「接続」に書き込みます。レガシーセッションの場合、それはスタンドアロンストリームです。モダンな接続にはその置き場所がありません。HTTP ではそうしたチャネルがなく、stdio では 4 種類の変更通知は `subscriptions/listen` ストリームにしか乗らないため、モダンな接続ではその通知は黙って捨てられます。
HTTP では、どちらの呼び出しももう一方の世代のクライアントには届きません。全員に知らせるには、両方を呼び出します。
```python title="server.py" hl_lines="19-20"
--8<-- "docs_src/legacy_clients/tutorial003.py"
```
2 行だけで、`if` もバージョンチェックもなく、これで完了です。レガシークライアントが存在するためにハンドラーが違うことをする箇所は、これがすべてです。
## まとめ {#recap}
* 1 つの `streamable_http_app()` が両方のプロトコル世代に対応します。SDK は各リクエストを `MCP-Protocol-Version` ヘッダーで振り分けます。設定するものはなく、探すべき世代のスイッチもありません。
* レガシークライアントにはセッションというコストがかかります。裏に分散ストアを持たない、プロセス内の `Mcp-Session-Id` レコードです。ワーカーが複数なら**スティッキールーティング**が必要で、さもなければ間違ったワーカーが `404 Session not found` を返します。複数ワーカーについて詳しくは **[デプロイとスケール](deploy.md)** を参照してください。
* `stateless_http=True` が唯一のスイッチで、**レガシー経路にだけ**効きます。レガシークライアントのロードバランシングが自由になる代わりに、その経路ではサーバーからクライアントへのチャネルが両方とも失われます。サーバー起点のリクエストは `NoBackChannelError` を送出し(クライアント側では `is_error` の結果ではなくトップレベルのエラーになります)、通知は捨てられます。
* `2026-07-28` の接続はどちらにしてもセッションレスです。`stateless_http` がこれに影響することはありません。
* ハンドラーのコードが世代で分岐するのはちょうど 1 か所、変更通知です。`ctx.notify_*` は `subscriptions/listen` のクライアントに届き、`ctx.session.send_*` はレガシーセッションに届きます。両方を呼び出してください。
* それ以外はすべて(`Resolve` 経由でユーザーに入力を求めることも含めて)、仕組みのうえで世代間で可搬です。モダンなやり方で一度書けば済みます。

View file

@ -0,0 +1,85 @@
---
translation:
sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607]
tool: 1
---
# OpenTelemetry {#opentelemetry}
サーバーはすでにトレースされています。何も追加する必要はありません。
作成するサーバーはどれも、処理するメッセージごとに [OpenTelemetry](https://opentelemetry.io/) のスパンを発行します。自分で書いたわけでも、インポートしたわけでもありません。`MCPServer(...)` を呼び出した瞬間から、そこにあります。
```python title="server.py"
--8<-- "docs_src/opentelemetry/tutorial001.py"
```
これで完全な、トレース済みのサーバーです。`search_books` を呼び出すと、そのためのスパンが作成されます。低レベルの `Server` でも同じです。トレースはどちらにも組み込まれています。
## 得られるもの {#what-you-get}
受信したメッセージはすべて、メソッドとその対象にちなんだ名前の `SERVER` スパンになります。`search_books` に対する `tools/call` は `tools/call search_books` というスパンになり、単なる `tools/list` はそのまま `tools/list` です。
各スパンはいくつかの属性を持ちます。
* `mcp.method.name` と `mcp.protocol.version`。すべてのスパンに付きます。
* `jsonrpc.request.id`。リクエストに付きます(通知には ID がありません)。
* ハンドラーが例外を送出すると、スパンのステータスがエラーになります。`is_error=True` のツール結果でも同様です。
そしてツール呼び出しのトレースは非常によくある要望なので、`tools/call` のスパンは OpenTelemetry の [GenAI セマンティック規約](https://opentelemetry.io/docs/specs/semconv/gen-ai/)に従います。
* `gen_ai.operation.name`。`"execute_tool"` が設定されます。
* `gen_ai.tool.name`。呼び出されるツールが設定されます。
同じ考え方で、`prompts/get` のスパンには `gen_ai.prompt.name` が付きます。一覧系のメソッドには名前を付ける対象がないため、`gen_ai.*` のキーは付きません。
!!! tip
トレース UI がツール呼び出しを他のエージェントと同じようにグループ化してくれるのは、これらの GenAI 属性のおかげです。このグループ化は追加のコードなしで手に入ります。
## 必要になるまでコストはかからない {#it-costs-nothing-until-you-want-it}
「デフォルトで有効」が安心できるデフォルトである理由はここにあります。
SDK が依存しているのは、OpenTelemetry の軽量な半分である `opentelemetry-api` だけです。SDK もエクスポーターもインストールされていなければ、スパンの作成は何もしません。つまり、サーバーが今まさに発行しているスパンのコストはほぼゼロで、誰もそれを収集していません。
実際に「見たく」なった日には、残りの半分をインストールして、送り先を指定します。
```console
uv add opentelemetry-sdk opentelemetry-exporter-otlp
```
通常の OpenTelemetry のやり方でエクスポーターを設定すれば、SDK が静かに作成してきたスパンがすべて見えるようになります。サーバーのコードは変わりません。1 行たりともです。
!!! info
[Pydantic Logfire](https://logfire.pydantic.dev/) はそうしたバックエンドの 1 つで、設定まで代わりにやってくれます。`pip install logfire`、`logfire.configure()` とするだけで、MCP のスパンがライブビューに表示されます。OpenTelemetry の上に構築されているので、以下の内容もすべてそのまま当てはまります。
## 通信路をまたぐトレース {#traces-that-cross-the-wire}
トレースが最も役に立つのは、リクエストをクライアントからサーバーまで、1 つにつながった図として追えるときです。
クライアントとサーバーの両方が SDK を使っていれば、そのつながりは自動的に得られます。クライアントが [W3C トレースコンテキスト](https://www.w3.org/TR/trace-context/)をリクエストに注入し、サーバーがそれを読み取るので、サーバーのスパンは同じトレース内でクライアントのスパンの下にネストされます。これが [SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414) で、特に何もしなくても使えます。
受信したメッセージにトレースコンテキストが含まれていない場合、たとえば SDK ではないクライアントからのリクエストでは、サーバーのスパンは孤立した新しいトレースを開始するのではなく、サーバー側ですでに現在のスパンになっているものを親にします。
## 無効にする {#turning-it-off}
トレースはミドルウェアであり、サーバーのリストの先頭にあります。スパンをまったく発行しないサーバーが本当に必要なら、取り除いてください。
```python
from mcp.server._otel import OpenTelemetryMiddleware
mcp._lowlevel_server.middleware[:] = [
m for m in mcp._lowlevel_server.middleware if not isinstance(m, OpenTelemetryMiddleware)
]
```
!!! warning
このインポートには先頭にアンダースコアが付いていますが、これは意図的なものです。このクラスは、[`Server.middleware`](../advanced/middleware.md) が暫定的であるのと同じく暫定的なものなので、インポートパスは変わるものと考えてください。これが必要になることはほとんどありません。エクスポーターをインストールしていなければスパンはコストがかからないので、通常は有効のままにしてエクスポーターをインストールしない、というのが答えです。
## まとめ {#recap}
* すべての `MCPServer` とすべての低レベルの `Server` は、受信したメッセージごとに `SERVER` スパンを 1 つ、デフォルトで発行します。何も書く必要はありません。
* スパンには `mcp.method.name` と `mcp.protocol.version` が付きます。`tools/call` と `prompts/get` にはさらに GenAI 属性も付くので、ツール呼び出しは他のエージェントと同じようにグループ化されます。
* OpenTelemetry の SDK とエクスポーターをインストールするまでコストはかからず、インストールすればサーバーを変更することなく見えるようになります。
* 両側が SDK を使っていれば、クライアントからサーバーへのトレースコンテキストは自動的に伝播します。
そもそもリクエストを実行してよいかどうかを決めるのが、**[認可](authorization.md)**です。