docs: publish translated docs in twelve languages and the tool that maintains them (#3280)
This commit is contained in:
parent
14aa889992
commit
52ad0a8876
6 changed files with 89737 additions and 54 deletions
130
i18n/zh-hant/pages/run/asgi.md
Normal file
130
i18n/zh-hant/pages/run/asgi.md
Normal 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")` 會幫你啟動一個網頁伺服器。有時候你不想要這樣:MCP 伺服器只是較大網頁應用程式的其中一塊,或者你早就有 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`。
|
||||
|
||||
這個應用程式已經自帶兩樣東西:
|
||||
|
||||
* 一條路由 `/mcp`:Streamable HTTP 端點。
|
||||
* 一個**生命週期**(lifespan),負責啟動 `mcp.session_manager`,也就是掌管每個進行中工作階段(session)背景工作的那個物件。
|
||||
|
||||
單獨執行這個應用程式(`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 重新綁定防護;在你自己的機器上,這正好合適。部署到真正的主機名稱後面,就代表**每個請求都會以 `421 Misdirected Request` 被拒絕**,直到你透過 `transport_security=` 傳入一份你實際提供服務的主機允許清單為止。在那之前,請求根本到不了你寫的任何東西。這份允許清單,以及從一個能動的應用程式到真正主機名稱之間的其他一切,都在 **[部署與擴展](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()`。這就是大家都會忘記的那一行。
|
||||
* `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()` 把 `session_manager.run()` 接進它回傳的那個 Starlette 的生命週期裡,但**被掛載的子應用程式,其生命週期永遠不會執行**。一旦掛載,內建的生命週期就成了死程式碼。位於 ASGI 堆疊最頂端的那個應用程式,必須在自己的生命週期裡進入 `mcp.session_manager.run()`。
|
||||
|
||||
!!! check
|
||||
刪掉 `lifespan=lifespan` 那一行再啟動伺服器。能啟動,路由也能解析。然後第一個送往 `/mcp` 的請求會失敗:
|
||||
|
||||
```text
|
||||
RuntimeError: Task group is not initialized. Make sure to use run().
|
||||
```
|
||||
|
||||
除了它自己的 `run()`,沒有任何東西會啟動工作階段管理器。
|
||||
|
||||
## 兩個伺服器,一個應用程式 {#two-servers-one-app}
|
||||
|
||||
每個 `MCPServer` 都是各自獨立的應用程式,有自己的工作階段管理器。想掛載幾個都可以;在外層那一個生命週期裡進入每一個管理器:
|
||||
|
||||
```python title="server.py" hl_lines="27-30 35-36"
|
||||
--8<-- "docs_src/asgi/tutorial003.py"
|
||||
```
|
||||
|
||||
* `AsyncExitStack` 會進入兩個管理器;它們一起啟動,並以相反順序關閉。
|
||||
* 端點是 `/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`,而不是 `/notes/mcp`。
|
||||
|
||||
## 給瀏覽器用戶端的 CORS {#cors-for-browser-clients}
|
||||
|
||||
以瀏覽器為基礎的用戶端需要你給兩項許可:**送出**它的 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 隱藏回應標頭。少了它,用戶端永遠發不出第二個請求。
|
||||
* `allow_origins` 是你的決定,不是 MCP 的。要精確,並在上面的 `allowed_origins=` 中照樣設定:CORS 由瀏覽器強制執行,但伺服器自己也會檢查 `Origin`,傳輸不信任的來源即使預檢順利通過,仍會收到 `403`。
|
||||
* `allow_methods` 列出 Streamable HTTP 用到的三個方法:`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 回呼必須在任何權杖存在之前就能連到。不要把任何私密的東西放在它後面。
|
||||
|
||||
## 重點回顧 {#recap}
|
||||
|
||||
* `mcp.streamable_http_app()` 回傳一個只有一條路由 `/mcp` 的 Starlette 應用程式。任何 ASGI 伺服器都能執行它。
|
||||
* 預設情況下,這個應用程式只回應送往 localhost 的請求;放在真正的主機名稱後面時,在你透過 `transport_security=` 傳入允許清單之前,它會以 `421` 拒絕一切。這件事,以及通往正式環境的其餘路程,都歸 **[部署與擴展](deploy.md)** 管。
|
||||
* `Mount`(或 `Host`)把它放進更大的 Starlette 或 FastAPI 應用程式裡。
|
||||
* **掛載會停用內建的生命週期。**外層應用程式的生命週期必須進入 `mcp.session_manager.run()`,否則第一個請求就會失敗。
|
||||
* 一個應用程式裡放多個伺服器,代表多個掛載,加上一個會進入每個工作階段管理器的生命週期。
|
||||
* `streamable_http_path="/"` 把端點移到掛載前綴本身。
|
||||
* 瀏覽器用戶端需要 CORS:`allow_headers` 給 `Mcp-*` 請求標頭用,`expose_headers=["Mcp-Session-Id"]` 給回應用。
|
||||
* `@mcp.custom_route()` 在 `/mcp` 旁邊加上普通、不經驗證的 HTTP 端點。
|
||||
|
||||
一旦伺服器能透過真正的 URL 連到,**[用戶端](../client/index.md)** 就會用那個 URL 而不是伺服器物件來連線。
|
||||
122
i18n/zh-hant/pages/run/authorization.md
Normal file
122
i18n/zh-hant/pages/run/authorization.md
Normal 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 bearer 權杖。
|
||||
|
||||
以 OAuth 的術語來說,你的伺服器是**資源伺服器(resource server)**。它從不讓任何人登入,也從不發出權杖。它只做一件事:查看每個請求上的 `Authorization` 標頭,判斷裡面的權杖是否有效。
|
||||
|
||||
這一頁講的是伺服器端。會探索授權伺服器並取得權杖的用戶端,請見 **[OAuth 用戶端](../client/oauth-clients.md)**。
|
||||
|
||||
## 三方角色 {#the-three-parties}
|
||||
|
||||
* **授權伺服器**負責讓人登入並發出存取權杖。這個不用你寫,它就是你的身分提供者(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` 是只有一個非同步方法的 protocol。`verify_token` 會拿到 `Authorization` 標頭裡的原始權杖,有效就回傳 **`AccessToken`**,無效就回傳 `None`。沒有別的需要實作。
|
||||
* 這個範例是在一張表裡查權杖。真實的實作會驗證 JWT 簽章,或呼叫授權伺服器的權杖內省(token introspection)端點。那段程式碼是你的,SDK 只負責呼叫它。
|
||||
* `token_verifier=` 和 `auth=` 永遠成對出現。只傳其中一個,`MCPServer(...)` 在服務任何請求之前就會引發 `ValueError`。
|
||||
|
||||
`AuthSettings` 是資源伺服器對外的門面:
|
||||
|
||||
* `issuer_url`:發出權杖的授權伺服器。
|
||||
* `resource_server_url`:這個 MCP 端點的公開 URL。它指明權杖是給**哪一個**資源用的,也是探索文件所在的位置。
|
||||
* `required_scopes`:每個權杖都必須帶有全部這些 scope。
|
||||
|
||||
!!! tip
|
||||
SDK 儲存庫裡的 `examples/servers/simple-auth/` 有一個 `IntrospectionTokenVerifier`,會呼叫真實授權伺服器的 [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) 端點。大多數正式環境的驗證器都是這個樣子。
|
||||
|
||||
## 透過 HTTP 會得到什麼 {#what-you-get-over-http}
|
||||
|
||||
授權存在於 HTTP 標頭裡,所以只存在於 HTTP 傳輸方式上。在你要部署的那一種上執行它:`mcp.run(transport="streamable-http")` 會把它放在 `http://127.0.0.1:8000/mcp`,其餘內容請見 **[執行伺服器](index.md)**。應用程式現在有兩個路由:
|
||||
|
||||
```text
|
||||
/mcp
|
||||
/.well-known/oauth-protected-resource/mcp
|
||||
```
|
||||
|
||||
你註冊了一個工具。第二個路由是 SDK 的。
|
||||
|
||||
### 探索 {#discovery}
|
||||
|
||||
對那個 well-known 路徑發 `GET`,會得到 **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata**,直接從你的 `AuthSettings` 產生:
|
||||
|
||||
```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`,再去那裡拿權杖。這些你一行都沒寫。
|
||||
|
||||
!!! check
|
||||
不帶權杖呼叫 `/mcp`(或帶一個驗證器回傳 `None` 的權杖),請求會被擋在門口:
|
||||
|
||||
```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 層,授權也一併跳過。
|
||||
|
||||
## 呼叫端的身分 {#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`。這就是逐工具規則的著力點:讀取 scope,然後拒絕。
|
||||
* 在已驗證的 HTTP 請求之外,它回傳 `None`。記憶體內和透過 `stdio` 時,它永遠是 `None`。
|
||||
|
||||
用 `Authorization: Bearer alice-token` 呼叫 `whoami`,模型會讀到:
|
||||
|
||||
```text
|
||||
alice (scopes: notes:read)
|
||||
```
|
||||
|
||||
## SDK 不做的那一半 {#the-half-the-sdk-doesnt-do}
|
||||
|
||||
SDK 給你的是資源伺服器這一半:驗證、公告、拒絕。它不提供登入頁面、同意畫面,也不提供權杖。
|
||||
|
||||
想看三方實際互動,可以執行 SDK 儲存庫裡的 `examples/servers/simple-auth/`(一個小型授權伺服器,加上一個設定方式和這一頁完全相同的資源伺服器),再把 `examples/clients/simple-auth-client/` 指向它,跑一遍完整的探索與取得權杖流程。
|
||||
|
||||
!!! info
|
||||
還有第二個建構子引數 `auth_server_provider=`,會把完整的授權伺服器嵌進你的 MCP 伺服器裡。它出現的時間早於 MCP 授權規範所依據的 AS/RS 分離設計。新的伺服器不應該使用它。
|
||||
|
||||
授權伺服器也可以接受企業身分提供者簽署的斷言,取代使用者點選同意畫面的步驟,而 SDK 支援這種交換的兩端。這種授權方式,以及提出它的用戶端,請見 **[身分斷言](../client/identity-assertion.md)**。
|
||||
|
||||
## 重點回顧 {#recap}
|
||||
|
||||
* 透過 Streamable HTTP,你的伺服器是 OAuth 2.1 的**資源伺服器**:它驗證權杖,從不發出權杖。
|
||||
* `TokenVerifier` 就是整個整合介面:一個非同步方法,權杖進去,`AccessToken | None` 出來。
|
||||
* `token_verifier=` 和 `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` 永遠成對出現。
|
||||
* SDK 會在 `/.well-known/oauth-protected-resource/...` 發布 [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata,並以 401 回應未驗證的請求,其 `WWW-Authenticate` 標頭會指向這份文件。整個探索機制就這樣。
|
||||
* 在任何處理函式裡,`get_access_token()` 就是誰在呼叫。
|
||||
* 授權是 HTTP 層的事。`stdio` 和記憶體內用戶端永遠看不到它。
|
||||
|
||||
用戶端那一半(探索你的授權伺服器並替你取得權杖)請見 **[OAuth 用戶端](../client/oauth-clients.md)**。至於不問使用者、而是直接**斷言**身分的用戶端,請見 **[身分斷言](../client/identity-assertion.md)**。
|
||||
163
i18n/zh-hant/pages/run/deploy.md
Normal file
163
i18n/zh-hant/pages/run/deploy.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
---
|
||||
translation:
|
||||
sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a]
|
||||
tool: 1
|
||||
---
|
||||
# 部署與擴展 {#deploy-scale}
|
||||
|
||||
伺服器可以運作了。現在它需要一個真正的主機名稱,後面還要有不只一個 worker。
|
||||
|
||||
這些事幾乎都不歸 MCP 管。ASGI 伺服器、處理程序管理器、負載平衡器都由你自備。這一頁只列出少數**確實**歸 MCP 管的事:一個擋在每次部署前面的設定,以及「不只一個 worker」會改變 SDK 行為的兩個地方。
|
||||
|
||||
## 先做這件事: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://` 形式。在你自己的機器上這完全正確:它能阻止惡意網頁透過重新綁定到 `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` 是純文字的 HTTP 回應,不是 JSON-RPC 錯誤,所以 MCP 用戶端只會引發一個籠統的傳輸錯誤;它不喜歡的主機名稱只會出現在**伺服器**的記錄裡,就一則警告。剛部署好卻拒絕所有連線的伺服器,在證明是別的原因之前,就是 Host 允許清單的問題。**[疑難排解](../troubleshooting.md)** 也從這裡開始。
|
||||
|
||||
## Worker,以及誰需要黏性 {#workers-and-who-has-to-be-sticky}
|
||||
|
||||
主機名稱能回應之後,就在後面放不只一個 worker。SDK 沒有這方面的設定;擴展 Starlette 應用程式的方式跟擴展任何 ASGI 應用程式一樣,把物件交給懂得 fork 的東西:
|
||||
|
||||
```console
|
||||
uvicorn server:app --workers 4
|
||||
```
|
||||
|
||||
四個處理程序,一個 socket。接著是每次部署都得回答的問題:**請求是否必須送到看過上一個請求的那個 worker?**
|
||||
|
||||
對使用 **2026-07-28** 協定的用戶端來說,不用。現代請求是一個自成一體的 POST:前面沒有 `initialize` 交握,回應上沒有 `Mcp-Session-Id`,第二個請求沒有什麼可以「回去找」的對象。送到任何一個 worker 都行。
|
||||
|
||||
這不是一個要你開啟的模式。`stateless_http=True` 看起來像是,但傳輸層依 `MCP-Protocol-Version` 請求標頭分流,把現代請求交給現代處理函式,然後就**回傳**了。讀取 `stateless_http` 的那一行在那個 return **之後**。並不是這個旗標在 2026-07-28 路徑上被忽略,而是根本執行不到。`stateless_http` 只是**舊版**那一支的開關,現代路徑在設計上就沒有工作階段(session)。
|
||||
|
||||
對規格版本 2025-11-25 或更早的舊版用戶端,答案取決於那個旗標:
|
||||
|
||||
| 用戶端的協定版本 | 工作階段 | 負載平衡器必須做的事 |
|
||||
| --- | --- | --- |
|
||||
| **2026-07-28** | 無。永遠不會設定 `Mcp-Session-Id`。 | 不用做什麼。任何 worker 都能服務任何請求。 |
|
||||
| **2025-11-25 及更早**(預設) | `Mcp-Session-Id`,保存在某一個 worker 的記憶體內。 | **黏性工作階段。**後續請求若送到不同的 worker,會得到 `404`「Session not found」。 |
|
||||
| **2025-11-25 及更早**,搭配 `stateless_http=True` | 無。 | 不用做什麼。代價是伺服器到用戶端的反向通道(back-channel),也就是取樣(sampling)、推送式徵詢(elicitation)、`roots/list`,以及可續傳能力。 |
|
||||
|
||||
黏性工作階段和舊版那一支的代價自有專頁:**[服務舊版用戶端](legacy-clients.md)**;兩個世代本身則見 **[協定版本](../protocol-versions.md)**。這裡重要的是答案的樣子:**在 2026-07-28 上你本來就是無狀態的,沒有任何東西要設定。**
|
||||
|
||||
本頁剩下的內容,是無狀態**沒有**幫你解決的兩件事。
|
||||
|
||||
## 跨 worker 的 `requestState` {#requeststate-across-workers}
|
||||
|
||||
**[多輪往返(multi-round-trip)](../handlers/multi-round-trip.md)** 工具需要某樣用戶端得去取得的東西(一個確認、一個選擇、一個憑證),所以它回傳的是問題而不是答案,並在重試時完成。兩輪之間,用戶端持有一個伺服器鑄造的不透明 `request_state` 權杖。重試時,伺服器得再把那個權杖打開。
|
||||
|
||||
「用哪一把金鑰封裝的?」預設是伺服器在建構時用 `os.urandom(32)` 產生的那一把。在 `--workers 4` 之下,那是四次建構、四個處理程序:四把不同的金鑰,從沒寫到任何地方、從不共用,重新啟動就消失。
|
||||
|
||||
下面是一個先問再做的工具,放在一台什麼都沒設定的伺服器上:
|
||||
|
||||
```python title="server.py" hl_lines="14 20"
|
||||
--8<-- "docs_src/deploy/tutorial002.py"
|
||||
```
|
||||
|
||||
第一輪送到 worker A。Worker A 用**它自己的**金鑰封裝 `refund:120` 並回傳權杖。用戶端把問題呈現給某個人,得到同意,然後重試。這次重試是一個全新的 HTTP 請求。
|
||||
|
||||
!!! check
|
||||
讓那次重試送到 worker B。B 試著解封一個不是它鑄造的權杖,辦不到,於是拒絕整輪。`refund` 根本沒被呼叫;用戶端收到一個 JSON-RPC 錯誤:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -32602,
|
||||
"message": "Invalid or expired requestState",
|
||||
"data": {"reason": "invalid_request_state"}
|
||||
}
|
||||
```
|
||||
|
||||
那則訊息是**固定不變**的。過期、被竄改、拿不同的引數重播,或者(在真實部署裡遠遠最常見的原因)由兄弟 worker 封裝:用戶端每次被告知的都是同一句話,所以線路上永遠看不出是哪一項檢查失敗。真正的原因是伺服器記錄裡的一則 `WARNING`:
|
||||
|
||||
```text
|
||||
requestState rejected on tools/call: unknown key
|
||||
```
|
||||
|
||||
一個 worker 時正常、兩個 worker 時開始**偶爾**失敗的多輪往返工具,就是這個問題。兩輪仍然必須送到同一個處理程序,所以負載平衡器把它們拆開的頻率有多高,它失敗的頻率就有多高。
|
||||
|
||||
兩輪是兩個獨立的 HTTP 請求,好幾種再平常不過的情況都會把它們拆開:逐請求平衡的代理、中間斷掉的連線、一次部署或重新啟動、把 `request_state` 存下來並從完全不同的處理程序恢復的用戶端(**[自己驅動迴圈](../handlers/multi-round-trip.md#driving-the-loop-yourself)**)。任何一種都算「不同的 worker」。
|
||||
|
||||
解法是一個引數。它有**兩**半。
|
||||
|
||||
```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 宣告**,回來時嚴格檢查。用同一份程式碼建出的兩個執行個體名稱相同,永遠不會察覺這件事。替它們取不同的名字(`MCPServer(f"billing-{POD}")` 看起來像是良好的可觀測性習慣),每次跨執行個體重試就會像上面那樣被拒絕,不管有沒有共用金鑰。記錄裡寫的是 `audience` 而不是 `unknown key`;用戶端分不出差別。
|
||||
|
||||
祕密只鑄造一次,把同一個值交給每個執行個體。如果傳入少於 32 個位元組,SDK 自己的錯誤訊息就會叫你執行這條指令:
|
||||
|
||||
```console
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
!!! warning "相同的金鑰,**還有**相同的名稱"
|
||||
多執行個體部署必須兩者都共用。如果各執行個體的名稱對你來說不可或缺,就改給整個機群一個明確的 audience:`RequestStateSecurity(keys=[...], audience="billing")`。這樣每個執行個體不管叫什麼,都用 `"billing"` 鑄造和接受。
|
||||
|
||||
封裝的其他一切都在 **[保護 `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**:它綁定什麼、每輪的 `ttl`(預設 600 秒)、自備編解碼器、為什麼未設定的預設值在 `stdio` 上完全正確。本頁的全部貢獻就是一張兩項的檢查清單:**相同的金鑰,相同的名稱。**
|
||||
|
||||
!!! info
|
||||
就算從沒打過 `InputRequiredResult`,你也在這條路徑上。參數用了 `Resolve(...)`(**[相依性](../handlers/dependencies.md)**)的工具就是多輪往返工具,SDK 會替它鑄造並封裝 `request_state`。同樣的預設金鑰,跨 worker 同樣的失敗,同樣的解法。
|
||||
|
||||
## 跨副本的變更通知 {#change-notifications-across-replicas}
|
||||
|
||||
用戶端的 `subscriptions/listen` 串流是一個長時間存活的回應,所以它整個生命週期都釘在同一個副本上。在**另一個**副本上發布的 `ctx.notify_resource_updated(...)` 必須送得到它。
|
||||
|
||||
兩者之間的接縫是 `SubscriptionBus`。給伺服器什麼 bus,每次發布就進到那個 bus,每個開著的串流也都在上面聽,所以把同一個 bus 交給每個副本:
|
||||
|
||||
```python title="server.py" hl_lines="2 7 9"
|
||||
--8<-- "docs_src/deploy/tutorial004.py"
|
||||
```
|
||||
|
||||
扇出完全不在乎串流掛在哪個伺服器物件上。兩個伺服器共用一個 `InMemorySubscriptionBus` 本來就是這樣運作:在其中一個上開啟 listen 串流,在另一個上 `edit_note`,串流就會聽到。那個記憶體內的 bus 只能跨越同一個處理程序裡的伺服器物件,所以它是模型,不是部署方案:
|
||||
|
||||
* 跨真正的處理程序時,**SDK 沒有附任何幫得上忙的 bus。**`SubscriptionBus` 是一個只有兩個方法的 `Protocol`(`publish` 和 `subscribe`),由你在自己的 pub/sub 後端(Redis、NATS,或你已經在跑的任何東西)上實作,再以 `MCPServer(subscriptions=...)` 傳入。草稿與契約請見 **[訂閱](../handlers/subscriptions.md#scaling-past-one-process)**。
|
||||
* bus 載的是四種小型的有型別事件,從來不是 JSON-RPC。確認、過濾和串流生命週期都留在 SDK 裡,所以你的 bus 不可能破壞協定;它只能在處理程序之間搬運事件。
|
||||
* 串流**不能**續傳,事件也**不會**重播。失去一個副本就丟掉它的串流;用戶端會重新 listen、重新抓取。沒有要共用的事件儲存區,也沒有別的要設定。這是唯一一個向外擴展真的只是「多幾台一樣的」的地方。
|
||||
|
||||
## SDK 不提供的東西 {#what-the-sdk-does-not-give-you}
|
||||
|
||||
`MCPServer` 是協定實作,不是應用程式伺服器。接下來你會去找的部署選項是刻意不放的:
|
||||
|
||||
* **沒有 `workers=`。**`mcp.run("streamable-http")` 啟動剛好一個 uvicorn 處理程序,而且永遠只會啟動這一個。多處理程序就是把 `streamable_http_app()` 交給你本來就拿來部署 ASGI 的東西:`uvicorn --workers`、gunicorn、平台的處理程序管理器。本頁刻意不當其中任何一個的教學;它們的說明文件比在這裡抄一份要好。
|
||||
* **沒有健康檢查路由。**`@mcp.custom_route("/health", methods=["GET"])` 就是全部答案,而且即使伺服器其他部分需要驗證,它也永遠不需要。這對存活探測是對的,對任何私密的東西是錯的。**[加入現有應用程式](asgi.md#custom-routes)** 有一個範例。
|
||||
* **沒有正式環境設定物件。**`MCPServer` 上沒有地方寫下逾時、TLS、優雅關閉或連線上限,因為這些都不是它的工作。它們屬於你的 ASGI 伺服器,在那裡設定。**[執行伺服器](index.md)** 涵蓋建構子**確實**接受的那幾個設定。
|
||||
* **沒有附 `EventStore`,而且在 2026-07-28 上也用不著。**可續傳是舊版有狀態那一支的功能;現代的交換就是一個 POST、一個回應,沒有什麼要續傳。
|
||||
|
||||
## 重點回顧 {#recap}
|
||||
|
||||
* 預設情況下,這個應用程式只回應送往 localhost 的請求。`transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` 是上線的關卡:在傳入它之前,真正主機名稱後面的每個請求都是 `421`,原因只在伺服器記錄裡。
|
||||
* 在 2026-07-28 上沒有工作階段,負載平衡器也沒有東西可黏。`stateless_http=True` 是只給舊版用的開關,因為現代請求在那個旗標被讀到之前就已經分流並回應了。
|
||||
* 預設的 `requestState` 金鑰是 `os.urandom(32)`,每個處理程序各自鑄造。送到不同 worker 的多輪往返重試會以 `-32602`「Invalid or expired requestState」失敗。
|
||||
* 解法是 `RequestStateSecurity(keys=[...])` **加上**每個執行個體相同的伺服器名稱。名稱是權杖預設的 audience 宣告。相同的金鑰,相同的名稱。
|
||||
* 變更通知透過一個共用的 `SubscriptionBus` 跨越副本。SDK 唯一的實作是處理程序內的;在你自己的 pub/sub 上寫那個兩方法的 `Protocol` 是你的事。
|
||||
* 沒有 `workers=`、沒有健康檢查路由、沒有正式環境設定物件。自備 ASGI 伺服器。
|
||||
|
||||
真正的主機名稱前面需要的另一樣東西是權杖:**[授權](authorization.md)**。
|
||||
149
i18n/zh-hant/pages/run/index.md
Normal file
149
i18n/zh-hant/pages/run/index.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
---
|
||||
translation:
|
||||
sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0]
|
||||
tool: 1
|
||||
---
|
||||
# 執行伺服器 {#running-your-server}
|
||||
|
||||
`mcp.run()` 會啟動伺服器。
|
||||
|
||||
唯一要做的決定是**傳輸方式**:伺服器和用戶端之間的位元組實際上怎麼移動。
|
||||
|
||||
## 選一種傳輸方式 {#pick-a-transport}
|
||||
|
||||
| 傳輸方式 | 是什麼 | 何時用 |
|
||||
|---|---|---|
|
||||
| `stdio` | MCP 主機(host)把你的檔案當成子處理程序啟動,透過它的 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 會把線路移到一個私有的檔案描述元,並把 **flush** 到 stdout 的輸出(子處理程序寫入它繼承來的 stdout、flush 過的 `print()`)改導到 stderr,在那裡不會弄壞串流。在開始服務**之前**就 flush 到 stdout 的輸出(包裝指令稿的 echo、匯入時未緩衝的 print)仍然會落到線路上;一直緩衝到直譯器結束時才清空的 `print()` 也一樣。真正想要的輸出,用 `logging` 模組才是正確的工具:它的 handler 會在每筆記錄發生時就 flush 到 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"
|
||||
```
|
||||
|
||||
這一行會建立一個 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 都用單一 JSON 本體回應,而不是 SSE 串流。那個本體只裝得下回應本身,別的都沒有,所以在請求中途回頭呼叫用戶端的工具(`ctx.elicit()`、取樣(sampling))在這一段會引發 `NoBackChannelError`,而綁在進行中呼叫上的通知(`ctx.report_progress()` 的進度、每次呼叫的記錄訊息)會被丟棄;獨立的 `GET` 串流仍會承載不相關的那些。
|
||||
* `stateless_http=True`:每個請求一個全新的傳輸,不追蹤工作階段(session)。
|
||||
* `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()` 描述它怎麼被提供服務。弄反了,Python 在 MCP 根本還沒介入之前就會回你:
|
||||
|
||||
```text
|
||||
TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'
|
||||
```
|
||||
|
||||
`run()` 是捷徑。一旦需要更多(伺服器掛載在現有的應用程式裡、一個處理程序裡兩個伺服器、給瀏覽器用戶端的 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()`。那會設定 **root** logger,所以也會設定你自己 logger 的層級,不只是 SDK 的。預設為 `"INFO"`。
|
||||
* `debug`:轉交給 HTTP 傳輸建立的 Starlette 應用程式。預設為 `False`。
|
||||
|
||||
兩者都會落在 `mcp.settings` 上,執行時可以讀回來。
|
||||
|
||||
## `mcp` 命令 {#the-mcp-command}
|
||||
|
||||
`[cli]` extra 會安裝一個把這些包起來的小命令列工具。
|
||||
|
||||
`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` 會匯入檔案、找出伺服器物件(模組層級的 `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 在它自己的處理程序裡啟動伺服器。你的 shell 環境不在那裡。
|
||||
|
||||
`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` 上。
|
||||
* `mcp dev` 開 Inspector,`mcp run` 執行檔案,`mcp install` 給 Claude Desktop,`mcp version` 看版本。
|
||||
* 傳輸方式永遠不會改變伺服器**是什麼**:這一頁的三個檔案公開的是一模一樣的工具。
|
||||
|
||||
當 `run()` 本身成了限制(伺服器在一個已經存在的應用程式裡),就看 **[加入現有應用程式](asgi.md)**。真正的主機名稱和不只一個 worker,是 **[部署與擴展](deploy.md)**。如果有些用戶端還停在規格版本 2025-11-25 或更早,**[服務舊版用戶端](legacy-clients.md)** 有好消息。
|
||||
116
i18n/zh-hant/pages/run/legacy-clients.md
Normal file
116
i18n/zh-hant/pages/run/legacy-clients.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
---
|
||||
translation:
|
||||
sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c]
|
||||
tool: 1
|
||||
---
|
||||
# 服務舊版用戶端 {#serving-legacy-clients}
|
||||
|
||||
MCP 有兩個協定世代:`initialize` 交握世代,到規格版本 `2025-11-25` 為止;以及現代世代 `2026-07-28`。專門講這個分界的頁面是 **[協定版本](../protocol-versions.md)**。
|
||||
|
||||
這一頁談的是這個分界的伺服器端,而答案一句話就講完:**你已經部署的 `streamable_http_app()` 兩個世代都能服務。**
|
||||
|
||||
SDK 依每個請求的 `MCP-Protocol-Version` 標頭來路由。標明 `2026-07-28` 的請求交給現代處理路徑。標明交握世代版本的請求,或是根本沒帶標頭的請求(2026 之前的用戶端送來的 `initialize` 就是這樣到達的),則交給那些用戶端預期的傳輸:`initialize` 交握、工作階段(session),一樣不少。這一切逐請求發生,在你的程式碼之前,在同一個應用程式上。
|
||||
|
||||
所以,舊版用戶端不是你要特地**為它**打造什麼東西;它只是會**連上**你已經寫好的伺服器。什麼都不用設定。
|
||||
|
||||
!!! note
|
||||
真的是什麼都沒有。沒有 `legacy=` 選項,沒有版本允許清單,沒有任何方法可以拒絕或停用某個世代:`streamable_http_app()` 上沒有、`run()` 上沒有、工作階段管理器上也沒有。兩個世代永遠都開著。那個簽章裡最接近「依世代切換」的東西是 `stateless_http`,而這一頁大半都在講它。
|
||||
|
||||
## 一個處理函式,兩個世代 {#one-handler-both-eras}
|
||||
|
||||
下面是一個必須問使用者問題的工具,以及兩個世代的用戶端呼叫它:
|
||||
|
||||
```python title="server.py" hl_lines="24 37-38"
|
||||
--8<-- "docs_src/legacy_clients/tutorial001.py"
|
||||
```
|
||||
|
||||
`reserve` 需要一樣模型沒有提供的東西:要幾本。`Annotated[..., Resolve(ask_quantity)]` 就是工具宣告這件事的方式(完整說明請見 **[相依性](../handlers/dependencies.md)**)。`reserve` 裡沒有任何地方指名版本、檢查能力或做分支。
|
||||
|
||||
兩個用戶端**同時**開著,連到同一個 `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'."}
|
||||
```
|
||||
|
||||
同一個伺服器、同一個處理函式、同一個答案。整個功能就這樣。
|
||||
|
||||
值得停下來看看**怎麼做到的**,因為這兩個用戶端是透過兩條完全不同的線路被問了同一個問題。`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`**。沒有分散式工作階段儲存區,也沒有辦法外掛一個。
|
||||
|
||||
只有一個 worker 時,這完全看不出來。有兩個時,這就是全部的問題所在:帶著 `Mcp-Session-Id` 的請求如果落到不是鑄造它的那個 worker 上,在那個 dict 裡什麼都找不到,得到的答案是 `404`(`Session not found`),而不是工具結果。所以只要執行超過一個 worker,**舊版用戶端就需要黏性路由**:一個工作階段裡的每個請求都必須抵達開啟它的那個處理程序。現代用戶端永遠不需要;它們沒有工作階段可黏。黏性和其他關於執行多個實例的一切,請見 **[部署與擴展](deploy.md)**。
|
||||
|
||||
!!! warning
|
||||
`event_store=` 看起來像解法,但不是。它是**可恢復性**(把漏掉的 SSE 事件重播給重新連回**同一個**工作階段的用戶端),不是工作階段儲存區。它永遠不會讓工作階段能從另一個處理程序存取到。
|
||||
|
||||
## 唯一的開關:`stateless_http` {#the-one-knob-stateless_http}
|
||||
|
||||
如果黏性是你不願付的代價,能改的東西剛好只有一樣。
|
||||
|
||||
```python title="server.py" hl_lines="28"
|
||||
--8<-- "docs_src/legacy_clients/tutorial002.py"
|
||||
```
|
||||
|
||||
這是頁面最上方的那個伺服器再加一個關鍵字。`stateless_http=True` 讓舊版路徑改為建立用過即丟、每個請求一個的工作階段:不發 `Mcp-Session-Id`,請求之間什麼都不記,所以任何 worker 都能服務任何請求,負載平衡器想怎麼分就怎麼分。
|
||||
|
||||
關於它,有兩件事比它做什麼更重要。
|
||||
|
||||
**它只影響舊版路徑。** 請求在讀取 `stateless_http` **之前**就已依版本標頭路由好了,所以現代路徑根本看不到它。`2026-07-28` 連線本來就無工作階段,在兩種值下完全一樣。
|
||||
|
||||
**它的代價是那條路徑上的兩個伺服器到用戶端通道。** 只活一次 `POST` 的工作階段,沒有串流讓伺服器推送請求,也沒有獨立串流讓它推送通知。每個伺服器發起的請求都會引發 `NoBackChannelError`:`ctx.elicit()`、已退役的取樣(sampling)與根目錄(roots)呼叫(**[已棄用的功能](../deprecated.md)**),還有,沒錯,`Resolve` 向**舊版**用戶端提問時也一樣。通知甚至連錯誤都沒有,就默默被丟掉。
|
||||
|
||||
!!! note
|
||||
`json_response=True` 不是那個開關,但它在**每個**舊版工作階段上都會付出一半同樣的代價:用單一 JSON 本體回應的 `POST` 沒有串流可供請求範圍的通道使用,所以請求途中的 `ctx.elicit()` 會引發同樣的 `NoBackChannelError`,綁在該請求上的通知則被丟掉。工作階段的獨立串流不受影響:不相關的通知照樣送達。
|
||||
|
||||
!!! check
|
||||
故意做錯一次。`reserve` 正是剛剛服務了兩個用戶端的那個工具。用 `stateless_http=True` 部署它,透過 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` 的存在就是為了讓它不成為你的問題:你剛剛才看到一個工具同時服務兩者。
|
||||
|
||||
剩下的剛好只有一件事,就是**變更通知**,因為兩個世代聽的是不同的管道:
|
||||
|
||||
* `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 時這四種變更通知只搭 `subscriptions/listen` 串流,所以在現代連線上這個通知會被默默丟掉。
|
||||
|
||||
透過 HTTP,兩個呼叫都到不了另一個世代的用戶端。要通知所有人,兩個都呼叫:
|
||||
|
||||
```python title="server.py" hl_lines="19-20"
|
||||
--8<-- "docs_src/legacy_clients/tutorial003.py"
|
||||
```
|
||||
|
||||
兩行,沒有 `if`,沒有版本檢查,就完成了。這就是處理函式因為舊版用戶端存在而要做得不一樣的事的完整清單。
|
||||
|
||||
## 重點回顧 {#recap}
|
||||
|
||||
* 一個 `streamable_http_app()` 服務兩個協定世代。SDK 依每個請求的 `MCP-Protocol-Version` 標頭路由;沒有東西要設定,也沒有世代開關可找。
|
||||
* 舊版用戶端的代價是一個工作階段:一筆處理程序內的 `Mcp-Session-Id` 紀錄,背後沒有分散式儲存區。超過一個 worker 就表示要**黏性路由**,否則錯的 worker 會回 `404 Session not found`。多 worker 的完整說明請見 **[部署與擴展](deploy.md)**。
|
||||
* `stateless_http=True` 是唯一的開關,而且**只作用於舊版路徑**。它用那條路徑上的兩個伺服器到用戶端通道,換來舊版用戶端的自由負載平衡:伺服器發起的請求會引發 `NoBackChannelError`(在用戶端是頂層錯誤,不是 `is_error` 結果),通知則被丟掉。
|
||||
* `2026-07-28` 連線不管怎樣都無工作階段。`stateless_http` 永遠碰不到它。
|
||||
* 處理函式的程式碼只在一個地方依世代分岔:變更通知。`ctx.notify_*` 到得了 `subscriptions/listen` 用戶端;`ctx.session.send_*` 到得了舊版工作階段。兩個都呼叫。
|
||||
* 其他一切(包括透過 `Resolve` 向使用者要輸入)在設計上就是跨世代可攜的。現代的寫法寫一次就好。
|
||||
85
i18n/zh-hant/pages/run/opentelemetry.md
Normal file
85
i18n/zh-hant/pages/run/opentelemetry.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
---
|
||||
translation:
|
||||
sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607]
|
||||
tool: 1
|
||||
---
|
||||
# OpenTelemetry {#opentelemetry}
|
||||
|
||||
伺服器已經有追蹤了,什麼都不用加。
|
||||
|
||||
你建立的每個伺服器,都會為它處理的每則訊息發出一個 [OpenTelemetry](https://opentelemetry.io/) span。這不是你寫的,也不需要匯入。呼叫 `MCPServer(...)` 的那一刻它就在了。
|
||||
|
||||
```python title="server.py"
|
||||
--8<-- "docs_src/opentelemetry/tutorial001.py"
|
||||
```
|
||||
|
||||
這就是一個完整、帶追蹤的伺服器。呼叫 `search_books`,就會替它建立一個 span。低階的 `Server` 也一樣:兩者都內建追蹤。
|
||||
|
||||
## 你會得到什麼 {#what-you-get}
|
||||
|
||||
每則傳入訊息都會變成一個 `SERVER` span,名稱取自方法和它的目標。所以對 `search_books` 的 `tools/call` 就是 `tools/call search_books` 這個 span,而單純的 `tools/list` 就只是 `tools/list`。
|
||||
|
||||
每個 span 帶有幾個屬性:
|
||||
|
||||
* `mcp.method.name` 和 `mcp.protocol.version`,每個 span 都有。
|
||||
* `jsonrpc.request.id`,請求才有(通知沒有)。
|
||||
* 處理函式引發例外時,會把 span 狀態設為 error。`is_error=True` 的工具結果也一樣。
|
||||
|
||||
而因為追蹤工具呼叫是很常見的需求,`tools/call` span 採用 OpenTelemetry 的 [GenAI 語意慣例](https://opentelemetry.io/docs/specs/semconv/gen-ai/):
|
||||
|
||||
* `gen_ai.operation.name`,設為 `"execute_tool"`。
|
||||
* `gen_ai.tool.name`,設為被呼叫的工具。
|
||||
|
||||
`prompts/get` span 同理會有 `gen_ai.prompt.name`。list 類方法不帶 `gen_ai.*` 鍵,因為沒有東西可命名。
|
||||
|
||||
!!! tip
|
||||
追蹤 UI 之所以會把你的工具呼叫和其他 agent 的工具呼叫用同樣方式分組,靠的就是這些 GenAI 屬性。這個分組是免費得到的,不用寫任何額外程式碼。
|
||||
|
||||
## 想用之前,完全沒有成本 {#it-costs-nothing-until-you-want-it}
|
||||
|
||||
下面這一點,是「預設開啟」能讓人放心當預設的原因。
|
||||
|
||||
SDK 只依賴 `opentelemetry-api`,也就是 OpenTelemetry 輕量的那一半。沒有安裝 SDK 也沒有安裝 exporter 時,建立 span 是 no-op。所以伺服器現在發出的那些 span 幾乎不花你任何成本,也沒有人在收集。
|
||||
|
||||
哪天想**看到**它們,就安裝另一半,再把它指向某個地方:
|
||||
|
||||
```console
|
||||
uv add opentelemetry-sdk opentelemetry-exporter-otlp
|
||||
```
|
||||
|
||||
照一般 OpenTelemetry 的方式設定 exporter,SDK 一直默默建立的每個 span 就全都亮起來了。伺服器程式碼不用改,一行都不用。
|
||||
|
||||
!!! info
|
||||
[Pydantic Logfire](https://logfire.pydantic.dev/) 就是這類後端之一,而且會幫你把設定做好:`pip install logfire`、`logfire.configure()`,你的 MCP span 就會出現在即時檢視中。它建構在 OpenTelemetry 之上,所以下面的內容也都適用。
|
||||
|
||||
## 跨越線路的追蹤 {#traces-that-cross-the-wire}
|
||||
|
||||
追蹤最有用的時候,是它能跟著一個請求從用戶端一路進到伺服器,呈現成一張連貫的圖。
|
||||
|
||||
當用戶端和伺服器都執行這個 SDK 時,這種串接是自動的。用戶端把 [W3C 追蹤上下文(trace context)](https://www.w3.org/TR/trace-context/) 注入請求,伺服器再把它讀出來,於是伺服器 span 會巢狀在同一條追蹤裡的用戶端 span 底下。這就是 [SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414),不用開口就有。
|
||||
|
||||
如果傳入訊息不帶追蹤上下文,例如來自非本 SDK 用戶端的請求,伺服器 span 就直接掛在伺服器端目前的 span 底下,而不是另起一條全新的孤立追蹤。
|
||||
|
||||
## 關掉它 {#turning-it-off}
|
||||
|
||||
追蹤是一個中介軟體,排在伺服器清單的第一個。如果真的想要一個完全不發出 span 的伺服器,把它拿掉:
|
||||
|
||||
```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
|
||||
那個 import 開頭有底線,這是刻意的。這個類別是暫定的,就像 [`Server.middleware`](../advanced/middleware.md) 是暫定的一樣,所以應該預期匯入路徑會改變。你幾乎不會需要這樣做:沒安裝 exporter 時 span 是免費的,所以通常的做法是讓它開著、不安裝 exporter 就好。
|
||||
|
||||
## 重點回顧 {#recap}
|
||||
|
||||
* 每個 `MCPServer` 和每個低階 `Server` 預設都會為每則傳入訊息發出一個 `SERVER` span。你什麼都不用寫。
|
||||
* span 帶有 `mcp.method.name` 和 `mcp.protocol.version`;`tools/call` 和 `prompts/get` 另外帶有 GenAI 屬性,讓你的工具呼叫和其他 agent 的一樣分組。
|
||||
* 在安裝 OpenTelemetry SDK 和 exporter 之前完全沒有成本,裝了之後就會亮起來,伺服器不用任何改動。
|
||||
* 兩端都執行這個 SDK 時,用戶端到伺服器的追蹤上下文會自動傳播。
|
||||
|
||||
至於決定一個請求到底能不能執行的,是 **[授權](authorization.md)**。
|
||||
Loading…
Reference in a new issue