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
50 changed files with 89737 additions and 54 deletions

View file

@ -0,0 +1,121 @@
---
translation:
sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
tool: 1
---
# MCP Apps {#mcp-apps}
**MCP App** 是一個有門面的工具:除了資料之外,工具還會指向一份 HTML 文件,由 MCP 主機(host)把它繪製成可互動的介面。
兩個部分,永遠都是兩個部分:
1. **一個工具**,負責做事並回傳資料,跟任何其他工具一樣。
2. **一個 `ui://` 資源**,裡面裝著主機要為它顯示的 HTML。
工具帶有一個指向該資源的 `_meta.ui.resourceUri` 參照。主機用 `resources/read` 取得它,在**沙箱化的 iframe** 裡繪製,再透過 `postMessage` 把工具的結果推進那個 iframe。伺服器從頭到尾不會送出或收到任何 `ui/*` 訊息:那些流量是主機和 iframe 之間的事。你提供一個工具和一份 HTML 文件,場面由主機負責。
SDK 以內建的 `Apps` 擴充功能(`io.modelcontextprotocol/ui`)提供這項功能。如果還不熟悉[擴充功能](extensions.md),先快速看過那一頁。一分鐘就好,看完再回來。
## 有錶面的時鐘 {#a-clock-with-a-face}
```python title="server.py" hl_lines="19 22 30 32"
--8<-- "docs_src/apps/tutorial001.py"
```
四個動作:
* `Apps()`:一個實例容納所有綁定 UI 的工具和它們的資源。
* `@apps.tool(resource_uri="ui://clock/app.html")`:一個普通的工具,外加 `_meta.ui.resourceUri` 標記。`@mcp.tool()` 接受的所有東西(name、title、description……)都會原樣傳下去。
* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`:對應的資源,以 `text/html;profile=mcp-app` 提供。正是這個 MIME 型別告訴主機「這是個 app,把它繪製出來」。
* `MCPServer("clock", extensions=[apps])`:選擇加入。伺服器現在會在 `capabilities.extensions` 底下宣告 `io.modelcontextprotocol/ui`。
HTML 本身會監聽主機的 `postMessage` 並顯示結果。真正的 app 請在 HTML 裡使用官方的 [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) 瀏覽器 SDK。它提供 `ontoolresult`、`callServerTool`、`getHostContext` 和 `onhostcontextchanged`,不用自己處理原始的訊息事件。
## 優雅降級 {#graceful-degradation}
不是每個用戶端都會繪製 app。規格對這代表什麼講得很直白:
> Tools **MUST** return a meaningful `content` array even when UI is available.
模型讀的是 `content`;iframe 是給人看的。支援 UI 的主機照樣會把文字結果餵給模型,而純文字用戶端**只**會拿到那個。所以標準做法是一個工具,兩種答案。再看一次 `get_time`:
```python title="server.py" hl_lines="23-27"
--8<-- "docs_src/apps/tutorial001.py"
```
只有當用戶端宣告了 `io.modelcontextprotocol/ui` 擴充功能,**而且**在它的 `mimeTypes` 設定裡列出 `text/html;profile=mcp-app` 時,`client_supports_apps(ctx)` 才會是 `True`。這個欄位是必填的,所以省略它的用戶端不算數。同一個檔案裡的 `main()` 宣告的正是這些:協商的用戶端那一半,於是回來的是豐富版的答案。
!!! warning
絕對不要把 `"[Rendered UI]"` 這類佔位文字當成唯一的內容回傳。如果後備文字沒有用,這個工具對每個純文字用戶端、對模型本身就都沒有用。好好寫那句話。
## 把 iframe 鎖緊 {#locking-the-iframe-down}
安全相關的中繼資料放在資源這一側:iframe 可以載入什麼、想要哪些瀏覽器權限、希望怎麼被嵌入:
```python title="server.py" hl_lines="9 19-22"
--8<-- "docs_src/apps/tutorial002.py"
```
`csp` 和 `permissions` 是**對主機的請求**,不是伺服器的行為。主機用它們建構 iframe 的 Content-Security-Policy 和 Permissions-Policy,而且可能拒絕。在 JS 裡做功能偵測,不要假設一定會獲准。
`ResourceCsp` 逐欄位說明(Python 名稱、線路上的鍵、主機拿它做什麼):
| Python | 線路(`_meta.ui.csp`) | 控制 |
|---|---|---|
| `connect_domains` | `connectDomains` | `connect-src`:`fetch`/XHR 可以連去哪裡 |
| `resource_domains` | `resourceDomains` | `img-src`、`style-src`……:靜態資產 |
| `frame_domains` | `frameDomains` | `frame-src`:巢狀 iframe |
| `base_uri_domains` | `baseUriDomains` | `base-uri`:`<base>` 可以指向哪裡 |
`ResourcePermissions`:每個欄位替 iframe 請求一項瀏覽器權限。
| Python | 線路(`_meta.ui.permissions`) |
|---|---|
| `camera` | `camera` |
| `microphone` | `microphone` |
| `geolocation` | `geolocation` |
| `clipboard_write` | `clipboardWrite` |
!!! note
CSP 和權限放在**資源**上,永遠不放在工具上。規格的工具中繼資料沒有它們的位置,放在那裡主機也會忽略。SDK 讓這個錯誤根本寫不出來:`@apps.tool()` 就是沒有 `csp` 參數。
### 可見性 {#visibility}
工具上的 `visibility=["app"]` 表示「這是為 iframe 存在的,不是為模型」:
* `"model"`:模型可以呼叫它。
* `"app"`:iframe 可以呼叫它(透過 `callServerTool`)。
* 省略:兩者皆可,這是預設值。
過濾是**主機**的工作。伺服器在 `tools/list` 裡照常列出僅限 app 的工具;主機負責對模型隱藏它們。不要在伺服器端過濾。
## SDK 強制執行的規則 {#the-rules-the-sdk-enforces}
這些全都在啟動時就失敗,不會等到上線:
* `resource_uri` 或資源 URI 不是 `ui://...`,會在裝飾/註冊時引發 `ValueError`。
* 工具綁定到一個**沒有對應已註冊資源**的 URI,會在 `MCPServer(extensions=[apps])` 取用這個擴充功能時引發 `ValueError`。一個宣稱有 HTML、`resources/read` 卻 404 的工具是設定錯誤,所以它拒絕建構。
* `@apps.tool()` 上的 `meta={"ui": ...}` 是 `ValueError`。`_meta["ui"]` 歸裝飾器管;要表達請用 `resource_uri=` 和 `visibility=`。其他的 `meta=` 鍵可以正常一起合併。
目前 TypeScript 的 ext-apps SDK 和 FastMCP 都不會攔下這些;我們寧可讓你比主機早一步發現。
## 不只是行內 HTML {#beyond-inline-html}
`add_html_resource` 涵蓋常見情況:一段 HTML 字串。其他情況,像是磁碟上的 HTML 或動態產生的內容,就自己建立資源再交出去:
```python title="server.py" hl_lines="12 18"
--8<-- "docs_src/apps/tutorial003.py"
```
資源沒有明確設定 MIME 型別時,`add_resource` 會補上 `text/html;profile=mcp-app`;明確設定卻不相符的則會拒絕:掛在任何其他 MIME 型別底下的 `ui://` 資源,沒有任何主機會繪製。
!!! tip
目標是某個 GA 前的主機,還在讀已棄用的扁平 `_meta["ui/resourceUri"]` 鍵?自己合併進去:`@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`。巢狀的 `ui` 物件才是規格的形狀;扁平鍵正在退場。
## 看它跑起來 {#see-it-run}
`examples/stories/` 裡的 `apps` 故事就是這一頁的可執行版本,成對出現:一個帶有綁定 UI 時鐘工具的伺服器,以及一個會協商 Apps、讀取工具的 `_meta.ui.resourceUri`、取得 HTML 並呼叫工具的用戶端。
```bash
uv run python -m stories.apps.client
```

View file

@ -0,0 +1,172 @@
---
translation:
sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff]
tool: 1
---
# 擴充功能 {#extensions}
**擴充功能**是掛在單一識別碼之下、需要主動啟用的一組 MCP 行為。
在伺服器上,它可以貢獻工具、資源和新的請求方法,也可以包裹 `tools/call`。在用戶端上,它可以認領額外的 `tools/call` 結果形狀,並觀察廠商通知。兩端各自在自己的 `capabilities.extensions` 底下宣告,對沒有要求它的人來說什麼都不會改變。這就是契約([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)),而它有一條黃金法則:**擴充功能預設是關閉的**。
## 使用擴充功能 {#using-an-extension}
在建構時傳入實例:
```python title="server.py"
--8<-- "docs_src/extensions/tutorial001.py"
```
完成。伺服器現在會在 `capabilities.extensions` 底下宣告 `io.modelcontextprotocol/ui`,並提供這個擴充功能貢獻的所有內容。
`Apps` 是內建的參考擴充功能,它有自己的專頁:**[MCP Apps](apps.md)**。
!!! note
擴充功能在建構時就固定了。沒有之後可以呼叫的 `add_extension`:伺服器的能力對映表在用戶端連線期間不應該改變。
能力對映表透過 `server/discover` 傳遞,而這是 **2026-07-28** 的路徑。舊版的 `initialize` 交握沒有地方可以放它,所以舊版用戶端根本看不到這個擴充功能。設計時要考慮到這一點:擴充功能是用來**增強**伺服器的,不能成為伺服器唯一可用的方式。
## 撰寫自己的擴充功能 {#writing-your-own}
繼承 `Extension`,只覆寫需要的部分。每個方法都有預設實作。
### 識別碼 {#the-identifier}
```python
--8<-- "docs_src/extensions/tutorial002.py"
```
識別碼是遵循規格 `_meta` 鍵語法的 `vendor-prefix/name` 字串:以點分隔的標籤(每個標籤以字母開頭,以字母或數字結尾)、一個斜線,接著是名稱。它在**類別定義時**就會驗證,所以打錯字不用等到伺服器啟動才發現:
```text
TypeError: Stamps.identifier must be a `vendor-prefix/name` string
(reverse-DNS prefix required), got 'stamps'
```
前綴請用你能掌控的網域。`io.modelcontextprotocol/*` 保留給 MCP 專案本身制定的擴充功能。
### 貢獻工具 {#contributing-tools}
最小的有用擴充功能是一個工具加上一個設定對映表:
```python title="server.py" hl_lines="17 19-20 22-23 26"
--8<-- "docs_src/extensions/tutorial003.py"
```
* `tools()` 回傳 `ToolBinding`。伺服器註冊每一個的方式,和你自己呼叫 `mcp.add_tool(...)` 完全一樣:同樣的 schema 產生、同樣的 `Context` 注入,全部都一樣。
* `settings()` 是在 `capabilities.extensions["com.example/stamps"]` 宣告的值。回傳 `{}`(預設值)表示宣告這個擴充功能但不帶任何設定。
* 擴充功能永遠不會拿到伺服器。它以資料的形式宣告貢獻,由 `MCPServer` 取用。沒有 `self.server` 可以修改。
而 `main()` 就是證明,一個記憶體內用戶端直接連上 `mcp`:
```python title="server.py" hl_lines="29-34"
--8<-- "docs_src/extensions/tutorial003.py"
```
### 提供自己的方法 {#serving-your-own-methods}
擴充功能可以註冊**新的請求方法**:屬於它自己的動詞,和規格定義的方法並列提供:
```python title="server.py" hl_lines="16-22 31 40-48"
--8<-- "docs_src/extensions/tutorial004.py"
```
* `SearchParams` 繼承 `RequestParams`,所以 2026 的 `_meta` 信封能以一致的方式解析,處理函式拿到的是驗證過的參數,永遠不會是原始 dict。對用戶端能控制的東西設下界限:`Field(ge=1, le=100)` 會在你的程式碼為它配置任何東西之前,就拒絕離譜的 `limit`。
* `require_client_extension(ctx, EXTENSION_ID)` 是關卡:沒有宣告這個擴充功能的用戶端會收到 `-32021`(缺少必要的用戶端能力)錯誤,附帶規格要求的機器可讀 `requiredCapabilities` 內容。
* `protocol_versions=frozenset({"2026-07-28"})` 把這個方法釘在單一線路版本上。在其他任何版本,用戶端會收到 `METHOD_NOT_FOUND`,就像這個方法在那裡不存在一樣。對那個用戶端來說,它確實不存在。
方法是**嚴格附加的**。SDK 在建構時就強制這一點,而不是在執行時:
* 為規格定義的方法(`tools/list`、`completion/complete`……)建立的 `MethodBinding`,在繫結建構時就會引發 `ValueError`。核心動詞屬於伺服器。
* 兩個擴充功能繫結同一個方法時,第二個註冊時會引發例外。後寫者勝出正是外掛互相搞壞對方的方式;我們不這麼做。
* 空的 `protocol_versions` 集合也會引發例外:一個永遠無法被提供的方法是 bug,不是設定。
### 用戶端這一側 {#the-client-side}
同一個檔案的 `main()` 就是完整的用戶端故事,兩半都在裡面:
```python title="server.py" hl_lines="54-58"
--8<-- "docs_src/extensions/tutorial004.py"
```
* `Client(..., extensions=[advertise(EXTENSION_ID)])` 宣告這個擴充功能。這些宣告會變成 `ClientCapabilities.extensions`:在 2026-07-28 連線上,這個對映表隨著每個請求的 `_meta` 信封傳送,所以伺服器在**每一個**請求上都看得到它;在舊版連線上,它則搭著 `initialize` 交握傳送。伺服器程式碼不用在意是哪一種:`require_client_extension(ctx, ...)` 和 `ctx.session.check_client_capability(...)` 在兩條路徑上都會讀取正確的來源。
* 廠商方法要往下一層用 `client.session.send_request(...)`;`Client` 只會為規格動詞長出一級方法。`send_request` 接受任何 `Request` 子類別,所以廠商請求原樣傳遞即可。
### 攔截 `tools/call` {#intercepting-toolscall}
唯一一個攔截式的掛鉤。覆寫 `intercept_tool_call` 來觀察、短路或否決工具呼叫:
```python title="server.py" hl_lines="17-24"
--8<-- "docs_src/extensions/tutorial005.py"
```
* `params` 是驗證過的 `CallToolRequestParams`:不用碰原始 JSON 就能拿到 `params.name` 和 `params.arguments`。決定執行哪個工具呼叫的也是它:透過 `call_next` 傳入改寫過的上下文,改變的是處理函式在 `ctx` 上觀察到的東西,而不是工具的呼叫本身。線路層級的請求改寫屬於[中介軟體](middleware.md)的範疇。
* `call_next(ctx)` 執行鏈的其餘部分並回傳處理函式的結果。原樣回傳(觀察)、回傳別的東西(取代),或引發 `MCPError`(拒絕)。不管回傳什麼,都會像任何處理函式結果一樣序列化,包括 2026 世代的 `serverInfo` 身分戳記,所以短路的攔截器永遠不會產生匿名或不符 schema 的回應。
* 有多個擴充功能時,攔截器依註冊順序巢狀套疊:`extensions=[...]` 裡的第一個擴充功能在最外層。
* 預設實作是直接放行,而擴充功能從未覆寫這個掛鉤的伺服器,會保持原本的 `tools/call` 處理函式不動。沒用到的東西不用付出代價。
這個掛鉤只包裹 `tools/call`,別無其他。需要處理每一則訊息的事情,請用[中介軟體](middleware.md)。那正是它的用途。
## 使用用戶端擴充功能 {#using-a-client-extension}
**用戶端擴充功能**是從使用端看的同一份契約:掛在單一識別碼之下的一組用戶端行為。把實例傳給 `Client(extensions=[...])`,然後照常呼叫工具:
```python title="client.py" hl_lines="66-68"
--8<-- "docs_src/extensions/tutorial006.py"
```
`call_tool("buy", ...)` 回傳普通的 `CallToolResult`,和其他所有呼叫一樣。擴充功能改變的是:伺服器現在可以用 `receipt` **結果形狀**來回應 `buy`,而不是最終結果,而 `Receipts` 會在 `call_tool` 回傳之前把它完成(這裡是透過後續呼叫兌換收據)。呼叫端的程式碼完全不用動。
拿掉這個擴充功能,這一切就不存在:伺服器的關卡會拒絕沒有宣告它的用戶端(錯誤 -32021),而來自跳過關卡的伺服器的認領形狀會驗證失敗,完全符合規格對無法辨識的 `resultType` 的要求。預設關閉,線路的兩端都是。
要宣告一個**沒有**任何用戶端行為的識別碼(伺服器以這個能力為關卡,用戶端什麼都不做,就像上面的搜尋用戶端),使用 `advertise()`:
```python
from mcp.client import advertise
client = Client(mcp, extensions=[advertise("com.example/search")])
```
## 撰寫用戶端擴充功能 {#writing-a-client-extension}
繼承 `ClientExtension`,只覆寫需要的部分。三種貢獻類型,各有預設實作:`settings()`、`claims()` 和 `notifications()`。
```python title="client.py" hl_lines="17-18 43-44 46-47"
--8<-- "docs_src/extensions/tutorial006.py"
```
* 識別碼遵循和伺服器相同的語法,在類別定義時驗證。
* `claims()` 回傳 `ResultClaim`:一個線路標籤、解析它的模型,以及完成它的解析器。模型必須用 `result_type: Literal["receipt"]` 釘住標籤,而且不可以繼承該動詞的核心結果型別;兩者都在認領建構時強制檢查。像 `receipt_token` 這樣的廠商欄位原樣走線路:被替換的形狀會一字不差地抵達用戶端。
* 解析器會收到解析過的模型和一個 `ClaimContext`;`ctx.session` 和 `client.session` 是同一個公開控制柄,所以後續動作就是一般的工作階段(session)呼叫。它回傳該動詞正常的 `CallToolResult`。
* `settings()` 是在 `ClientCapabilities.extensions[identifier]` 宣告的值,在 `Client` 建構時讀取一次。
`notifications()` 宣告要觀察的廠商伺服器通知:
```python
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)]
```
處理函式一次收到一個驗證過的參數,依分派順序。它只觀察;不能否決或回覆。
兩條低調的規則。認領只在 2026-07-28 連線上生效,而能力宣告跟著它們走:在舊版連線上,認領會消失,識別碼也會跟著從宣告中移除,所以用戶端永遠不會宣告一個它會拒絕其形狀的擴充功能。另外,當你想自己拿到認領的形狀而不是交給解析器時,呼叫 `client.session.call_tool(..., allow_claimed=True)`;沒有這個旗標時,認領形狀抵達工作階段層的呼叫端會引發 `UnexpectedClaimedResult`。
### 擴充功能動詞 {#extension-verbs}
擴充功能自己的請求方法不需要用戶端註冊。廠商請求型別繼承 `mcp.types.Request`,並透過 `client.session.send_request` 送出,如[提供自己的方法](#serving-your-own-methods)所示。多一件事:當某個參數鍵必須搭上 `Mcp-Name` 標頭時(像 tasks 這類擴充功能規格對它們的動詞有此要求),請求型別要宣告 `name_param`:
```python title="client.py" hl_lines="22-25 46-47"
--8<-- "docs_src/extensions/tutorial007.py"
```
工作階段會在每一條送出路徑上把 `params["jobId"]` 鏡射到 `Mcp-Name`,而缺少值時會明確失敗,而不是默默省略必要的標頭。
## 擴充功能不能做的事 {#what-an-extension-cannot-do}
貢獻介面是刻意**封閉**的。在伺服器上:設定、工具、資源、方法、一個 `tools/call` 攔截器。在用戶端上:設定、結果認領、通知繫結。擴充功能不能:
* **伸手進外層的伺服器或用戶端。**它只宣告資料,不持有任何伺服器或用戶端的參考。
* **取代核心行為。**規格方法和核心結果標籤在建構時就會被拒絕(`initialize` 直接由執行器保留);被核心詞彙遮蔽的通知繫結則會靜默並發出警告。
* **延後註冊。**`MCPServer(...)` 或 `Client(...)` 回傳之後,擴充功能集合就定了。
如果你在跟這些牆對抗,你寫的就不是擴充功能,而是 fork。這些牆本身就是功能:讀到 `extensions=[Apps(), Stamps()]` 的使用者,就知道這兩個東西**所有**可能碰過的地方。

View file

@ -0,0 +1,22 @@
---
translation:
sections: [ca6988b7503cd2d3]
tool: 1
---
# 進階 {#advanced}
一般的伺服器或用戶端需要的東西,在前面的章節裡都有對應的主題可循。這一節是當 `MCPServer` 的便利層反而礙事時,可以拿來用的逃生門:
* **[低階 Server](low-level-server.md)**:`MCPServer` 建構於其上的類別。手寫的 schema、`on_*` 處理函式、沒有任何東西會幫你檢查,還可以加上自訂的 JSON-RPC 方法。
* **[分頁](pagination.md)** 和 **[中介軟體](middleware.md)**:兩件**只**能在低階 `Server` 上做的事。
* **[擴充功能](extensions.md)** 和 **[MCP Apps](apps.md)**:協定的擴充介面。把擴充功能套件組合進伺服器,或自己寫一個。
有幾樣東西你可能理所當然會來這裡找,但它們其實放在實際會用到的地方:
* **授權** 放在 **[執行伺服器](../run/index.md)** 底下,因為伺服器部署在哪裡,就在哪裡保護它。
* **OAuth**、**身分斷言**、連接 **多個伺服器**,以及回應 **快取**,都在 **[用戶端](../client/index.md)** 底下。
* **多輪往返(multi-round-trip)請求** 和 **訂閱** 放在 **[在處理函式內部](../handlers/index.md)** 底下,因為兩者都是處理函式 **會做** 的事。
* **URI 範本** 放在 **[伺服器](../servers/index.md)** 底下,就在資源旁邊。
* **[協定版本](../protocol-versions.md)** 和 **[已棄用的功能](../deprecated.md)** 則各有自己的頂層頁面。
如果不確定自己需不需要這一節,那就是不需要。

View file

@ -0,0 +1,206 @@
---
translation:
sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e]
tool: 1
---
# 低階 Server {#the-low-level-server}
`@mcp.tool()` 是一層包裝。底下還有第二個伺服器類別 `Server`,講的是原始的 MCP:把協定物件交給它,它就原封不動地放上線路。
`MCPServer` 就是建構在它之上。當便利層礙事時,才往下走:
* 需要送出**精確**的 schema(從檔案載入、從資料庫產生),而不是從 Python 簽章推導出來的。
* 需要完全掌控結果:`_meta`、`is_error`、`structured_content` 的每一個鍵。
* 需要處理 MCP 沒有定義的方法。
其他情況,就留在 `MCPServer`。
## 同一個工具,手工打造 {#the-same-tool-by-hand}
這是 **[工具](../servers/tools.md)** 用九行 `@mcp.tool()` 寫出的 `search_books` 工具,拿掉語法糖之後的樣子:
```python title="server.py" hl_lines="22 26 32"
--8<-- "docs_src/lowlevel/tutorial001.py"
```
改了三件事,而這三件事就是整個低階 API:
* **處理函式是建構子參數。** `on_list_tools=` 和 `on_call_tool=` 傳進 `Server(...)`。這一層沒有裝飾器,而且每個處理函式的形狀都一樣:`async (ctx, params) -> result`。
* **輸入 schema 自己寫。** `Tool.input_schema` 是普通的 JSON Schema `dict`。沒有人會從型別提示推導它,因為根本沒有型別提示可以推導。
* **結果自己組。** `CallToolResult(content=[TextContent(...)])`,手動建立。沒有任何東西會被包裝、轉換,或從回傳註記推斷出來。
`params` 是解析後的請求:`CallToolRequestParams` 提供 `.name` 和 `.arguments`。`ctx` 是 `ServerRequestContext`:`ctx.session` 用來回頭和用戶端溝通,還有 `ctx.lifespan_context`、`ctx.request_id`,以及 `ctx.meta`,也就是請求傳入的 `_meta`。
!!! info
如果用過 FastAPI,這個關係你早就認識了。`MCPServer` 是裝飾器加型別提示的那一層;`Server` 是底下的 Starlette。兩者不是競爭對手:`MCPServer` 會建立一個 `Server`,並在上面註冊和這些一模一樣的處理函式。
### 試試看 {#try-it}
這個沒有 Inspector 可用:`mcp dev` 和 `mcp run` 只接受 `MCPServer`。記憶體內的 `Client` 則不在乎;它接收低階 `Server` 的方式和接收 `MCPServer` 完全一樣:
```python title="main.py"
import asyncio
from mcp import Client
from server import server
async def main() -> None:
async with Client(server) as client:
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
print(result.content)
asyncio.run(main())
```
```text
[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)]
```
和 `@mcp.tool()` 版本產生的文字一模一樣。坦白說有兩個差異:
* `result.structured_content` 是 `None`。高階伺服器會幫你把 `-> str` 包成 `{"result": ...}`;在這裡,你沒建的東西,沒有人會替你建。
* `list_tools` 回傳的是**你**打出來的 schema,一字不差。高階版本每個屬性上都有 `"title": "Query"`,根部還有一個 `"title": "search_booksArguments"`:那是 Pydantic 的產物。在這一層,線路上有的東西,都是你放上去的。
## 沒有人替你檢查 {#nothing-is-checked-for-you}
`MCPServer` 會在函式執行之前就拒絕錯誤的引數,依照它產生的 schema 驗證這次呼叫(**[工具](../servers/tools.md)**)。
`Server` 不做這件事。你的 `input_schema` 是**公告**給用戶端看的;從來不會**套用**到 `params.arguments` 上。
!!! check
呼叫 `search_books` 時不帶 `limit`,`args["limit"]` 就會引發 `KeyError`。用戶端看到的是:
```text
MCPError: Internal server error
```
一個 JSON-RPC 錯誤,錯誤碼 `-32603`,訊息刻意寫得很籠統:SDK 不會把你的 traceback 洩漏給遠端呼叫端。模型永遠不知道自己哪裡做錯,所以無法重試。(在測試中,`raise_exceptions=True` 會改為浮現真正的例外;請見 **[測試](../get-started/testing.md)**。)
這可以推而廣之。從低階處理函式引發的例外**永遠**是協定錯誤,絕不會是 `is_error=True` 的工具結果。如果希望模型讀到失敗並恢復,就自己驗證 `params.arguments`,然後回傳 `CallToolResult(content=[TextContent(...)], is_error=True)`。這兩種失敗正是 **[處理錯誤](../servers/handling-errors.md)** 的主題。
## 兩個工具,一個處理函式 {#two-tools-one-handler}
`on_call_tool` 是伺服器上所有工具唯一的進入點。依 `params.name` 分派:
```python title="server.py" hl_lines="38-43"
--8<-- "docs_src/lowlevel/tutorial002.py"
```
* `list_tools` 公告兩者。`call_tool` 依名稱分派。
* `else` 分支很重要:就算是你從沒列出過的名稱,`Server` 也會照樣把它的 `tools/call` 直接轉進你的處理函式。在那裡引發例外,這次呼叫就會變成和上面一樣的 `-32603`。
## 結構化輸出,手工打造 {#structured-output-by-hand}
在 `Tool` 上宣告 `output_schema`,並在結果上放 `structured_content`。兩者都由你負責:
```python title="server.py" hl_lines="19-23 36"
--8<-- "docs_src/lowlevel/tutorial003.py"
```
呼叫它,結果會同時帶著兩種表示法:
```json
{
"content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
"structuredContent": {"matches": 3, "query": "dune"},
"isError": false,
"resultType": "complete",
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}}
}
```
`_meta` 區塊是伺服器的身分戳記:SDK 會把它加到每個 2026 世代的結果上,`version` 取自建構子(沒設定的伺服器會回報空字串)。不能表明身分的伺服器可以用中介軟體把這個鍵拿掉,中介軟體擁有它回傳的結果。
伺服器從不比對這兩個欄位。這個 SDK 的 `Client` 會:回傳的 `structured_content` 如果不符合你宣告的 `output_schema`,`call_tool` 就會引發 `RuntimeError`,訊息以 `Invalid structured content returned by tool search_books` 開頭,接著引用 `jsonschema` 的失敗內容。承諾一個 schema 很便宜;守住承諾是你的事。回傳型別與 schema 的完整階梯請見 **[結構化輸出](../servers/structured-output.md)**。
## `_meta`:給應用程式,不是給模型 {#\_meta-for-the-application-not-the-model}
`content` 是答案中模型會讀的部分。`structured_content` 是同一個答案的型別化資料。`_meta` 是第三個管道:跟著結果一起送給**用戶端應用程式**的資料,完全不屬於答案的一部分。
用它放紀錄 ID、追蹤 ID,任何 UI 需要而提示詞不需要的東西:
```python title="server.py" hl_lines="37"
--8<-- "docs_src/lowlevel/tutorial004.py"
```
* 建構時寫成 `_meta=`,也就是線路上的名稱。用戶端讀回來時是 `result.meta`。
* 替鍵加上命名空間(`bookshop/record_ids`)。`io.modelcontextprotocol/*` 這些鍵由協定保留。
!!! warning
`_meta` 是你和用戶端應用程式之間的約定,不保證什麼會送到模型。要呈現什麼由 MCP 主機(host)決定。永遠不要在工具結果的任何部分放機密。
## 能力跟著處理函式走 {#capabilities-follow-your-handlers}
`Server` 公告的方法族群,恰好就是你給了處理函式的那些。上面的 `Bookshop` 只傳了 `on_list_tools` 和 `on_call_tool`,其他什麼都沒有,所以連上它的用戶端會看到:
```json
{"tools": {"listChanged": false}}
```
沒有 `resources`,沒有 `prompts`:背後沒有東西支撐它們。傳入 `on_list_prompts`,`prompts` 就會出現;傳入 `on_completion`,`completions` 就會出現。
`MCPServer` 不管你有沒有註冊,都一律公告工具、資源和提示詞,因為它的管理器永遠存在。在這一層,宣告**就是**那個建構子呼叫。
## 生命週期泛型 {#the-lifespan-generic}
`Server` 對其生命週期 yield 出的型別是泛型的。註記一次,這個物件在每個出現的地方都有型別:
```python title="server.py" hl_lines="24-26 44-45 50"
--8<-- "docs_src/lowlevel/tutorial005.py"
```
* 生命週期是一個 `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`;在 `async` 產生器上套 `@asynccontextmanager` 就正好得到這個。
* 它 `yield` 出的東西會變成 `ctx.lifespan_context`,而因為處理函式註記為 `ServerRequestContext[Catalog]`,`.search(...)` 可以自動完成,也能通過型別檢查。
* 伺服器啟動時進入一次,停止時離開一次。啟動、收尾,以及 `MCPServer` 對同一個概念的版本,請見 **[生命週期](../handlers/lifespan.md)**。
沒有 `lifespan=` 的話,`ctx.lifespan_context` 是一個空的 `dict`。
## 自己的方法 {#a-method-of-your-own}
建構子涵蓋 MCP 定義的方法。其他的一切由 `add_request_handler` 負責:
```python title="server.py" hl_lines="35-36 39-40 43-44 48"
--8<-- "docs_src/lowlevel/tutorial006.py"
```
* 第一個引數是方法字串。通知有個孿生的 `add_notification_handler`。
* `params_type` 是傳入的 `params` 在處理函式執行**之前**用來驗證的模型,所以自訂方法**確實**享有工具沒有的驗證。繼承 `RequestParams`,讓 `_meta` 欄位和其他方法一樣解析。
* 處理函式回傳 `BaseModel`、`dict` 或 `None`。SDK 會把它序列化成 JSON-RPC 結果。
一個坦白的提醒:高階 `Client` 只有對應 MCP 定義方法的動詞,所以沒有 `client.reindex()`。廠商方法是給已經知道它存在的對端用的:你同時發佈的用戶端,或是你自己另一個講 JSON-RPC 的服務。
有一個方法你不能占用:
```text
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
use Server.middleware to observe or wrap initialization
```
交握屬於執行器。`server/discover`、`ping`,以及其他所有內建方法,都可以替換。
!!! tip
那則錯誤裡提到的 `Server.middleware` 會包住**每一則**傳入訊息,包括 `initialize`。如果想做的是觀察或改寫流量,而不是回應新方法,請從 **[中介軟體](middleware.md)** 開始。
## 其他處理函式 {#the-other-handlers}
下面每一項都是一個你現在已經有詞彙可以理解的概念;每一項都有自己的頁面。
* `on_call_tool`、`on_get_prompt` 和 `on_read_resource` 可以回傳 `InputRequiredResult` 取代正常結果,暫停呼叫並向用戶端要求輸入;請見 **[多輪往返(multi-round-trip)請求](../handlers/multi-round-trip.md)**。忠於這一層的風格,沒有任何東西會替你裝好:`MCPServer` 預設會封裝 `requestState`,在這裡你設定的 `request_state` 會一字不差地跨過線路,直到你用 `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))` 選擇加入:一行(兩個名稱都從 `mcp.server.request_state` 匯入)就能得到和 `MCPServer` 完全相同的封裝與驗證(**[保護 `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**)。
* `on_list_resources`、`on_read_resource`、`on_list_prompts`、`on_get_prompt`、`on_completion` 是其他基本元件的同一個 `(ctx, params) -> result` 形狀。
* `on_subscriptions_listen` 負責 2026-07-28 的 `subscriptions/listen` 串流。傳入一個建構在 `SubscriptionBus` 之上的 `ListenHandler`,並從其他處理函式把事件發佈到 bus;完整的組合方式請見 **[訂閱](../handlers/subscriptions.md)**。
* `server.streamable_http_app()` 回傳的 Starlette 應用程式和 `MCPServer` 的一樣;照 **[執行伺服器](../run/index.md)** 部署其他 ASGI 應用程式的方式部署它。這一層沒有 `server.run(transport=...)`:`server.run(read_stream, write_stream, server.create_initialization_options())` 透過一對串流驅動一條連線,而這一行就是全部。
## 重點回顧 {#recap}
* 低階 `Server` 以 `on_*` **建構子參數**接收處理函式;每個處理函式都是 `async (ctx, params) -> result`。
* `input_schema` dict 自己寫,`CallToolResult` 自己組。沒有任何東西會替你推導、包裝或驗證。
* 處理函式裡的例外是 `-32603` 協定錯誤。模型讀得到的工具錯誤,是**你**回傳的 `is_error=True` 的 `CallToolResult`。
* 結果上的 `_meta` 是給用戶端應用程式的,不是給模型的。
* `Server[T]` 對其生命週期 yield 出的東西是泛型的;`ctx.lifespan_context` 是有型別的 `T`。
* `add_request_handler(method, params_type, handler)` 可以服務任何方法。`initialize` 被保留。
* `Server` 公告的能力,由你註冊了哪些處理函式推導而來。
`Client(server)` 對兩種伺服器一視同仁,因為它們**就是**同一個協定,這正是重點所在。再往下一層根本不是類別:是 **[中介軟體](middleware.md)**。

View file

@ -0,0 +1,84 @@
---
translation:
sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c]
tool: 1
---
# 中介軟體 {#middleware}
**中介軟體(middleware)**是一個非同步函式,包住伺服器收到的每一則訊息。
寫成 `async (ctx, call_next)` 的形式,再附加到 `server.middleware` 就好。整個 API 就這樣。
!!! warning
中介軟體清單在原始碼裡標示為**暫定(provisional)**:它的簽章和語意可能在 2.x 的小版本中變動。用它來**觀察**(計時、記錄、追蹤)和**拒絕**訊息;不要把它當成伺服器賴以運作的基礎。
`MCPServer` 在建構時接收這份清單(`MCPServer(name, middleware=[...])`),並以 `mcp.middleware` 公開;低階的 `Server` 則以 `server.middleware` 公開同一份清單。下面的範例使用低階的 `Server`;如果還沒見過 `Server(name, on_call_tool=...)`,請先讀 **[低階 Server](low-level-server.md)**。
## 一個計時中介軟體 {#a-timing-middleware}
一個伺服器、一個工具、一個中介軟體,記錄每則訊息花了多久:
```python title="server.py" hl_lines="39-45 49"
--8<-- "docs_src/middleware/tutorial001.py"
```
* `ctx` 就是處理函式收到的同一個 `ServerRequestContext`。`ctx.method` 是原始的方法字串;`ctx.params` 是原始的參數,尚未經過**任何**驗證。
* `call_next(ctx)` 會執行鏈上剩下的部分:驗證、查找處理函式、你的處理函式。把它的回傳值原樣回傳,回應就不會被動到。
* `try`/`finally` 是刻意的:引發例外的處理函式一樣會被計時,因為失敗會以 `call_next` 拋出的例外形式抵達你的中介軟體。
* `server.middleware.append(...)` 完成註冊。清單由最外層開始執行,所以 `middleware[0]` 是最靠近線路的那一個。
### 試試看 {#try-it}
連上一個用戶端,列出工具,呼叫其中一個。記錄裡會有**三**行:
```text
server/discover took 18.3 ms
tools/list took 0.1 ms
tools/call took 0.1 ms
```
呼叫了兩次,卻得到三行。第一行是 `server/discover`:這是用戶端為了建立連線而送出的請求,早在你要求任何東西之前。
重點就在這裡。中介軟體包住**每一則**傳入的訊息:
* 連線建立階段:`server/discover`,或在舊版工作階段(session)上的 `initialize` 和 `notifications/initialized`。
* 每一個請求和每一則通知。對通知而言,`ctx.request_id is None`,`call_next(ctx)` 回傳 `None`,而你回傳的任何東西都會被丟棄。
* 連伺服器沒有處理函式的方法也一樣:`call_next` 會引發 `MCPError(-32601, "Method not found")`,**穿過**你的中介軟體一路送到用戶端。
## 在裡面能做什麼 {#what-you-can-do-inside-one}
依照該有的猶豫程度,由低到高排列:
* **觀察。**計時、計數、記錄。就是上面的範例。
* **拒絕。**不呼叫 `call_next(ctx)`,**改為**引發 `MCPError`,那一則訊息就會以 JSON-RPC 錯誤回應。連線不會斷;下一則訊息照常通過。伺服器就是這樣依呼叫端控管 `subscriptions/listen` 的:訂閱頁面的 **[決定誰可以觀看](../handlers/subscriptions.md#deciding-who-may-watch)** 有逐步說明。
* **改寫。**`ctx` 是一個 dataclass:`await call_next(dataclasses.replace(ctx, params=...))` 會把和用戶端送來的不同的參數交給鏈上剩下的部分。絕對不要對 `initialize` 這麼做:用戶端拿到的結果是根據你改寫後的參數建立的,但伺服器提交連線狀態時用的是線路上原本的參數。雙方可能在交握結束時,對彼此協商出的內容認知不一致。
* **回答。**不呼叫 `call_next(ctx)` 就直接回傳一個結果,它會作為你的回應送到用戶端。`call_next` 交給你的是完成的線路格式,而管線絕不會修補你回傳的東西,所以整個封包都由你負責:在 2026 世代的連線上,這包括 `serverInfo` 的 `_meta` 戳記,SDK 會替處理函式的結果加上它,但不會替你的加。
!!! check
`initialize` 是中介軟體包住的東西之一,而且這是它**唯一**的掛鉤點。試著用 `add_request_handler` 接管它,SDK 會拒絕:
```text
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
use Server.middleware to observe or wrap initialization
```
!!! warning
`initialize` 是就地處理的:在你的中介軟體鏈回傳之前,伺服器不會再讀取任何傳入的訊息。因此在處理 `initialize` 時等待一個伺服器對用戶端的請求(`ctx.session.send_request(...)`、一次徵詢(elicitation)),會**讓連線死結**:你在等的回應永遠讀不到。射後不理的通知則沒問題。
## 唯一一個預設就啟用的中介軟體 {#the-one-middleware-that-ships-on-by-default}
SDK 只附帶一個中介軟體,而且它已經在伺服器的清單上了:為每則訊息發出一個 OpenTelemetry span 的那一個。不需要自己附加,大多數時候也不用去想它。在安裝匯出器之前它什麼都不做,而且有自己的頁面:**[OpenTelemetry](../run/opentelemetry.md)**。
!!! info
如果寫過 ASGI 中介軟體,這個形狀你已經認得。Starlette 的 `(scope, receive, send)` 變成了 `(ctx, call_next)`,而且它在傳輸**之後**執行,處理的是解碼後的訊息而不是原始的 HTTP 請求。兩者可以組合:掛在 `streamable_http_app()` 上的 Starlette 中介軟體看到的是 HTTP;這裡看到的是 MCP。
## 重點回顧 {#recap}
* 中介軟體是 `async (ctx, call_next) -> result`,以 `MCPServer(middleware=[...])` 傳入(或附加到 `mcp.middleware`),在低階的 `Server` 上則附加到 `server.middleware`。
* 它包住**每一則**傳入的訊息(`server/discover`、`initialize`、請求、通知、未知的方法),並由最外層開始執行。
* 用 `ctx.request_id is None` 區分通知和請求。
* 不呼叫 `call_next` 改為引發例外,就能拒絕一則訊息;連線會存活下來。
* SDK 自己的 OpenTelemetry 追蹤也是一個中介軟體,已經在清單上。請見 **[OpenTelemetry](../run/opentelemetry.md)**。
* 整個介面都是暫定的。用它來觀察;不要在它上面蓋東西。
以上就是包住請求的一切。至於請求到底能不能執行,則由 **[授權](../run/authorization.md)** 決定。

View file

@ -0,0 +1,81 @@
---
translation:
sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9]
tool: 1
---
# 分頁 {#pagination}
大多數伺服器永遠用不到這個。
`MCPServer` 回應每個 `list_*` 請求時,都把手上所有東西一次給完:一頁,`next_cursor=None`。對幾十個工具、資源或提示詞來說,這就是正確的答案,沒有什麼需要設定。
分頁是給資源清單其實是資料庫的那種伺服器用的:成千上萬列,它拒絕在一個回應裡全部序列化。協定的答案是**游標(cursor)**:伺服器回傳一頁加上一個不透明的 token,用戶端把這個 token 送回去,就能拿到下一頁。
`@mcp.resource()` 沒有任何掛鉤可以做這些事。要分頁,就得在 **[低階 Server](low-level-server.md)** 上自己寫清單處理函式。
## 會分頁的伺服器 {#a-server-that-pages}
```python title="server.py" hl_lines="12 15-16"
--8<-- "docs_src/pagination/tutorial001.py"
```
* 在低階 `Server` 上,處理函式是建構子引數,不是裝飾器。`on_list_resources` 回應每一個 `resources/list` 請求;整個接線就這樣。
* 每個分頁處理函式的型別都是 `params: PaginatedRequestParams | None`,範例兩種都接受。不過在實際連線上,SDK 永遠不會交給你 `None`(沒有 `params` 成員的請求抵達處理函式時,會是帶著預設值的模型),所以真正重要的訊號是 `params.cursor is None`:**從頭開始**。
* 游標**是**什麼由你決定。這裡是轉成字串的偏移量。時間戳記、主鍵、base64 blob:任何送出時能產生、送回來時認得出的東西都可以。
* `next_cursor=None` 就是「那是最後一頁」的說法。沒有計數、沒有總數、沒有 `has_more`。`None` 就是全部的訊號。
!!! tip
`PAGE_SIZE` 設成 10 是為了讓範例好讀。依端點各自挑選:一行就講完的資源清單,一頁 500 個也負擔得起;一堆肥大的提示詞範本清單就不行。用戶端對此沒有發言權,這是刻意的設計。
### 試試看 {#try-it}
`Client(server)` 在記憶體內連線到低階 `Server` 的方式,和連到 `MCPServer` 完全一樣。
不帶引數呼叫 `list_resources()`。會拿到十個資源,`book-1` 到 `book-10`,而 `next_cursor` 是字串 `"10"`。
用 `list_resources(cursor="10")` 把它交回去,第一個資源就是 `book-11`,新的 `next_cursor` 是 `"20"`。
第十頁回來時 `next_cursor` 是 `None`。結束。
## 用戶端迴圈 {#the-client-loop}
`Client` 上的每個 `list_*` 方法(`list_tools`、`list_resources`、`list_resource_templates`、`list_prompts`)都接受 `cursor=` 關鍵字引數。把分頁清單抓完只要一個 `while True`:
```python title="client.py" hl_lines="26-32"
--8<-- "docs_src/pagination/tutorial002.py"
```
* `cursor` 一開始是 `None`,所以第一個請求不帶游標。
* 先 extend,**再**看 `next_cursor`:最後一頁也有資源。
* `next_cursor is None` 就是出口。其他任何值都原封不動直接放回 `cursor=`。
執行它的 `main()`,會印出 `100 resources`:十頁、每頁十個,由一個從頭到尾不知道有十頁的迴圈接起來。
這和 **[用戶端](../client/index.md)** 為每個 `list_*` 動詞示範的迴圈是同一個,而且對不分頁的伺服器也沒有任何代價:第一個回應的 `next_cursor` 就是 `None`,迴圈只執行一次。
## 三條規則 {#the-three-rules}
**游標是不透明的。** 用戶端絕不能解析、組裝或猜測游標。游標唯一合法的來源,是上一頁的 `next_cursor`,一字不改。
**頁面大小由伺服器決定。** 協定裡沒有 `limit=`。如果需要不同的頁面大小,改的是伺服器。
**忽略分頁的用戶端照樣能用。** 它呼叫一次 `list_resources()`,拿到前十個,永遠不會注意到被它丟掉的 `next_cursor`。什麼都沒壞,只是看到的比較少。
!!! check
不透明就是不透明。自己發明一個游標(`list_resources(cursor="page-2")`),協定也幫不了你。這個伺服器會嘗試 `int("page-2")`,處理函式引發例外,回到用戶端的是:
```text
MCPError(-32603, 'Internal server error', None)
```
不是從伺服器拿到的游標是 bug,不是功能需求。
## 重點回顧 {#recap}
* `MCPServer` 一頁回傳全部。分頁是選擇性啟用的,而啟用的地方是低階 `Server`。
* `on_list_resources`(以及 `on_list_tools`、`on_list_prompts`、`on_list_resource_templates`)收到 `PaginatedRequestParams | None`;第一頁時 `params.cursor` 是 `None`。
* 回傳一頁加上 `next_cursor`:任何之後認得出的字串,或在沒有東西剩下時回傳 `None`。
* 用戶端迴圈:傳入 `cursor=`、累積、重複,直到 `next_cursor is None`。
* 游標是不透明的,頁面大小歸伺服器管,不分頁的用戶端還是拿得到第一頁。
手寫 `Server` API 的其餘部分(`on_call_tool`、`input_schema` dict、`_meta`)在 **[低階 Server](low-level-server.md)**。

View file

@ -0,0 +1,119 @@
---
translation:
sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800]
tool: 1
---
# 快取提示 {#caching-hints}
在 2026-07-28 協定上,伺服器為 `tools/list`、`prompts/list`、`resources/list`、`resources/templates/list`、`resources/read` 和 `server/discover` 回傳的每個結果都帶有兩個欄位:`ttlMs`,表示用戶端可以把這個結果視為新鮮的毫秒數;`cacheScope`,表示快取的結果可以跨使用者共用(`"public"`),還是只屬於某一個授權上下文(`"private"`)。
伺服器本身什麼都不快取。這兩個欄位是一種**宣告**:「這份工具清單對所有人都一樣,而且一分鐘內不會變。」用戶端(或擋在你前面的閘道)就可以省掉這次往返。要不要遵守這些提示,由用戶端決定;送出這些提示則是伺服器的工作,而 SDK 會替你處理。
預設情況下,每個結果都是 `ttlMs: 0, cacheScope: "private"`:立刻過期、永不共用。這永遠安全,也永遠符合規範。如果你的清單確實穩定,而且對所有呼叫端都相同,就在建構時說清楚:
```python title="server.py" hl_lines="5-8"
--8<-- "docs_src/caching/tutorial001.py"
```
* 這個對應表以**方法名稱**為鍵,而且只有這六個可快取的方法是合法的鍵。參數的型別是 `Mapping[CacheableMethod, CacheHint]`,所以編輯器會自動完成這些鍵,並在執行前標出拼字錯誤;任何躲過型別檢查器的錯誤,都會在建構時引發例外。
* 沒提到的方法就維持預設值。這個對應表是一組覆寫,不是完整清單。
* `CacheHint(ttl_ms=5_000)` 沒有設定 `scope`,所以維持 `"private"`:每個呼叫端各自享有五秒的新鮮期。範圍和 TTL 是兩個各自獨立的決定。
* `"server/discover"` 也是合法的鍵,因為探索結果和任何清單一樣可以快取。
!!! warning
`cacheScope: "public"` 的意思是**任何人**都可能收到你快取的回應。共用的閘道會毫不猶豫地把某個使用者的結果交給另一個使用者,即使請求經過身分驗證也一樣。只有在結果對每個呼叫端都完全相同時,才把它標成 `"public"`;也絕對不要把 `cacheScope` 當成存取控制:它是標籤,不是鎖。
## 個別處理函式的覆寫 {#per-handler-override}
在低階的 `Server` 上,處理函式自己手動組出結果,而 `ttl_ms` / `cache_scope` 只是結果模型上的欄位。明確設定這些欄位的處理函式,永遠勝過建構子的對應表,而且是逐欄位比較:
```python title="server.py" hl_lines="10 16"
--8<-- "docs_src/caching/tutorial002.py"
```
處理函式指定了 `ttl_ms=1_000`,但對範圍隻字未提。線路上的結果是:`ttlMs: 1000`(來自處理函式,不是對應表的 `60_000`)和 `cacheScope: "public"`(來自對應表,因為處理函式沒設定)。明確指定的勝過建構時設定的,建構時設定的又勝過預設值。這條規則是逐欄位套用的,所以處理函式可以釘住一個欄位,把另一個欄位交給全伺服器的政策。
這也是應付建構子無從得知的動態情況的出口:一個依使用者過濾 `resources/read` 的處理函式,可以在其他部分都是 public 的伺服器上,針對某一個 URI 回傳 `cache_scope="private"`。
分頁清單有一點要注意:協定要求同一份清單的**每一頁都要有相同的 `cacheScope`**。建構子的對應表天生就滿足這一點,因為它以方法為鍵,而不是以頁為鍵。但自行覆寫範圍的處理函式,就得自己負責這份一致性:要在**每一**頁都覆寫,絕不能只在有 cursor 時才覆寫,否則第一頁和第二頁會對不上。
## 用戶端看到什麼 {#what-the-client-sees}
在 2026-07-28 的工作階段(session)上,`Client` 會替你遵守這些提示:它內建一個回應快取,預設開啟。帶著 `ttlMs` 抵達的結果會被存起來,在 TTL 內完全相同的呼叫會直接由快取提供,不需要往返。**沒有**帶提示的結果不會被快取:沒有提示的結果會套用 `CacheConfig.default_ttl_ms`,它預設為 `0`(立刻過期),所以什麼都沒宣告的伺服器,看到的流量和以往一模一樣,一次呼叫就一次請求。
```python title="client.py" hl_lines="33 35 38"
--8<-- "docs_src/caching/tutorial003.py"
```
四次呼叫,三次抓取。第二次呼叫找到新鮮的項目,根本沒送到伺服器;把(注入的)時鐘撥過 TTL 之後,第三次又重新抓取;第四次則指定了 `cache_mode="refresh"`。這個關鍵字引數存在於五個會快取的動詞上(`list_tools`、`list_prompts`、`list_resources`、`list_resource_templates`、`read_resource`):
* `"use"`(預設)如果有新鮮的項目就直接提供,沒有的話就抓取並存起來。
* `"refresh"` 從不由快取提供:它會抓取並儲存結果,取代原本快取的內容。
* `"bypass"` 直接往返,完全不碰快取:不讀、也不寫。
有一條規則凌駕於 `"use"` 之上:**帶有 `meta` 的呼叫一定會送到伺服器。**設定了 `meta` 的請求(進度 token、追蹤欄位)期待的是一個實際送上線路的請求,所以在 `cache_mode="use"` 下會被當成 `"refresh"` 處理:跳過快取讀取,而抓取回來的結果仍然會取代快取中的項目。`"bypass"` 和明確指定的 `"refresh"` 行為照舊。
要完全關掉快取,就用 `Client(server, cache=None)` 建構:每次呼叫又都變回一次往返,而 `cache_mode` 雖然仍可接受,但不會有任何作用。
範圍也會自動遵守:`"private"` 項目綁定在快取的**分區(partition)**上(見下文),而 `"public"` 項目則可以選擇更廣的共用。此外,對通知點名的那些項目來說,**通知勝過 TTL**:`list_changed` 通知會逐出對應的快取清單,`resources/updated` 則會逐出恰好存在該 URI 下的快取讀取結果,不管它們有多新鮮。在 2026-07-28 連線上,這些通知是透過你用 `client.listen(...)` 開啟的 `subscriptions/listen` 串流送達的,而且逐出會在你的監看程式看到事件之前完成;詳情請見 **[訂閱](subscriptions.md)**。
`resources/updated` 有一點要注意:逐出只比對完全相同的 URI。存放區的契約沒有列舉或掃描的操作(和參考的 TypeScript 實作一樣),所以帶著**子**資源 URI 的通知不會逐出其父資源的快取讀取結果。如果你的伺服器是用這種方式通知子資源的變動,就用 `cache_mode="refresh"` 重新抓取父資源。
### 設定方式:`CacheConfig` {#configuring-it-cacheconfig}
```python
from mcp.client import CacheConfig
client = Client("https://api.example.com/mcp", cache=CacheConfig(default_ttl_ms=5_000))
```
* `store`:項目存放的地方。預設是每個用戶端各自一個全新的記憶體內存放區;傳入你自己的 `ResponseCacheStore` 實作(例如以 Redis 為後端)就能跨用戶端或跨處理程序共用快取。契約型別(`ResponseCacheStore`、`CacheKey`、`CacheEntry`,以及預設的 `InMemoryResponseCacheStore`)都可以從 `mcp.client` 匯入。一次查詢最多可能對存放區連續發出兩次 `get`(先查 private 分支,再查 public 分支),所以遠端存放區的延遲預期要據此估算。自訂存放區**必須**搭配明確的 `partition`。
* `partition`:授權上下文的標籤,用來避免在共用存放區中把某個主體的 `"private"` 項目提供給另一個主體。
* `target_id`:明確的伺服器身分,用於自訂傳輸和同處理程序內的伺服器(見下文)。
* `default_ttl_ms`:套用在沒有帶 `ttlMs` 提示的結果上的 TTL。預設的 `0` 讓沒有提示的結果不被快取。
* `share_public`:跨分區提供伺服器宣稱為 `"public"` 的項目(見下文)。預設關閉。
* `clock`:牆上時鐘的來源,以 epoch 秒為單位。像上面的範例那樣注入一個,過期測試就不需要 sleep。
!!! warning "分區 = 經過驗證的主體"
`partition` 要從**經過驗證的憑證**推導出來,例如已驗證權杖的 subject。絕不要從請求提供的資料推導,也絕不要從伺服器 URL 推導(伺服器身分是另一條獨立的鍵軸)。SDK 是一個函式庫,本身沒有任何身分驗證:信任的錨點是建構 `CacheConfig` 的人,也就是部署方,而不是租戶。多租戶閘道要為每個已驗證的主體各建立一個 `CacheConfig`。
分區在 `Client` 的整個存活期間也是固定的。如果連線的授權上下文在工作階段中途改變(例如重新驗證成另一個主體),快取不會跟著變;請為新的主體建構一個新的 `Client`。
快取鍵也帶有**伺服器的身分**:你連線的 URL 字串,去掉任何 `user:pass@` 使用者資訊,其餘逐位元組保留。不做大小寫摺疊、不重排查詢參數、不清理結尾斜線。正規化不足只會損失共用的機會,過度正規化卻可能把兩個租戶合併在一起(`?tenant=a` 對 `?tenant=b`),所以表面上不同的 URL 就是不共用項目。沒有 URL 的時候(同處理程序內的伺服器,或 `Transport` 實例),用戶端會改拿到一個每個實例隨機產生的身分;設定 `CacheConfig.target_id` 來替伺服器命名(使用自訂存放區時這是必要的,建構時也會這麼告訴你)。身分在進入鍵的材料之前會先經過 sha256 雜湊,所以查詢字串裡帶著機密的 URL 永遠不會出現在存放區的鍵中。你自己也不要把雜湊前的形式記錄下來。
!!! warning "`share_public` 代表信任伺服器,而且是整個機群一起信任"
預設情況下,即使是 `"public"` 項目也會留在自己的分區內。`share_public=True` 會把伺服器標成 `cacheScope: "public"` 的項目提供給使用該存放區的**每一個**分區,等於代替它們全體信任伺服器的分類。如果伺服器(因為 bug 或惡意)把 `"public"` 蓋在各租戶專屬的資料上,某個租戶的回應就會洩漏給其他租戶。這個旗標刻意只放在建構子層級:每次呼叫的 `cache_mode` 可以縮小快取範圍,但沒有任何每次呼叫層級的東西可以擴大共用。
### 快取絕不會做的事 {#what-the-cache-never-does}
* **工作階段層級的呼叫會繞過它。** `client.session.list_tools()` 這一類的呼叫一定會往返;快取是掛在 `Client` 的動詞上。
* **`server/discover` 不參與。** discover 結果只在連線時送達一次,永遠不會進入回應快取,即使它帶著 `ttlMs` 也一樣。如果你自己把它保存下來以跳過重新連線時的探測([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)),它的新鮮度就由你自己記帳:`DiscoverResult` 帶有已解析好的 `ttl_ms` 和 `cache_scope`,正是為了這個用途。
* **後續分頁永遠不會被快取。** 只有不帶 cursor 的呼叫會參與。因為 cursor 過期而被拒絕的後續分頁倒是會**逐出**快取的清單,因為清單已經在底下變了。
* **多輪往返(multi-round-trip)的讀取永遠不會被快取。** 以 `input_responses`/`request_state` 起頭的 `read_resource`,或是經過輸入回合才解析出結果的讀取,都永遠不會進入快取(這是規範的 MUST)。
* **靠通知逐出,就得有通知。** 逐出的效果取決於傳輸能不能把通知送到,而現代的同處理程序內路徑(`Client(server)` 搭配預設的 `mode="auto"`)目前不會遞送獨立的通知。
* **逐出是最終發生,不是立即發生。** 走線路的通知是從衍生出來的 task 分派的,所以和通知抵達搶時間的呼叫,可能會再被提供一次逐出前的項目;這個空窗受分派延遲所限,而逐出終究會生效。
* **沒有 stale-if-error。** 過期的項目絕不會因為重新抓取失敗就被拿出來提供;錯誤會往上傳遞。
* **沒有提前重新抓取。** 已存的項目會一直提供到 TTL 過期為止,過期後的下一次呼叫要付出往返的代價;背景不會有任何東西在更新。
* **沒有合併。** 兩個同時發出的相同呼叫就是兩次抓取。
* **TTL 不會超過 24 小時。** 更大的 `ttlMs`,不論是伺服器送來的還是設定的,在存入時都會被壓到上限(`mcp.client.caching.MAX_TTL_MS`),這限制了任何項目能被提供的時間,不管它的提示有多大方。
* 在**共用存放區**上,用戶端之間會互相競爭。當逐出搶在進行中的抓取之前發生時,每個用戶端會丟棄自己的寫入,但**共用同一存放區的其他**用戶端仍然可能把一個項目寫回去,而那個項目其實已經被一次它沒看到的逐出移除了;這份競爭的記帳本身也有上限:追蹤的鍵超過 4096 個時,最舊那個鍵的防護會先被丟掉。這兩個空窗都是可接受的,並且由上面的 TTL 上限收尾。
* **不會跨協定世代提供。** 項目的範圍限定在協商出來的協定版本:在共用的持久性存放區上,工作階段絕不會提供在另一個協商版本下寫入的項目(同一份清單在不同世代確實不一樣,因為 SDK 會替較舊的工作階段剝掉 2026 的欄位)。逐出同樣只碰目前世代的項目;其他世代的項目就靠 TTL 自然老化淘汰。
### 自己讀取提示 {#reading-the-hints-yourself}
這些提示也是每個可快取結果上的普通欄位(`result.ttl_ms` 和 `result.cache_scope`,已解析好),如果你想在內建快取之上(或取而代之)疊上自己的記帳機制,可以直接用。
面對**較舊的伺服器**(2026 之前的協定),這些欄位在線路上根本不存在,模型會顯示保守的預設值:`ttl_ms == 0` 和 `cache_scope == "private"`,過期且不共用,對一個什麼都沒宣告的伺服器來說是正確的假設。快取對待舊版工作階段的方式也一樣:在那裡永遠不參考提示(不管線路上出現什麼鍵),只套用 `default_ttl_ms`,而它的預設值 `0` 什麼都不快取,所以 2026 之前的連線行為和快取存在之前一模一樣。如果需要區分「伺服器說了 0」和「伺服器什麼都沒說」,就檢查 `"ttl_ms" in result.model_fields_set`:只有欄位真的送達時它才會被設定。
## 較舊的用戶端 {#older-clients}
使用 2026 之前協定版本的用戶端永遠看不到這兩個欄位;SDK 在為這些連線序列化時就把它們剝掉了。提示只要設定一次;沒有任何需要針對版本另外寫的東西。
## 重點回顧 {#recap}
* 六個方法帶有 `ttlMs`/`cacheScope`;SDK 把它們預設為 `0`/`"private"`,過期且不共用,永遠安全。
* 建構時的 `cache_hints={method: CacheHint(...)}`(`MCPServer` 和 `Server` 都有)會為每個方法設定全伺服器的值。
* 在結果上設定這些欄位的處理函式會逐欄位覆寫對應表。
* `"public"` 是一個承諾:結果對每個呼叫端都完全相同。它不是存取控制。
* `Client` 會自動遵守提示:它的回應快取預設開啟,會提供新鮮的項目而不重新抓取,而對沒有提供提示的伺服器(或工作階段)則什麼都不快取。
* 每次呼叫可用 `cache_mode="refresh"` 重新抓取、用 `"bypass"` 跳過快取;建構時傳入 `cache=None` 則會完全關掉它。

View file

@ -0,0 +1,142 @@
---
translation:
sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0]
tool: 1
---
# 用戶端回呼 {#client-callbacks}
MCP 裡幾乎每一個請求都是單向的:從用戶端到伺服器。
伺服器也可以反過來向**用戶端**要東西:向使用者提問、對使用者的模型取樣(sampling)、列出使用者的工作區資料夾。要回應這些請求,就把**回呼**傳給 `Client(...)`。
## 會發問的伺服器 {#a-server-that-asks}
下面這個伺服器的工具沒辦法自己完成:
```python title="server.py" hl_lines="16"
--8<-- "docs_src/client_callbacks/tutorial001.py"
```
* `ctx.elicit(...)` 會**向用戶端**送出一個 `elicitation/create` 請求,然後等待。
* 在有人(填表單的人,或是你的程式碼)提供 `name` 之前,這個工具不會回傳。
那是伺服器那一半,由 **[徵詢(elicitation)](../handlers/elicitation.md)** 頁面負責說明。這一頁講的是線路的另一端。
## 徵詢回呼 {#the-elicitation-callback}
```python title="client.py" hl_lines="6-10 16-17"
--8<-- "docs_src/client_callbacks/tutorial002.py"
```
* 徵詢回呼的形式是 `async (context, params) -> ElicitResult`。
* `params.message` 是問題本身。`params.requested_schema` 是伺服器想要的答案的 JSON Schema。真正的用戶端會依它繪製出表單;這個範例則是自動填入。
* 回傳 `ElicitResult(action="accept", content={...})`,或 `action="decline"`,或 `action="cancel"`。除此之外唯一的選項是 `ErrorData(...)`,它會拒絕這個請求,讓整個呼叫失敗。
* `context` 是一個 `ClientRequestContext`:包含目前的 `session`、伺服器的 `request_id`,以及它附上的任何 `meta`。
!!! tip
`params` 是兩種徵詢模式的聯集。這裡的 `params.mode` 是 `"form"`;`"url"` 請求帶的是 `params.url` 而不是 schema。同一個回呼處理兩種模式,依 `params.mode` 分支即可。完整的寫法請見 **[徵詢](../handlers/elicitation.md)**。
### 試試看 {#try-it}
呼叫 `issue_card`,觀察兩端的情況。
回呼會收到伺服器的問題,而且已經解析好了:
```python
params.mode # 'form'
params.message # 'What name should go on the card?'
params.requested_schema # {'properties': {'name': {'title': 'Name', 'type': 'string'}},
# 'required': ['name'], 'title': 'CardHolder', 'type': 'object'}
```
它回答之後,`ctx.elicit(...)` 在工具內部恢復執行,工具隨即完成:
```python
result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')]
```
你送出一個 `tools/call`,伺服器回送一個 `elicitation/create`,由你的函式回答,全都發生在同一次工具呼叫之內。
!!! info
`Client(...)` 呼叫上的 `mode="legacy"` 是真的有作用。預設情況下 `Client(...)` 會協商出現代的協定路徑,而這條路徑沒有讓伺服器向用戶端發請求的反向通道(back-channel):`ctx.elicit` 在你的回呼有機會執行之前就失敗了。決定這件事的不是傳輸方式,而是協商出來的協定,記憶體內和透過 URL 連線都一樣。只要用戶端必須回應這類請求,就固定用 `mode="legacy"`;這一頁背後的每個測試都是這樣做的。完整說明請見 **[協定版本](../protocol-versions.md)**。
在 2026-07-28 的工作階段(session)上,回呼並沒有失效,只是餵給它的方式不同:當工具回傳帶有 `ElicitRequest` 的 `InputRequiredResult` 時,`Client` 會把那個項目分派給同一個 `elicitation_callback`,並替你重試這次呼叫。這個流程就是 **[多輪往返(multi-round-trip)請求](../handlers/multi-round-trip.md)**。
## 回呼就是能力 {#a-callback-is-a-capability}
你從來沒有告訴伺服器你的用戶端能回應徵詢請求。是 SDK 說的。
用戶端連線時會宣告自己的 `capabilities`,正好是伺服器那一份的鏡像。這個物件不用你寫。**註冊回呼就是宣告。**
| 你傳入 | 用戶端宣告 |
| --- | --- |
| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` |
| `sampling_callback=` | `"sampling": {}` |
| `list_roots_callback=` | `"roots": {"listChanged": true}` |
| 一個都不傳 | `{}` |
取樣的子能力是唯一需要細分的地方:如果你的取樣器會處理 `tools` / `tool_choice` 參數,就在 `sampling_callback` 旁邊一併傳入 `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())`。伺服器必須先看到 `sampling.tools` 被宣告,才能送出這些參數。
`logging_callback` 和 `message_handler` 不在表中。它們處理的是通知,而通知不需要能力。
伺服器用 `ctx.session.check_client_capability(...)` 把這份宣告讀回來。加一個這樣做的工具:
```python title="server.py" hl_lines="23-31"
--8<-- "docs_src/client_callbacks/tutorial003.py"
```
只帶 `elicitation_callback` 連線並呼叫它:
```python
result.structured_content # {'result': ['elicitation']}
```
三個回呼都傳,會得到 `['elicitation', 'sampling', 'roots']`。一個都不傳,會得到 `[]`。
!!! check
現在故意做錯:**不帶** `elicitation_callback` 連線,照樣呼叫 `issue_card`。
伺服器的 `elicitation/create` 請求還是會送到你的用戶端,而 SDK 會替你回應,用的是錯誤,因為你從沒說過自己能處理它。這個錯誤會拖垮整個呼叫。`call_tool` 不會回傳 `is_error` 結果,而是引發例外:
```text
MCPError: Elicitation not supported
```
這是協定錯誤(`-32600`,*invalid request*),不是工具錯誤:沒有任何東西可以讓模型讀了再重試。這就是 `client_features` 值得有的原因:行為良好的伺服器會先檢查再發問。
## 已棄用的那一對 {#the-deprecated-pair}
`sampling_callback` 回應 `sampling/createMessage`:伺服器請**你的**模型生成一些內容。`list_roots_callback` 回應 `roots/list`:伺服器詢問它可以在哪些目錄裡工作。
兩個都能用。兩個都遵守上面的規則。而兩個服務的 RPC 都是 **2026-07-28 規格移除的**:現代的伺服器不會在請求途中回頭呼叫你的用戶端,而是把請求當成工具結果的一部分交還給你(**[多輪往返請求](../handlers/multi-round-trip.md)**)。回呼本身並沒有失效。當 `InputRequiredResult` 帶著 `CreateMessageRequest` 或 `ListRootsRequest` 時,`Client` 的自動迴圈會把它分派給你在這裡註冊的同一個 `sampling_callback` 或 `list_roots_callback`。完整清單請見 **[已棄用的功能](../deprecated.md)**。
要和還沒升級的伺服器溝通,你仍然需要這些回呼。簽章如下:
```python title="client.py"
--8<-- "docs_src/client_callbacks/tutorial004.py"
```
* 取樣回呼會收到完整的 `CreateMessageRequestParams`(`messages`、`model_preferences`、`max_tokens`),並回傳 `CreateMessageResult`。模型由**你**來執行,怎麼執行都行;SDK 只負責傳遞請求。
* 根目錄(roots)回呼完全不接受參數,回傳 `ListRootsResult`。
* 兩者都可以改為回傳 `ErrorData(...)` 來拒絕。
把它們傳給 `Client(...)` 的方式和 `elicitation_callback` 完全一樣。
## 通知回呼 {#the-notification-callbacks}
還有兩個。兩個都不宣告任何東西。
`logging_callback` 會收到伺服器送出的 `notifications/message`,型別是 `LoggingMessageNotificationParams`(`level`、`logger`、`data`)。協定記錄本身已被 2026-07-28 規格棄用(該怎麼改做請見 **[記錄](../handlers/logging.md)**),所以這個回呼是為了還在送出它的伺服器而存在。在 2026 世代的連線上,光有回呼什麼都收不到,因為 2026 的伺服器只會把記錄訊息送給主動選擇接收的請求:把 `log_level="info"`(或其他層級)傳給 `Client(...)`,就會在每個請求上蓋上這個選擇,並收到該層級以上的訊息。2026 之前的伺服器會忽略它,維持原本的 `logging/setLevel` 行為。
`message_handler` 是總攬一切的那個:工作階段浮現的每一個伺服器通知都會送到它(同時也送到各自專屬的回呼),在以串流為基礎的傳輸方式上,每一個傳輸層級的 `Exception` 也會。有兩種永遠不會:`notifications/cancelled` 由 SDK 直接套用而不浮現,而正在運作的 `listen()` 串流的訂閱確認則由那個串流自己消化。把這個參數註記為 `IncomingMessage`(`ServerNotification | Exception`,從 `mcp.client` 匯出)。唯一值得知道的寫法是 `if isinstance(message, Exception): raise message`,這樣連線斷掉時會大聲失敗,而不是悄悄消失。
## 重點回顧 {#recap}
* 伺服器可以向用戶端送出請求。用傳給 `Client(...)` 的回呼來回應它們。
* 徵詢回呼是現行的那一個:`async (context, params) -> ElicitResult`,一個函式同時處理 form 和 URL 模式。
* **註冊回呼就是宣告能力。**沒有它,SDK 會替你拒絕伺服器的請求,整個呼叫以 `MCPError` 失敗。
* 伺服器在發問之前用 `ctx.session.check_client_capability(...)` 先確認。
* `sampling_callback` 和 `list_roots_callback` 的運作方式相同,但服務的是已棄用的功能;現代的伺服器改用多輪往返請求。
* `logging_callback` 和 `message_handler` 接收通知。它們不宣告任何東西。
`Client(...)` 的第一個引數是一個傳輸物件。**[用戶端傳輸方式](transports.md)** 涵蓋了每一種。

View file

@ -0,0 +1,129 @@
---
translation:
sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0]
tool: 1
---
# 身分斷言 {#identity-assertion}
一般的 OAuth provider(**[OAuth 用戶端](oauth-clients.md)**)一開始會先問 MCP 伺服器一個問題:「你信任哪一個授權伺服器?」答案指向哪裡它就跟到哪裡,接著要嘛有人登入,要嘛用預先共享的密鑰代替。
企業兩者都不想交給每台伺服器各自決定。它早就有一個身分提供者(Okta、Microsoft Entra ID,或你自己的);使用者今天早上就已經登入過了;而且安全團隊希望只在這一個地方決定誰能存取什麼。[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990),也就是 **Enterprise-Managed Authorization** 擴充功能,把決定權移到那裡。IdP 會簽署一個短效的 JWT,**Identity Assertion JWT Authorization Grant**,簡稱 **ID-JAG**:宣告**這位使用者**透過**這個用戶端**可以存取**這台 MCP 伺服器**。用戶端拿它換一個普通的存取權杖。沒有瀏覽器、沒有同意畫面、沒有動態註冊。
這一頁講的是這筆交換的兩端。MCP 伺服器本身完全不變:它仍然是 **[授權](../run/authorization.md)** 裡的資源伺服器,檢查送上門的任何權杖。
## 兩個權杖請求 {#two-token-requests}
這裡有兩個不同的權威機構在運作,把它們分開命名,幾乎就等於讀懂這一頁。**企業 IdP** 是你所屬組織的身分提供者:它知道員工是誰,政策放在它那裡,ID-JAG 也由它簽發。SDK 從不跟它對話。**MCP 授權伺服器**和 **[授權](../run/authorization.md)** 裡是同一個角色:MCP 伺服器中繼資料裡指名的簽發者,負責簽發該 MCP 伺服器接受的權杖。在一般的 OAuth 流程裡,這兩個角色通常是同一台機器。這裡它們是兩台,而整個授權流程就是後者同意信任前者。
用戶端對兩者各發一個權杖請求。
1. **對企業 IdP。** 用戶端拿使用者的登入結果(他們的 OpenID Connect ID 權杖)換 ID-JAG。這是一次 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 權杖交換,完全是你 IdP 的 API,而且 **SDK 不會發這個請求**。由你在一個非同步回呼裡完成。政策決定也發生在這裡:IdP 如果拒絕,就根本不會簽發 ID-JAG,也就沒有東西可以出示。
2. **對 MCP 授權伺服器。** 用戶端以 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) 的 `jwt-bearer` 授權類型出示 ID-JAG(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`,ID-JAG 放在 `assertion`),然後收到存取權杖。**這是 SDK 會發的請求**,而接受它,就是這一頁替授權伺服器加上的唯一一件事。
以下全部都是第二個請求:送出它的用戶端,以及回應它的授權伺服器。
## 用戶端 {#the-client}
**`IdentityAssertionOAuthProvider`** 位於 `mcp.client.auth.extensions.identity_assertion`。和 **[OAuth 用戶端](oauth-clients.md)** 裡的每個 provider 一樣,它是一個 `httpx2.Auth`:建立一個,放到 `auth=`,再把 `httpx2.AsyncClient` 交給傳輸。
```python title="client.py" hl_lines="49-50 53-61"
--8<-- "docs_src/identity_assertion/tutorial001.py"
```
從下往上讀。
* `main()` 就是標準 OAuth 用戶端的 `main()`(**[OAuth 用戶端](oauth-clients.md)**),一行都沒改。重點正是這個:一旦 provider 存在,下游沒有任何東西知道權杖是哪一種授權類型產生的。
* 這個 provider 接收其他 provider 無法自行探索到的東西:有人事先向授權伺服器**預先註冊**好的 `client_id` 和 `client_secret`、該授權伺服器的 `issuer`,以及 `assertion_provider`,一個依需求回傳全新 ID-JAG 的非同步回呼。
* `storage` 是同一個 `TokenStorage` 協定。只會呼叫那兩個權杖方法;這裡沒有動態註冊,所以沒有 `client_info` 需要記住。
### 斷言提供者 {#the-assertion-provider}
`fetch_id_jag(audience, resource)` 是你唯一要寫的程式碼。每次權杖交換會 await 它一次,建立時從不呼叫,而且只在授權伺服器的中繼資料已取回並驗證**之後**才呼叫,所以設定錯誤的 issuer 永遠不會洩漏斷言。它的兩個引數是簽發 ID-JAG 時必須帶有的其中兩個 claim:`audience` 是授權伺服器的簽發者(ID-JAG 的 `aud`),`resource` 是 MCP 伺服器的正規識別碼(ID-JAG 的 `resource`)。第三個你手上已經有了:ID-JAG 的 `client_id` claim 必須指名你交給 provider 的那個 `client_id`,否則授權伺服器會拒絕交換。
上面的 `idp_issue_id_jag` **不是你的程式碼**。它代替身分提供者,在處理程序內簽署斷言,讓這個檔案能完整執行,你也能讀到 ID-JAG 帶有的每一個 claim。真正的 `fetch_id_jag` 會改發上一節的第一個權杖請求:對你的 IdP 做一次 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 權杖交換,定義在 Identity Assertion JWT Authorization Grant 草案裡,[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 則是該草案的 profile。已登入使用者的 ID 權杖作為 `subject_token` 送進去,`requested_token_type` 是 ID-JAG 自己的 URN(`urn:ietf:params:oauth:token-type:id-jag`),`audience` 和 `resource` 原樣傳過去,回應裡就帶著 ID-JAG。到你 IdP 的說明文件裡要找的,就是這些名稱底下的這個交換。
!!! tip
每次交換都會請求一個全新的 ID-JAG,而這正是重點:它是單次使用、只活幾分鐘的授權,而且這一頁的授權伺服器拒絕接受同一個兩次。不要快取它。會被重複使用的,是它幫你換來的存取權杖。
### issuer 是設定值 {#the-issuer-is-configuration}
反轉的地方在這裡。`OAuthClientProvider` 會問資源伺服器該用哪一個授權伺服器,答案指向哪裡就跟到哪裡。這個 provider 拒絕這麼做:`issuer` 是必填,[RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) 中繼資料從該 issuer 自己的 well-known 路徑取回,權杖端點必須位於該 issuer 的 origin 上,而且從不向資源伺服器詢問任何事。
擴充功能並沒有要求這樣做;這是刻意更嚴格的選擇。這個用戶端帶著兩樣值得偷的東西,一個預先註冊的密鑰和一個綁定 audience 的斷言,而如果用戶端任由遭入侵的 MCP 伺服器把它導向攻擊者的授權伺服器,這兩樣都會 POST 過去。在建立時就釘死 issuer,等於把這段對話整個刪掉。
!!! warning
設定的 `issuer` 會依 RFC 8414 §3.3 的簡單字串比對,和中繼資料文件的 `issuer` 欄位比較:逐字元比對,結尾斜線也算,沒有任何正規化。不要用猜的。從你的授權伺服器抓 `/.well-known/oauth-authorization-server`,把它回傳的 `issuer` 值複製過來。以這一頁的授權伺服器來說是 `https://auth.example.com/`,帶斜線,因為它的 issuer 是從 pydantic 的 URL 物件建出來的。不相符的話,流程會在送出任何一個憑證或斷言之前,就停在 `OAuthFlowError: Authorization server metadata issuer
mismatch`。
### 機密用戶端 {#a-confidential-client}
`client_secret` 是必填;沒有它,建構子會引發 `ValueError`。[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 底下的 IETF profile 把這種授權類型保留給機密用戶端,SEP-990 要求用戶端必須驗證身分,而這個 SDK 以堅持要有共享密鑰的方式同時強制這兩點。`token_endpoint_auth_method` 決定它走哪裡:`client_secret_post`(預設,放在表單主體)或 `client_secret_basic`(HTTP Basic 標頭)。profile 也允許 `private_key_jwt`;這個 provider 不支援。
!!! tip
從環境變數或密鑰管理服務讀取 `client_secret`,永遠不要從版本控制裡讀。
### provider 替你做的事 {#what-the-provider-does-for-you}
第一個請求不帶驗證就送出,伺服器的 `401` 啟動整個流程。
1. **探索。** 從設定的 issuer 的 [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) well-known 路徑取回授權伺服器中繼資料,檢查文件的 `issuer` 相符,並檢查權杖端點位於 issuer 的 origin 上。
2. **斷言。** await 你的 `assertion_provider`。
3. **交換。** 把 `jwt-bearer` 授權 POST 到權杖端點,儲存 `OAuthToken`,然後帶著 `Authorization: Bearer ...` 重送你原本的請求。
`WWW-Authenticate` 指名 `insufficient_scope` 的 `403`,會用你的 `scope` 和被質疑的 scope 的聯集,再跑一次步驟 2 和 3。(`scope` 永遠只是請求;這一頁的授權伺服器只核發 ID-JAG 上寫的,其他一概不給。)整個過程裡沒有任何更新權杖:存取權杖過期時,下一個 `401` 會簽發一個全新的 ID-JAG 再交換一次,而**那**正是 IdP 握在手上的控制桿。失敗和 **[OAuth 用戶端](oauth-clients.md)** 其他部分一樣是那兩個例外:探索和驗證用 `OAuthFlowError`,權杖端點拒絕時則是它的子類別 `OAuthTokenError`。
## 授權伺服器 {#the-authorization-server}
大多數時候你到這裡就停了。MCP 授權伺服器是別人的產品,接受 ID-JAG 是它要開啟的設定,而 SDK 在 [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 裡負責的那一半,就是上面的用戶端。
SDK 也可以自己**當**授權伺服器:`create_auth_routes` 以任何 Starlette 應用程式都能掛載的清單形式回傳授權伺服器的路由,儲存庫裡的 `examples/servers/simple-auth/` 就是這樣跑起一個的。SEP-990 在這個介面上加了一個旗標和一個方法:
```python title="auth_server.py" hl_lines="48-50 105-107"
--8<-- "docs_src/identity_assertion/tutorial002.py"
```
* `identity_assertion_enabled=True` 管控一切。關閉時(這是預設),即使你實作了 hook,`/token` 也會以 `unsupported_grant_type` 回應這種授權類型,中繼資料也不會提到它。開啟時,中繼資料會多出 `jwt-bearer` 授權類型,並在 `authorization_grant_profiles_supported` 裡列出 `urn:ietf:params:oauth:grant-profile:id-jag`,也就是擴充功能用來宣傳支援的欄位。(這個 SDK 的用戶端從不讀它:它只為一個 issuer 佈建,直接開口問就是了。)
* **`exchange_identity_assertion`** 就是那個 hook。在它執行之前,SDK 已經驗證了用戶端、拒絕了公開用戶端,也拒絕了註冊資料裡沒列出這種授權類型的用戶端。你會拿到一個 `IdentityAssertionParams`(原始的 `assertion`、請求的 `scopes` 和 `resource`),回傳一個普通的 `OAuthToken`。
* 動態用戶端註冊無條件拒絕這種授權類型,所以這裡的 `get_client` 提供的是手動佈建的用戶端。ID-JAG 用戶端沒辦法靠自己註冊而存在。
* 這個類別有一半是拒絕。`OAuthAuthorizationServerProvider` 是**整個**授權伺服器,所以它也要求授權碼流程;同時讓使用者登入的伺服器會真的實作那些,而這一台只有一扇門。
!!! warning
SDK 從不解碼斷言:只有你的部署知道它信任哪一個 IdP、那個 IdP 發布哪些金鑰,所以 `exchange_identity_assertion` 裡的每一行都至關重要。依 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3,用 IdP 發布的金鑰(它的 JWKS;這裡的共享密鑰只是示範用)驗證簽章,以及 `iss` 和 `exp`。要求 JWT 標頭的 `typ` 是 `oauth-id-jag+jwt`,這是 profile 防止其他 JWT 被拿來重播成授權的防線。要求 `aud` 是你自己的 issuer。要求 ID-JAG 的 `client_id` claim 等於處理函式驗證過的用戶端,且它的 `resource` claim 指名的是你確實有提供的資源。追蹤 `jti` 直到斷言的 `exp`,讓它只被接受一次。還有,核發的 scope,以及最重要的、簽發權杖的 `resource`,都要從驗證過的 ID-JAG 取得,永遠不要從請求取得:`params.resource` 是用戶端隨便打的東西。完整的處理規則在 [Enterprise-Managed Authorization 規格](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization)裡。
用 `TokenError("invalid_grant", ...)` 拒絕不合格的斷言。這個流程裡另一個錯誤碼是 `invalid_target`:指名了你沒提供的資源的 ID-JAG 會用它拒絕,這正是阻止這台伺服器替別人的資源簽發權杖的機制。而核發的 scope 來自 ID-JAG 的 `scope` claim(沒有這個 claim 的斷言也會被拒絕);你的實作也許會改成對應使用者的群組。
再注意回傳的 `OAuthToken` 沒有帶的東西:更新權杖。IdP 透過決定是否簽發下一個 ID-JAG,來決定這位使用者能保有存取權多久。在這裡簽發更新權杖,等於悄悄把這個決定權交回去。
!!! info
仍以 `auth_server_provider=` 內嵌授權伺服器的伺服器,透過 `AuthSettings(identity_assertion_enabled=True)` 走到同一段程式碼。**[授權](../run/authorization.md)** 說明了為什麼新的伺服器不該從那裡開始。
!!! check
把這一頁的兩個檔案接在一起,整個授權流程就是一個 `POST /token`:
```text
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QifQ...
client_id=finance-agent
resource=http://localhost:8001/mcp
scope=notes:read
client_secret=finance-agent-secret
HTTP/1.1 200 OK
{"access_token": "mcp_...", "token_type": "Bearer", "expires_in": 300, "scope": "notes:read"}
```
沒有 `/authorize`、沒有 `/register`、沒有抓 protected-resource 中繼資料。線路上僅有的請求是引來 `401` 的那一個、well-known 的抓取、這次交換,然後就是帶著 bearer 的普通 MCP 流量。而你的驗證器從 ID-JAG 讀出的 `sub`,正是工具裡 `get_access_token().subject` 回報的值。
### 試試看 {#try-it}
SDK 儲存庫裡的 `examples/stories/identity_assertion/` 就是這一頁的實際執行版:同一個 `exchange_identity_assertion` 驗證器、一台以它的權杖把關的 MCP 伺服器、一個替身 IdP,和用戶端,全放在一個會自我檢查的程式裡。`uv run python -m stories.identity_assertion.client --http` 會跑完整個交換,並斷言 IdP 指名的使用者就是工具看到的使用者。
## 重點回顧 {#recap}
* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 讓企業身分提供者(而不是終端使用者)決定用戶端可以存取哪些 MCP 伺服器。IdP 把這個決定簽進一個 **ID-JAG** 裡。
* 取得 ID-JAG 是對**你的 IdP** 做的 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 權杖交換,SDK 不做這件事。把它出示給 MCP 授權伺服器是 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) 的 `jwt-bearer` 授權類型,SDK 兩端都做。
* `IdentityAssertionOAuthProvider` 是另一個 `httpx2.Auth`:一個預先註冊的機密用戶端、一個釘死的 `issuer`,和一個 `assertion_provider(audience, resource)` 回呼。沒有瀏覽器、沒有註冊、沒有更新權杖。
* 授權伺服器從不透過資源伺服器探索。把 `issuer` 設定成和它中繼資料文件提供的字串一模一樣;比對是逐字元的。
* 伺服器端是 `identity_assertion_enabled=True` 加上 `exchange_identity_assertion`。SDK 驗證用戶端並管控授權類型;驗證 ID-JAG 完全是你的事,而簽發的權杖綁定的是 ID-JAG 的 `resource`,不是請求的。
這一頁唯一沒碰過的角色是 MCP 伺服器。它拿你剛簽發的權杖做什麼,早在 **[授權](../run/authorization.md)** 裡就已經在做了。

View file

@ -0,0 +1,207 @@
---
translation:
sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30]
tool: 1
---
# 用戶端 {#the-client}
Python 程式要和 MCP 伺服器對話,靠的就是 **`Client`**。
它是一個物件,只有一套生命週期:建立它、進入 `async with`、呼叫方法。每個協定動作(列出工具、呼叫其中一個、讀取資源、算繪提示詞)都是它上面的一個 `async` 方法,回傳有型別的結果。
## 你的第一個用戶端 {#your-first-client}
```python title="client.py" hl_lines="14-18"
--8<-- "docs_src/client/tutorial001.py"
```
最上面的伺服器只是讓你有東西可以連而已。用戶端就是標示出來的那五行。
* `Client(mcp)` 拿到的是**伺服器物件本身**。這就是記憶體內傳輸:沒有子處理程序、沒有連接埠、沒有 HTTP。這一頁的每個範例,以及你寫的每個測試,都是這樣連線的。
* `async with` 就是**生命週期**。進入時連線並協商;離開時斷線。沒有 `connect()` / `close()` 這種成對的方法,而且區塊結束後 `Client` 不能再重複使用。
* 在區塊內,連線的各項資訊已經以普通屬性的形式準備好了。
### 可以傳什麼給 `Client` {#what-you-can-pass-to-client}
`Client` 接受一個位置引數,並依它的型別決定傳輸方式:
* `MCPServer`(或低階的 `Server`)實例:在**同一個處理程序內**連線。
* URL 字串(`Client("http://localhost:8000/mcp")`):Streamable HTTP,也就是正式環境的路徑。
* 一個**傳輸**:任何可以 `async with ... as (read, write)` 的東西,例如包住子處理程序的 `stdio_client(...)`。
這一頁其餘的內容在這三種情況下完全相同。標頭、子處理程序、逾時,以及 `Transport` 協定另外有專屬的頁面:**[用戶端傳輸方式](transports.md)**。
### 連線後的用戶端上有什麼 {#whats-on-a-connected-client}
四個唯讀屬性,一進入區塊就填好了:
* `client.server_info`:伺服器的身分;如果是不回報身分的 2026 世代伺服器,則為 `None`(python-sdk 伺服器預設會回報)。這裡的 `server_info.name` 是 `"Bookshop"`,`server_info.version` 則是伺服器回報的值。
* `client.server_capabilities`:伺服器能做什麼(`tools`、`resources`、`prompts`、`completions`……)。伺服器沒有的能力會是 `None`。
* `client.protocol_version`:雙方談妥的協定版本。這裡是 `"2026-07-28"`。
* `client.instructions`:伺服器的 `instructions=` 字串;如果沒有設定則為 `None`。
你從頭到尾都沒有挑過協定版本。預設情況下 `Client` 會先探測伺服器,遇到較舊的伺服器就退回傳統的交握,所以同一個用戶端可以對應任何世代的伺服器。需要自己掌控時,完整說明請見 **[協定版本](../protocol-versions.md)**。
!!! tip
`client.session` 是底層的 `ClientSession`,也就是低階的逃生出口。這一頁的任何內容都用不到它。
## 列出工具 {#listing-tools}
```python title="client.py" hl_lines="15-20"
--8<-- "docs_src/client/tutorial002.py"
```
`list_tools()` 回傳一個 `ListToolsResult`;工具在 `.tools` 裡。每一個都是 MCP 主機(host)會交給模型的完整定義:
```python
tool.name # 'search_books'
tool.title # 'Search the catalog'
tool.description # 'Search the catalog by title or author.'
```
而 `tool.input_schema` 是伺服器從函式的型別提示推導出來的 JSON Schema:
```json
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"},
"limit": {"default": 10, "title": "Limit", "type": "integer"}
},
"required": ["query"],
"title": "search_booksArguments"
}
```
這份 schema 就是 UI 算繪引數表單所需的一切,也是模型產生合法引數所需的一切。
!!! tip
`title` 是選填的,所以把工具顯示給人看的 UI 得自己挑:有的話就用 `title`,沒有就用 `name`。`from mcp.shared.metadata_utils import get_display_name` 做的正是這件事,適用於工具、資源、資源範本和提示詞。
## 呼叫工具 {#calling-a-tool}
`call_tool(name, arguments)` 會執行工具,並回傳一個 `CallToolResult`。
```python title="client.py" hl_lines="26-33"
--8<-- "docs_src/client/tutorial003.py"
```
伺服器的 `lookup_book` 回傳一個 Pydantic 的 `Book`。用戶端看到的是:
```python
result.content # [TextContent(type='text', text='{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}')]
result.structured_content # {'title': 'Dune', 'author': 'Frank Herbert', 'year': 1965}
result.is_error # False
```
一個回傳值,三樣東西可讀。各自有不同的使用對象。
### `content`:給模型讀的 {#content-what-the-model-reads}
`content` 是一個**內容區塊**的 `list`,而內容區塊是一個聯集:`TextContent`、`ImageContent`、`AudioContent`、`ResourceLink` 或 `EmbeddedResource`。一個工具可以回傳好幾個,而且種類各異。
這就是為什麼 `main` 在碰 `block.text` 之前,先用 `isinstance(block, TextContent)` 縮窄型別。注意 `isinstance` 之外完全沒有出現 `.text`:型別檢查器不會放行,因為 `ImageContent` 有的是 `.data`,不是 `.text`。這個聯集誠實地表達了工具可以送什麼給你;你的程式碼也應該如此。
### `structured_content`:給應用程式讀的 {#structured_content-what-your-application-reads}
`structured_content` 是工具回傳值的 JSON 形式,符合工具宣告的 `output_schema`。不用剖析字串,不用猜。
兩者同時存在時,是刻意把同一件事講兩遍:`content` 給模型,`structured_content` 給程式碼。結構化的那一半從哪裡來、又該怎麼控制,請見 **[結構化輸出](../servers/structured-output.md)** 頁面。
### `is_error`:工具有沒有失敗 {#is_error-whether-the-tool-failed}
會引發例外的工具,在用戶端這邊**不會**引發例外。它會以一個普通的結果回來,帶著 `is_error=True`。
!!! check
向 `lookup_book` 要 `"Solaris"`(目錄裡沒有的書名),函式會引發 `ValueError`。呼叫仍然正常回傳:
```python
result.is_error # True
result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")]
result.structured_content # None
```
例外的訊息落在 `content` 裡,**模型**可以讀到它並再試一次。這是刻意的設計:工具錯誤是對話的一部分,不是當機。在信任 `structured_content` 之前,一定要先看 `is_error`。
!!! warning
`is_error=True` 涵蓋的不只是你自己的 `raise`。要一個伺服器根本沒有的工具(`call_tool("does_not_exist", {})`),也不會引發任何例外。你會拿回同樣的形狀:`is_error=True`,`content` 裡是 `Unknown tool: does_not_exist`。只有在伺服器回的是 JSON-RPC **錯誤**而不是結果時,`Client` 的方法才會引發 `MCPError`;伺服器什麼時候產生哪一種,請見 **[處理錯誤](../servers/handling-errors.md)**。
## 資源 {#resources}
資源的動作是成組的:兩種列出的方式,一種讀取的方式。
```python title="client.py" hl_lines="22-31"
--8<-- "docs_src/client/tutorial004.py"
```
* `list_resources()` 回傳**具體**的資源,也就是 URI 固定的那些。這裡是:`['catalog://genres']`。
* `list_resource_templates()` 回傳**參數化**的那些。這裡是:`['catalog://genres/{genre}']`。它們分成兩個清單,因為範本在填好之前是不能讀的。
* `read_resource(uri)` 接受一個普通的 `str` URI,兩種都適用:傳入 `"catalog://genres/poetry"`,伺服器會把它比對到範本。
`read_resource` 回傳 `contents`,一個由 `TextResourceContents` 或 `BlobResourceContents` 組成的清單。跟工具內容是同樣的概念:用 `isinstance` 縮窄,再讀 `.text`(或 `.blob`)。
用戶端也可以在資源變更時收到通知。在 2025 世代的連線上,這是 `subscribe_resource(uri)` / `unsubscribe_resource(uri)`——一組 `MCPServer` 沒有實作的方法,所以在 2026-07-28 的線路上(那裡已經沒有這些動作了),請求得到的回應是 `-32601`,「Method not found」。2026 的替代方案是 `subscriptions/listen` 串流,這個 `MCPServer` **有**提供——在那裡 `server_capabilities.resources.subscribe` 是 `True`——而用 `client.listen(...)` 來消費它,就是本節的 **[訂閱](subscriptions.md)** 頁面。
## 提示詞 {#prompts}
```python title="client.py" hl_lines="15-20"
--8<-- "docs_src/client/tutorial005.py"
```
`list_prompts()` 告訴你伺服器提供什麼,以及每個提示詞需要什麼:
```python
prompt.name # 'recommend'
prompt.title # 'Recommend a book'
prompt.arguments # [PromptArgument(name='genre', required=True)]
```
`get_prompt(name, arguments)` 負責算繪它。引數 dict 是 `str -> str`:提示詞引數永遠是字串。結果是 `messages`,一個 `PromptMessage` 的清單,每個都有 `role` 和一個 `content` 區塊:
```python
message.role # 'user'
message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.')
```
主機會把這些訊息直接交給模型。整個功能就這樣。
## 自動完成 {#completions}
有自動完成處理函式的伺服器,可以在使用者輸入時自動完成提示詞和資源範本的引數。
```python title="client.py" hl_lines="27-31"
--8<-- "docs_src/client/tutorial006.py"
```
* `ref` 指出你正在填的是**哪一個**提示詞或範本:`PromptReference` 或 `ResourceTemplateReference`。
* `argument` 是 `{"name": ..., "value": ...}`:引數本身,以及使用者目前為止輸入的內容。
答案在 `result.completion.values` 裡。輸入 `"p"`,伺服器回的是 `['poetry']`。伺服器端的部分,以及處理函式如何利用**其他**已填好的引數來縮小建議範圍,請見 **[自動完成](../servers/completions.md)** 頁面。
## 分頁 {#pagination}
每個 `list_*` 方法都接受 `cursor=` 關鍵字引數,每個結果都帶有 `next_cursor`。當 `next_cursor` 是 `None`,表示全部拿到了。
```python title="client.py" hl_lines="22-30"
--8<-- "docs_src/client/tutorial007.py"
```
這個迴圈對任何伺服器都正確。`MCPServer` 會一頁回傳全部,所以 `next_cursor` 是 `None`,迴圈只跑一次,這也是為什麼大部分程式碼從來不寫它。真正會分頁的伺服器,以及游標遵守的規則,請見 **[分頁](../advanced/pagination.md)**。
## 在測試中 {#in-tests}
不需要處理程序、不需要連接埠的 `Client(mcp)`,本身就已經是伺服器的測試工具了。
有一個建構子旗標是專為此設計的:`Client(mcp, raise_exceptions=True)`。它只對記憶體內連線有作用,而 **[測試](../get-started/testing.md)** 頁面會解釋它,並圍繞它建立整套模式。
## 重點回顧 {#recap}
* `Client(x)` 對伺服器物件以記憶體內方式連線,對 URL 字串透過 Streamable HTTP 連線,其他情況則透過傳輸連線。
* `async with` 就是整個生命週期。在裡面,`server_capabilities` 和 `protocol_version` 已經填好;伺服器有提供時,`server_info` 和 `instructions` 也是。
* `list_tools()` 給你每個工具的 `name`、`title`、`description` 和 `input_schema`。
* `call_tool()` 回傳給模型的 `content`、給程式碼的 `structured_content`,以及 `is_error`。會引發例外的工具是一個結果,不是例外。
* `content` 是區塊型別的聯集;讀取前先用 `isinstance` 縮窄。
* `list_resources` / `list_resource_templates` / `read_resource`、`list_prompts` / `get_prompt`,以及 `complete` 補齊了其餘的動作。
* 每個 `list_*` 都接受 `cursor=`;一直迴圈到 `next_cursor` 是 `None` 為止。
伺服器可以向**用戶端**要求的東西,以及你如何回應,請見 **[用戶端回呼](callbacks.md)**。

View file

@ -0,0 +1,143 @@
---
translation:
sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b]
tool: 1
---
# OAuth 用戶端 {#oauth-clients}
有些 MCP 伺服器是受保護的。不帶權杖送請求過去,得到的回應是 `401 Unauthorized`。
**`OAuthClientProvider`** 就是取得權杖的方式。它根本不是 MCP 物件,而是一個 `httpx2.Auth`,也就是 httpx2 用來「對每個請求做點什麼」的標準掛鉤。把它掛在 `httpx2.AsyncClient` 上,再把那個用戶端交給 Streamable HTTP 傳輸,之後就不用再管它了。
這一頁講的是用戶端這一側。要讓自己的伺服器要求權杖,請見 **[授權](../run/authorization.md)**。
## Provider {#the-provider}
```python title="client.py" hl_lines="44-54"
--8<-- "docs_src/oauth_clients/tutorial001.py"
```
要給它四樣東西:
* `server_url`:要連線的 MCP 端點。其餘的一切 provider 都會從這裡自行探索出來。
* `client_metadata`:就是你會在授權伺服器的「註冊應用程式」表單裡填的內容。
* `storage`:權杖在兩次執行之間存放的地方。
* `redirect_handler` 和 `callback_handler`:需要人介入的兩個時刻。
檔案裡其他地方都沒有提到 OAuth。`main()` 從頭到尾都看不到權杖。
### 用戶端中繼資料 {#client-metadata}
`OAuthClientMetadata` 就是貨真價實的 [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) 註冊文件,以 Pydantic 模型的形式呈現。
你設定三個欄位,其餘由預設值補上:`grant_types` 已經是 `["authorization_code", "refresh_token"]`,`response_types` 已經是 `["code"]`,正好就是這個 provider 執行的流程。
!!! check
因為它是 Pydantic 模型,所以**在任何一個位元組送上網路之前**就會先驗證。漏掉 `redirect_uris`,建構當場就會失敗,引發一個指名該欄位的 `ValidationError`:
```text
redirect_uris
Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict]
```
不會開啟瀏覽器,也不會在授權伺服器上留下做到一半的註冊。
### 權杖儲存 {#token-storage}
**`TokenStorage`** 是一個有四個非同步方法的 `Protocol`。不需要繼承任何東西;把這些方法寫出來,任何類別都能當權杖儲存庫:
* `get_tokens` / `set_tokens` 保存 `OAuthToken`:存取權杖、重新整理權杖、到期時間、範圍。
* `get_client_info` / `set_client_info` 保存授權伺服器在 provider 幫你註冊時發給你的 `OAuthClientInformationFull`,其中包含你的 `client_id`。
上面那個存在記憶體內的版本可以用。但處理程序結束時它就什麼都忘了,所以下次執行又得整套流程重來一遍。把它持久化到檔案或平台的鑰匙圈裡,下次執行就會安安靜靜。
!!! tip
要存 `client_info`,不要只存權杖。provider 第一次找不到已儲存的 `client_info` 時會動態註冊。把它丟掉,每次執行就會產生一筆全新的註冊。
### 兩個處理函式 {#the-two-handlers}
授權碼流程只需要人介入一次:得有人登入並按下「允許」。
* **`redirect_handler`** 會帶著組裝完整的授權 URL 被 await。`client_id`、`redirect_uri`、`state` 和 PKCE challenge 都已經在裡面。你唯一的工作是讓瀏覽器開到那裡。桌面應用程式會呼叫 `webbrowser.open`;這個檔案則把它印出來。
* **`callback_handler`** 接著被 await。它會等到使用者回到你的 `redirect_uri`,再把那次重新導向的查詢參數以 `AuthorizationCodeResult` 回傳。
真正的用戶端會在重新導向 URI 上跑一個小型本機 HTTP 伺服器,而不是呼叫 `input()`。形狀完全一樣:接收重新導向,交回 `code`、`state` 和 `iss`。
!!! warning
`state` 和 `iss` 要原封不動地傳回去。provider 會拿 `state` 與自己產生的那個比對,拿 `iss` 與探索到的 issuer 比對,不一致就拒絕。它們分別是 CSRF 與伺服器混淆攻擊的防線。
### 放進 `Client` {#into-the-client}
看看 `main()`。provider 掛在 **httpx2 用戶端**上,httpx2 用戶端放進 `streamable_http_client(url, http_client=...)`,那個傳輸再放進 `Client`。
`streamable_http_client` 沒有 `auth=` 關鍵字引數。凡是 HTTP 層級的東西(驗證、標頭、逾時、代理)都屬於你自備的 `httpx2.AsyncClient`。這種分層的說明請見 **[用戶端傳輸方式](transports.md)**。
## Provider 幫你做的事 {#what-the-provider-does-for-you}
`Client` 第一次送出請求時,伺服器回應 `401`。provider 接手:
1. **探索。** 讀取 `WWW-Authenticate` 標頭,從 `/.well-known/oauth-protected-resource` 抓取伺服器的 Protected Resource Metadata,得知是哪個授權伺服器在保護這個資源,再去抓取**那個**伺服器的中繼資料。
2. **註冊。** 儲存庫裡什麼都沒有?它會用你的 `OAuthClientMetadata` 動態註冊,並把結果存起來。
3. **授權。** 產生 PKCE 配對和一個 `state`,組出授權 URL,await 你的 `redirect_handler`,接著 await 你的 `callback_handler` 取得授權碼。
4. **交換。** 拿授權碼換得 `OAuthToken`,存起來,然後帶著 `Authorization: Bearer ...` 重送你原本的請求。
之後它就很安靜。權杖從儲存庫拿出來用,過期的存取權杖用重新整理權杖更新,只有這些都行不通時才會重跑整個流程。
這些你一行都沒寫。還剩兩個關鍵字引數(`client_metadata_url` 和 `validate_resource_url`),這個檔案兩個都用不到。值得認識的是 `client_metadata_url`,下面有它專屬的一節。
### 試試看 {#try-it}
這份文件裡的大多數範例都能用記憶體內的 `Client(server)` 檢驗。這個不行:整個流程的重點就是一個 HTTP `401`,而記憶體內的用戶端和它的伺服器之間根本沒有 HTTP。
儲存庫裡附有實際運作的版本。`examples/servers/simple-auth/` 會執行一個獨立的授權伺服器和一個受保護的 MCP 伺服器;`examples/clients/simple-auth-client/` 則是這一頁的用戶端長成的一個小型 CLI。它的 README 有那兩個指令:啟動伺服器、對著它們執行用戶端,就能看著上面四個步驟依序發生。
## Client ID Metadata Documents {#client-id-metadata-documents}
規格的 2026-07-28 修訂版已棄用動態用戶端註冊,改用 **Client ID Metadata Documents**(CIMD)。用戶端不再對遇到的每個授權伺服器 POST 一筆新的註冊,而是在一個穩定的 HTTPS URL 上發布一份描述自己的 JSON 文件,而那個 URL **就是**它的 `client_id`。文件由授權伺服器去抓取;provider 完全不碰它。
SDK 已經支援:建構 provider 時把那個 URL 以 `client_metadata_url=` 傳入即可。當授權伺服器的中繼資料宣告 `client_id_metadata_document_supported: true` 時,provider 會完全跳過 `/register` 請求:URL 以 `client_id` 的身分進入流程,而且沒有 `client_secret`。當伺服器沒有宣告(目前多數都還沒有),或者你根本沒傳 URL,provider 會**默默地**退回動態註冊,上面的一切照原樣運作。已儲存的 `client_info` 仍然優先於這兩者。
URL 必須是 HTTPS 且路徑不能是根路徑;否則在建構時就會引發 `ValueError`,不會發生任何網路動作。隨附的 `examples/clients/simple-auth-client/` 透過 `MCP_CLIENT_METADATA_URL` 環境變數接收它。
## 機器對機器 {#machine-to-machine}
夜間排程、CI 步驟、另一個服務。沒有瀏覽器,也沒有人可以按「允許」。這就是 **client credentials** 授權類型:你手上已經有 `client_id` 和 `client_secret`,權杖端點就是整個流程。
`ClientCredentialsOAuthProvider` 是同一個 `httpx2.Auth`,只是少了人:
```python title="client.py" hl_lines="4 27-33"
--8<-- "docs_src/oauth_clients/tutorial002.py"
```
改變的地方:
* 沒有 `OAuthClientMetadata`,沒有處理函式。傳入 `client_id` 和 `client_secret`;provider 會圍繞它們建出一筆最精簡的 `client_credentials` 註冊,並完全跳過動態註冊。
* `scope` 是以空格分隔的字串,也就是 OAuth 的線路格式。
* 下游的一切完全相同:同樣的 `TokenStorage`、同樣的 `httpx2.AsyncClient(auth=...)`、同樣的 `streamable_http_client`。
預設情況下,secret 在權杖請求中以 HTTP Basic 驗證傳送(`client_secret_basic`)。傳入 `token_endpoint_auth_method="client_secret_post"` 可改放進表單主體。有些授權伺服器只接受兩者其中之一。
!!! tip
`client_secret` 要從環境變數或祕密管理工具讀取,絕對不要放進版本控制。
!!! info
`mcp.client.auth.extensions.client_credentials` 裡還有一個 provider:**`PrivateKeyJWTOAuthProvider`**,給用 JWT 而非共用 secret 來驗證的用戶端使用(`private_key_jwt`,也就是金鑰對與工作負載身分那一類)。它遵循同樣的模式:建構一個,放到 `auth=` 上。同一個模組還附了 `SignedJWTParameters` 和 `static_assertion_provider`,兩個用來建出其 assertion 的輔助工具。
還有一種無人介入的情境:用戶端屬於某個企業,由企業的身分提供者(而非使用者)決定它可以連到哪些 MCP 伺服器。那是另一種授權類型,有自己的信任模型,也有自己的頁面:**[身分斷言](identity-assertion.md)**。
## 失敗的時候 {#when-it-fails}
OAuth 流程出錯時,provider 會引發來自 `mcp.client.auth` 的 `OAuthFlowError`。它有兩個子類別。`OAuthRegistrationError` 表示註冊沒有產生可用的用戶端:授權伺服器拒絕替你註冊,或者有註冊,但給的憑證是這個流程用不了的(例如它沒有實作的驗證方法)。`OAuthTokenError` 表示無法取得權杖:權杖端點拒絕了,或者已儲存的用戶端紀錄帶著這個用戶端無法套用的驗證方法——這會在組裝權杖請求時就回報,而不是送出之後。一個 `except OAuthFlowError:` 就能涵蓋探索、註冊、授權與交換。
不是所有問題都是流程錯誤。網路還是可能出錯;那些是一般的 `httpx2` 例外,會原封不動地往外傳遞。
## 重點回顧 {#recap}
* `OAuthClientProvider` 是一個 `httpx2.Auth`。放到 `httpx2.AsyncClient` 上,再把它傳給 `streamable_http_client(url, http_client=...)`,`Client` 永遠不會知道發生過 OAuth。
* 你提供四樣東西:伺服器 URL、一個 `OAuthClientMetadata`、一個 `TokenStorage`,以及 redirect/callback 這一對處理函式。
* `TokenStorage` 是一個 `Protocol`:四個非同步方法,沒有基底類別。除了權杖,也要持久化 `client_info`。
* 探索、註冊(動態的,或透過 **Client ID Metadata Document**)、PKCE、`state` 與 `iss` 檢查,以及權杖重新整理,都是 provider 的工作,不是你的。
* `ClientCredentialsOAuthProvider` 是無人介入的版本:`client_id` + `client_secret`,沒有處理函式,沒有瀏覽器。
* 每一種 OAuth 失敗都是 `OAuthFlowError`;`OAuthRegistrationError` 和 `OAuthTokenError` 是它的子類別。
這次交握的另一半,也就是讓你的**伺服器**要求權杖,請見 **[授權](../run/authorization.md)**。

View file

@ -0,0 +1,85 @@
---
translation:
sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73]
tool: 1
---
# 工作階段群組 {#session-groups}
一個 `Client` 只連到一台伺服器。實際的應用程式往往需要好幾台(搜尋伺服器、資料庫伺服器、內部 API),結果得替每一台各自管理一條連線和一份工具清單。
**`ClientSessionGroup`** 是單一物件,裡面握有多條連線,並把它們公開的所有東西合併成一個統一的檢視。
## 兩台伺服器 {#two-servers}
先從兩台普通的伺服器開始。它們彼此毫無關係,所以很自然地都把自己的工具取名為 `search`:
```python title="library_server.py" hl_lines="7"
--8<-- "docs_src/session_groups/tutorial001.py"
```
```python title="web_server.py" hl_lines="7"
--8<-- "docs_src/session_groups/tutorial002.py"
```
## 一個群組 {#one-group}
建立一個 `ClientSessionGroup`,然後對每台伺服器各呼叫一次 **`connect_to_server`**:
```python title="client.py" hl_lines="10-12"
--8<-- "docs_src/session_groups/tutorial003.py"
```
* `connect_to_server` 接受的是傳輸參數,不是伺服器物件:用 `StdioServerParameters`(來自 `mcp`)啟動子處理程序,或用 `StreamableHttpParameters` / `SseServerParameters`(來自 `mcp.client.session_group`)連到已經在某個 URL 上監聽的伺服器。
* `group.tools` 是一個 `dict[str, Tool]`,收集所有已連線伺服器的工具。`group.resources` 和 `group.prompts` 的結構相同。
* `group.call_tool(name, arguments)` 會查詢名稱、找出擁有它的工作階段(session),再把呼叫轉送過去。你永遠不需要指明是哪台伺服器。
!!! check
把 `client.py` 放在兩台伺服器旁邊執行。第二次 `connect_to_server` 會拒絕:
```text
mcp.shared.exceptions.MCPError: {'search'} already exist in group tools.
```
這是一個 `MCPError`,在第二台伺服器的任何東西被登記之前就引發了。名稱在**整個**群組內必須唯一,而兩台你無法掌控的伺服器遲早會撞名。
## `component_name_hook` {#component_name_hook}
這個問題要在群組這邊解決,而不是在伺服器端。傳入一個接收 `(name, server_info)` 的函式,群組會對它登記的每個名稱執行這個函式:
```python title="client.py" hl_lines="7-8 15"
--8<-- "docs_src/session_groups/tutorial004.py"
```
再執行一次。`print(sorted(group.tools))` 現在兩個都會顯示:
```text
['Library.search', 'Web.search']
```
* **鍵**是你自己決定的。`by_server` 用 `server_info.name` 組出來,也就是每個 `MCPServer(...)` 建構時傳入的名稱。
* 裡面的 `Tool` 完全沒動:`group.tools["Web.search"].name` 仍然是 `"search"`,而這也是 `call_tool` 放上線路的名稱。前綴永遠不會離開你的處理程序。
* 不只工具如此。圖書館的 `hours` 資源登記為 `Library.hours`。
!!! tip
這個 hook 會對**每台**伺服器的**每個**名稱執行,不只在衝突時才執行:沒有所謂「撞名才加前綴」的模式。選定一套命名規則,讓它套用到所有地方。
## 新增與移除伺服器 {#adding-and-removing-servers}
`connect_to_server` 會回傳它開啟的 `ClientSession`。如果之後可能想拿掉那台伺服器,就把它留著:`await group.disconnect_from_server(session)` 會把它的工具、資源和提示詞從群組中移除。
如果手上已經有一個連線中的 `ClientSession`(`Client.session` 就是一個),改把它交給 `await group.connect_with_session(server_info, session)`,不必另開新的傳輸。它彙整的方式相同。群組永遠不會關閉不是它自己開啟的工作階段。`server_info` 用來替伺服器命名,供元件前綴使用;在 2026 世代的連線上,`client.server_info` 可能是 `None`(身分是選填的),這種情況下就傳入你自己的 `Implementation(name=..., version=...)`。
## 傳統的交握 {#the-classic-handshake}
`ClientSessionGroup` 建立在 `ClientSession` 之上,而不是 `Client`。每次 `connect_to_server` 都會執行傳統的 `initialize` 交握,從不送出 **[協定版本](../protocol-versions.md)** 裡描述的 `server/discover` 探測。每台 MCP 伺服器都懂這套交握,所以這不會讓你犧牲任何相容性;只是代表群組面對一台本來能做得更好的伺服器時,走的是比較舊、比較慢的路徑。
## 重點回顧 {#recap}
* `ClientSessionGroup` 握有多條伺服器連線,並把它們的工具、資源和提示詞各自合併成一個 `dict`。
* 每台伺服器呼叫一次 `connect_to_server(params)`。它接受傳輸參數,絕不是 `Client` 接受的伺服器物件或 URL。
* `group.call_tool(name, arguments)` 會替你轉送到擁有該工具的伺服器。
* 名稱在整個群組內必須唯一;兩台都有 `search` 工具的伺服器無法原樣共存。
* `component_name_hook=` 會改寫每個登記的名稱。dict 的鍵會變,線路上的名稱不變。
* `connect_with_session` 加入你已經握有的工作階段;`disconnect_from_server` 移除一個。
群組使用的交握(以及 `Client` 偏好的那套更快的交握)是 **[協定版本](../protocol-versions.md)** 的主題。

View file

@ -0,0 +1,88 @@
---
translation:
sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2]
tool: 1
---
# 訂閱 {#subscriptions}
伺服器的目錄不是固定的。工具會在執行時出現,資源 URI 背後的內容也會改變。用戶端透過 `client.listen(...)` 得知這些變化:一個 `subscriptions/listen` 請求,它的回應**就是**串流。這條串流會一直開著,承載用戶端所要求的變更通知。
這一頁講的是用戶端這一端:開啟串流、在主流程旁邊監看它,以及處理它的結束。發布變更、篩選和提供這個方法,則是伺服器那一邊的事,寫在「在處理函式內部」底下的 **[訂閱](../handlers/subscriptions.md)**。這裡的範例對接的是在那一頁建立的衝刺看板(sprint-board)伺服器。
## 監看串流 {#watching-the-stream}
一個訂閱就是一個上下文管理器。進入它會送出請求,把你的關鍵字引數當作訂閱的篩選條件,並等待伺服器的確認,所以區塊開始時串流已經是活的了。
```python title="client.py" hl_lines="15 18 28"
--8<-- "docs_src/subscriptions/tutorial003.py"
```
迭代會產生四種有型別的事件:`ToolsListChanged`、`PromptsListChanged`、`ResourcesListChanged` 和 `ResourceUpdated(uri=...)`。
事件只說**什麼**變了,從不說**怎麼**變的。這就是 `follow_board` 會呼叫 `read_resource` 和 `list_tools` 的原因:事件是重新擷取的信號。讀 `event.uri`,不要自己假設是哪個資源變動了:篩選條件可以列出好幾個 URI,伺服器也可能回報其中某個 URI 的子資源有變更。
等著被取用的重複事件會合併成一個,而重新擷取仍然能拿到目前的狀態。只有完全相同的事件才會合併:兩個 URI 不同的 `ResourceUpdated` 是兩個事件。
這個訂閱物件還有兩個屬性:
* `sub.honored` 是伺服器確認的篩選條件:一個 `SubscriptionFilter`,帶有你傳入的欄位,以屬性的方式讀取(`sub.honored.prompts_list_changed`)。`MCPServer` 會接受你要求的每一種,所以它會把你的請求原樣回傳。支援較少種類的伺服器確認的也較少,而且被接受的種類仍可能永遠不會觸發。伺服器也可能拒絕整個請求而不是確認它(見伺服器那一頁的[決定誰可以監看](../handlers/subscriptions.md#deciding-who-may-watch)),這會以該請求的錯誤呈現。
* `sub.subscription_id` 是 listen 請求的 id,也就是蓋在這條串流每個訊框上的那個 id。可以同時開著好幾個訂閱,各自靠自己的 id 解多工。
## 監看而不阻塞 {#watching-without-blocking}
`follow_board` 會一直執行到伺服器關閉串流為止,而這可能永遠不會發生,所以單獨執行時它會佔據你的整個程式。實際的用戶端希望監看器在主流程**旁邊**執行:代理程式呼叫工具的同時,監看器讓快取或 UI 保持最新。
先開啟訂閱,再啟動監看器,然後繼續做你的事。
=== "asyncio"
```python title="app.py" hl_lines="18 20"
--8<-- "docs_src/subscriptions/tutorial004_asyncio.py"
```
=== "trio"
```python title="app.py" hl_lines="18 21"
--8<-- "docs_src/subscriptions/tutorial004_trio.py"
```
=== "anyio"
```python title="app.py" hl_lines="18 21"
--8<-- "docs_src/subscriptions/tutorial004_anyio.py"
```
!!! note
`app.py` 從第一個範例匯入 `BOARD` 和 `read_board`,這個 repo 把它存成 `tutorial003.py`。如果你把產生出來的檔案並排存成 `client.py` 和 `app.py`,就改寫成 `from client import BOARD, read_board`。更下面的 `watch.py` 範例也用同樣的方式匯入 `read_board`。
重點在於順序。沒有任何東西會重播,所以在你的串流存在之前發布的事件就錯過了。進入 `client.listen(...)` 會等待確認,所以從那一刻起的每個變更都會送到監看器,而你在區塊內取得的快照不會漏掉任何一個。
串流開著的時候,請求可以自由地在旁邊執行,不管來自監看器任務還是其他任務,都在同一個用戶端上。因為**重複**的未取用事件會合併,忙碌的主流程可能只產生一次重新擷取,而不是三次。不同的事件不會合併:列出許多 URI 的篩選條件會為每個 URI 各排一個待處理事件。
要停止監看,離開區塊就好:沒有 `unsubscribe` 呼叫。取消擁有該區塊的任務就會幫你做到這件事,SDK 會依傳輸方式預期的方法取消 listen 請求:在 Streamable HTTP 上,就是關閉該請求的串流。在應用程式整個存活期間執行的監看器永遠不會自己結束,所以在關閉時取消它,或取消它所屬任務群組的範圍。
## 串流會結束 {#streams-end}
串流的結束方式有兩種,兩種都是一般的控制流程。伺服器優雅地關閉會結束 `async for`;突然中斷則會引發 `SubscriptionLost`。
兩者的差別在於診斷,而不在於接下來該做什麼:串流沒了,沒有任何東西會重播,還在意的監看器就重新 listen 並重新擷取。
```python title="watch.py" hl_lines="16 20"
--8<-- "docs_src/subscriptions/tutorial005.py"
```
伺服器會因為自己的理由優雅地關閉串流,包括甩掉積壓太多的訂閱者,所以乾淨的結束並不是該停止監看的信號。重新 listen 之前先退避一下。
`SubscriptionLost` 也有一個本地端的成因。用戶端最多保留 1024 個未取用的事件,落後到這種程度的取用端會失去訂閱,而不是無限制地膨脹。讓 `async for` 的本體保持簡短,慢的工作放到別處做。
`keep_following` 只攔截 `SubscriptionLost`。進入 `listen()` 也可能引發 `MCPError`(連線失敗,或伺服器不提供這個方法)、`TimeoutError`(沒有收到確認)和 `ListenNotSupportedError`(2026 之前的連線)。決定其中哪些是監看器該重試的:最後一種永遠不會自己好。
## 重點回顧 {#recap}
* 進入 `async with client.listen(...)`;進入時會等待確認,所以之後發布的東西都不會漏掉。
* 用 `async for event in sub` 迭代。事件是重新擷取的信號,從來不是承載內容。
* 先開啟訂閱,再把監看器當成任務執行,工具呼叫就能在旁邊持續進行。
* 乾淨的結束會讓迴圈停下;中斷則引發 `SubscriptionLost`。不管哪一種:重新 listen、重新擷取,但先退避。
* 離開區塊就是取消訂閱。
發布這些事件、縮小篩選條件,以及擴展到超過一個處理程序,是伺服器那一邊的事:**[訂閱](../handlers/subscriptions.md)**。同樣這些事件也能讓用戶端快取保持正確,而 **[快取](caching.md)** 就是下一頁。

View file

@ -0,0 +1,114 @@
---
translation:
sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7]
tool: 1
---
# 用戶端傳輸方式 {#client-transports}
每個 `Client` 都透過一種**傳輸**(transport)和伺服器溝通:也就是實際承載訊息的那個東西。
你從來不需要單獨設定它。`Client` 只接受一個位置引數,並依據它的型別判斷要用哪一種傳輸方式。
每種傳輸方式的**伺服器**端(`mcp.run()` 做的事,以及你部署的東西)請見 **[執行伺服器](../run/index.md)**。
## 記憶體內 {#in-memory}
直接傳入伺服器物件本身:
```python title="client.py" hl_lines="14"
--8<-- "docs_src/client_transports/tutorial001.py"
```
沒有子處理程序,沒有連接埠,線路上也沒有任何位元組。用戶端和伺服器是同一個處理程序裡的兩個物件,但呼叫仍然會經過真正的協定層:`search_books` 被列出、驗證、呼叫的方式,和透過 HTTP 時完全一樣。
所以它同時是兩樣東西:
* **測試工具。** 這份說明文件裡的每個範例都是這樣跑過的,而 **[測試](../get-started/testing.md)** 那一頁整個模式就是圍繞它建立的。
* **嵌入用的 API。** 自己建立伺服器的應用程式,不需要繞一圈網路就能呼叫它的工具。
## Streamable HTTP {#streamable-http}
傳入一個 URL 字串,得到的就是 **Streamable HTTP**,也就是部署時使用的那種傳輸方式:
```python title="client.py" hl_lines="5"
--8<-- "docs_src/client_transports/tutorial002.py"
```
這就是完整的正式環境用戶端。`Client` 會替你把 URL 包進 `streamable_http_client(...)`,底下是一個依 MCP 需求設定好的 `httpx2.AsyncClient`:`follow_redirects=True`、connect/write/pool 的逾時為 30 秒,read 逾時則是 300 秒,因為伺服器可能會讓回應串流一直開著。
!!! check
建立好的 `Client` **還沒有**連線。建立只是選定傳輸方式;真正開啟它的是 `async with`。在進入之前就去拿連線,SDK 會直接告訴你:
```text
RuntimeError: Client must be used within an async context manager
```
寫下 `Client("http://...")` 的時候,沒有解析、抓取或啟動任何東西。那一行是零成本的。
### 自備 `httpx2.AsyncClient` {#bring-your-own-httpx2asyncclient}
一旦需要 `Authorization` 標頭、cookie、proxy、mTLS,或不同的逾時,就自己建立 `httpx2.AsyncClient`,再交給 `streamable_http_client`:
```python title="client.py" hl_lines="8-14"
--8<-- "docs_src/client_transports/tutorial003.py"
```
有兩件事要注意:
* `httpx2.AsyncClient` 是你的,所以由**你**負責進入和離開它。SDK 永遠不會關閉不是它自己建立的用戶端。
* `streamable_http_client(url, http_client=...)` 回傳的是一個傳輸,而 `Client(transport)` 和接受其他東西一樣接受它。
關於 TLS 有一點要提:`httpx2` 是對照作業系統的信任存放區驗證憑證(透過 [`truststore`](https://pypi.org/project/truststore/)),而不是內建的 CA 清單。在沒有可用系統 CA 存放區的環境(某些精簡容器)裡,請設定標準的 `SSL_CERT_FILE`/`SSL_CERT_DIR` 環境變數,或明確傳入 `verify=ssl_context` 給你的 `httpx2.AsyncClient`(背景說明請見 [`httpx` 和 `httpx-sse` 已由 `httpx2` 取代](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2))。
!!! warning
`streamable_http_client` 以前可以直接接受 `headers=` 和 `timeout=`。現在不行了:它僅有的參數是 `url`、`http_client` 和 `terminate_on_close`。如果習慣性地寫了 `headers=`,會得到:
```text
TypeError: streamable_http_client() got an unexpected keyword argument 'headers'
```
所有跟 HTTP 有關的設定,現在都放在你傳入的那一個 `httpx2.AsyncClient` 上。
!!! info
`httpx2` 保留了熟悉的 `httpx` API,所以只要會用 `httpx`,就已經知道這裡的驗證、proxy、事件掛鉤、重試和連線數限制該怎麼做。SDK 沒有在上面加任何東西,也沒有拿掉任何東西。OAuth 也是從這裡接上的:`httpx2.AsyncClient(auth=OAuthClientProvider(...))`。整個流程請見 **[OAuth 用戶端](oauth-clients.md)**。
## stdio {#stdio}
**stdio** 伺服器是一個子處理程序。用戶端啟動它,把 JSON-RPC 寫進它的 stdin,再從它的 stdout 讀取 JSON-RPC。桌面版 MCP 主機(host)就是這樣在你的機器上執行伺服器的:主機**就是**這段程式碼加上一個 UI,而 **[連接到真正的主機](../get-started/real-host.md)** 則是從主機那一側、以設定檔的形式看同一個關係。
用 `StdioServerParameters` 描述這個處理程序,用 `stdio_client` 把它變成傳輸,再把**那個**交給 `Client`:
```python title="client.py" hl_lines="4-8 12"
--8<-- "docs_src/client_transports/tutorial004.py"
```
`Client` 不接受單獨的參數物件。`StdioServerParameters` 是設定;`stdio_client(server)` 才是知道怎麼依據它啟動處理程序的傳輸。一定要包起來。
離開 `async with` 區塊時,子處理程序也會一併關閉:關掉 stdin、等待、拖太久就強制終止。你永遠不需要自己清理。
!!! warning
子處理程序**不會**繼承你的環境。它拿到的是一份精簡的允許清單(POSIX 上是 `HOME`、`LOGNAME`、`PATH`、`SHELL`、`TERM` 和 `USER`),這樣敏感的東西才不會洩漏到一個可能不是你寫的處理程序裡。
需要 API 金鑰的伺服器在那裡是找不到的。請用 `env=` 明確傳入;這些變數會疊加在允許清單之上。上面的 `BOOKSHOP_API_KEY` 做的就是這件事。
## SSE {#sse}
`mcp.client.sse` 裡的 `sse_client(url)` 是被 Streamable HTTP 取代的那個 HTTP 傳輸。要和還在講它的伺服器溝通,用同樣的方式包起來即可:`Client(sse_client("http://localhost:8000/sse"))`,但不要在它上面蓋任何新東西。
## `Transport` 協定 {#the-transport-protocol}
對 `Client` 來說,上面這些全都是同一種東西。
**傳輸**是任何會產出一對 `(read, write)` 訊息串流的非同步 context manager:正式地說,就是 `mcp.client` 裡的 `Transport` 協定。`Client` 依型別解析它的引數:伺服器物件就在處理程序內連線,`str` 會變成 `streamable_http_client(url)`,其他任何東西則直接當成傳輸進入。最後這條規則就是為什麼 `stdio_client(...)`、`streamable_http_client(...)` 和 `sse_client(...)` 都能放進同一個位置,也是為什麼你可以自己寫一個。
## 重點回顧 {#recap}
* `Client(mcp)`(伺服器物件)在記憶體內連線。用在測試和嵌入。
* `Client("http://.../mcp")`(URL)透過 Streamable HTTP 連線,也就是正式環境用的傳輸方式。
* 標頭、驗證、proxy 和逾時都放在你傳給 `streamable_http_client(url, http_client=...)` 的 `httpx2.AsyncClient` 上。沒有 `headers=` 這個關鍵字引數。
* stdio 是 `Client(stdio_client(StdioServerParameters(...)))`,絕對不是單獨的參數物件。
* 子處理程序拿到的是允許清單上的環境,不是你的環境;`env=` 會往上加。
* 傳輸就是任何可以 `async with x as (read, write)` 的東西。只要不是伺服器物件或 URL,`Client` 就會直接交給那個協定處理。
* 建立 `Client` 是選定傳輸方式。`async with` 才是開啟它。
傳輸開啟之後,兩邊得對協定版本達成一致。平常根本不需要去想這件事;真的需要的時候,請看 **[協定版本](../protocol-versions.md)**。

View file

@ -0,0 +1,86 @@
---
translation:
sections: [20541a40dbdd5980, 01262a123ad9501d, 429db5b574a2ac08, 56b2d49da412cb28, 6a1717123fe4513c]
tool: 1
---
# 已棄用的功能 {#deprecated-features}
2026-07-28 規格讓五樣東西退場。SDK 仍然實作了其中每一項,而每一項現在都帶有**棄用警告**。
下表列出每一項已棄用的功能、它為什麼要退場,以及應該改用的替代做法。
## 哪些已棄用 {#what-is-deprecated}
| 已棄用項目 | 原因 | 替代做法 |
|---|---|---|
| **根目錄(roots)**:`ctx.session.list_roots()`、`client.send_roots_list_changed()`、傳給 `Client(...)` 的 `list_roots_callback=` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) 讓這項能力退場。 | 把路徑當成一般的工具引數或資源 URI 來接收,或是在 `InputRequiredResult` 裡嵌入一個 `ListRootsRequest`(請見 **[多輪往返(multi-round-trip)請求](handlers/multi-round-trip.md)**)。 |
| **伺服器發起的取樣(sampling)**:`ctx.session.create_message()`、傳給 `Client(...)` 的 `sampling_callback=` | SEP-2577 讓這項能力退場。 | 回傳 `InputRequiredResult`,讓用戶端重試這次呼叫(請見 **[多輪往返請求](handlers/multi-round-trip.md)**)。 |
| **協定記錄**:`ctx.log()`、`ctx.debug()`、`ctx.info()`、`ctx.warning()`、`ctx.error()`、`ctx.session.send_log_message()`、`client.set_logging_level()` | SEP-2577 讓這項能力退場。協定內沒有任何東西取代它。 | 用一般的 `import logging` 輸出到 stderr(請見 **[記錄](handlers/logging.md)**)。 |
| **`ping`**:`client.send_ping()` | 從協定中**移除**,而不只是棄用。2026-07-28 裡沒有 `ping` 方法。 | 什麼都不用。它只在 `mode="legacy"` 的連線上有效。 |
| **用戶端到伺服器的進度**:`client.send_progress_notification()` | 2026-07-28 讓進度只能從伺服器送往用戶端。 | 沒有東西要送。**伺服器**用 `ctx.report_progress()` 回報進度(請見 **[進度](handlers/progress.md)**)。 |
從這張表可以看出三件事:
* 根目錄、取樣和記錄是一起的。同一份提案 **SEP-2577** 一次棄用了這三項能力。
* 取樣和根目錄有個更深層的共同問題:它們都是**伺服器**向**用戶端**送出**請求**的地方。2026-07-28 用 **[多輪往返請求](handlers/multi-round-trip.md)** 取代的正是這整個方向。消失的是那些獨立的 RPC 方法(`sampling/createMessage`、`roots/list`,以及推送式的 `elicitation/create`);`CreateMessageRequest`/`ListRootsRequest`/`ElicitRequest` 這些酬載型別則保留下來,嵌在 `InputRequiredResult.input_requests` 裡,在用戶端會觸發同樣的回呼。
* `ping` 是特例。協定不是棄用它,而是移除它。SDK 的方法仍然會發出警告(訊息寫的是 *removed*,不是 *deprecated*),在現代連線上呼叫它,得到的回應是 *"Method not found"*。
## 棄用只是勸告性質 {#deprecated-is-advisory}
今天什麼都不會壞。
上面每個方法,在任何協商到 **2025-11-25 或更早版本**的工作階段(session)上都能繼續運作。在用戶端固定 `mode="legacy"`,就能得到和 2026 之前完全一樣的行為。線路上沒有任何變更,能力協商也維持不變。
改變的是,每個方法第一次執行時,你會看到一則明顯的警告:
```text
MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
```
`MCPDeprecationWarning` 繼承自 `UserWarning`,**不是** `DeprecationWarning`。這是刻意的:Python 的預設過濾器只會在直接以 `__main__` 執行的程式碼裡顯示 `DeprecationWarning`,這就是為什麼函式庫棄用了某樣東西,卻兩年都沒人注意到。這個警告到處都會出現,不需要 `-W` 旗標。
!!! warning
「勸告性質」到線路為止。取樣和根目錄是伺服器對用戶端的**請求**,而 2026-07-28 的工作階段沒有通道可以承載它。在現代連線上於工具內呼叫 `ctx.session.create_message()`,警告照樣會發出,接著傳送會失敗並出現錯誤:
```text
Cannot send 'sampling/createMessage': this transport context has no back-channel
for server-initiated requests.
```
兩個訊號,依這個順序出現。`MCPDeprecationWarning` 在呼叫方法的那一刻就會發出,任何連線都一樣。錯誤則是 SDK 接著嘗試傳送時回傳來的東西。這兩者只有在用戶端註冊了對應回呼的 `mode="legacy"` 連線上,才能從頭到尾正常運作。
## 讓警告靜音 {#silencing-the-warning}
新程式碼裡,不要這麼做。
但如果你維護的伺服器確實在服務 2026 之前的用戶端,它完全有權保持記錄乾淨。在第一個已棄用的呼叫執行之前,先過濾掉這個類別:
```python
import warnings
from mcp import MCPDeprecationWarning
warnings.filterwarnings("ignore", category=MCPDeprecationWarning)
```
整個 API 就這樣。沒有逐方法的開關,你也不會想要:只用一個類別的意義在於,一行就能關掉它,一行就能把它叫回來。
!!! check
把過濾器反過來用,就免費得到一個回歸測試。在 pytest 設定的 `filterwarnings` 裡加上 `"error::mcp.MCPDeprecationWarning"`,已棄用的呼叫就會**引發例外**而不是發出警告。一個名為 `old_log`、還在呼叫 `ctx.info()` 的工具會不再通過,開始回報:
```text
Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
```
一行 pytest 設定,已棄用的呼叫就再也沒辦法在不讓測試失敗的情況下溜回程式碼庫。
## 重點回顧 {#recap}
* 2026-07-28 規格棄用了**根目錄**、伺服器發起的**取樣**和協定**記錄**(全部來自 [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)),把**進度**限制為只能從伺服器到用戶端,並移除了 **`ping`**。
* 替代做法那一欄指引你接下來往哪走:取樣和根目錄看 **[多輪往返請求](handlers/multi-round-trip.md)**,記錄看 **[記錄](handlers/logging.md)**,進度看 **[進度](handlers/progress.md)**。`ping` 什麼都不需要。
* 棄用只是勸告性質:線路沒有變更,一切在 2026 之前的工作階段上都能繼續運作,而且你會看到明顯的 `MCPDeprecationWarning`(它是 `UserWarning`,所以預設就會顯示)。
* 取樣和根目錄還額外需要一條反向通道(back-channel),而 2026-07-28 的工作階段沒有。在現代連線上,它們會先警告,再引發例外。
* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` 會讓整個類別靜音;pytest 裡的 `"error::mcp.MCPDeprecationWarning"` 則把它變成測試失敗。
* 新程式碼不應該建立在這些東西之上。
這份說明文件的其他每一頁教的都是目前的 API。

View file

@ -0,0 +1,139 @@
---
translation:
sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686]
tool: 1
---
# 第一步 {#first-steps}
**[首頁](../index.md)** 的節奏很快:寫一個伺服器、執行它、呼叫一個工具。
這一頁放慢腳步,把伺服器能公開的三種東西都走過一遍,沿途每樣東西都給個名字。
## 主機、用戶端與伺服器 {#host-client-and-server}
接下來每一頁都會看到這三個詞:
* **主機**(host)是 LLM 應用程式:Claude、IDE、代理執行環境。使用者對話的就是它。
* **用戶端**位於主機內部,負責講 MCP。主機每連上一個伺服器,就執行一個用戶端。
* **伺服器**是你用這個 SDK 打造的東西。它把東西公開給用戶端,從不直接和模型溝通。
伺服器由你來寫,主機是別人的產品。SDK 也提供一個 `Client`,用來測試你的伺服器,這一頁稍後就會出現。
## 三種基本元件 {#the-three-primitives}
伺服器公開的東西正好只有三種。區分它們的關鍵是**誰決定要用**:
| 基本元件 | 由誰控制 | 是什麼 | 範例 |
|---------------|-----------------|-----------------------------------------------------|------------------------------------|
| **工具** | 模型 | 模型呼叫來執行動作的函式 | API 呼叫、寫入資料庫 |
| **資源** | 應用程式 | 主機載入到模型上下文的資料 | 檔案內容、API 回應 |
| **提示詞** | 使用者 | 使用者依名稱叫用的可重複使用訊息範本 | 斜線指令、選單項目 |
「由誰控制」正是這樣拆分的重點所在。工具會執行,是因為**模型**決定呼叫它。資源會被附上,是因為**應用程式**判斷模型需要它。提示詞會執行,是因為**使用者**選了它。
!!! info
如果做過 web API,大部分直覺你已經有了:**資源**是 `GET`(載入資料,不改變任何東西),**工具**是 `POST`(做事,可能有副作用)。**提示詞**沒有 HTTP 的對應物,比較像使用者依名稱執行的已儲存查詢。
## 一個伺服器,三種齊備 {#one-server-all-three}
```python title="server.py" hl_lines="6 12 18"
--8<-- "docs_src/first_steps/tutorial001.py"
```
三個普通函式,三個裝飾器。每個裝飾器就是完整的註冊動作:
* `@mcp.tool()` 讓 `add` 成為**工具**。
* `@mcp.resource("greeting://{name}")` 讓 `greeting` 成為**資源範本**:URI 裡的 `{name}` 就是函式的參數。
* `@mcp.prompt()` 讓 `summarize` 成為**提示詞**。它回傳的字串會變成一則使用者訊息。
其他一切(名稱、描述、引數 schema)SDK 都從函式本身讀取:函式名稱、docstring、型別提示。這些你從來沒有另外宣告過。
!!! tip
SDK 的兩半各有自己的匯入路徑:`from mcp import Client` 和 `from mcp.server import MCPServer`。沒有 `from mcp import MCPServer` 這種寫法。
### 試試看 {#try-it}
用 MCP Inspector 執行它:
```console
uv run mcp dev server.py
```
開啟它印出的 URL。Inspector 每種基本元件各有一個分頁,依序走過一遍。
**Tools。**一個項目:`add`,描述為 *Add two numbers.*。表單有一個必填的整數欄位 `a`,另一個是 `b`。填好、呼叫,結果是 `3`。Inspector 是從 `a: int, b: int` 建出那張表單的,其他每個用戶端也一樣。
**Resources。**這裡的 *Resources* 清單是空的。`greeting` 在 **Resource Templates** 底下,因為 `greeting://{name}` 帶有參數:在有人提供 `name` 之前,沒有單一資源可以列出。給它 `World` 然後讀取:
```text
Hello, World!
```
**Prompts。**一個項目:`summarize`,只有一個必填的 `text` 引數。帶一段文字去取得它,會收到一則 `role: user` 的訊息,內容就是你組好的字串。提示詞就只是這樣:一個組出訊息的函式。
Inspector 透過 **stdio** 執行你的伺服器,這是 MCP 伺服器能使用的傳輸方式之一。現在還不用選;那是 **[執行伺服器](../run/index.md)** 那一頁的事。
## 能力 {#capabilities}
在 Inspector 裡看到了三個分頁。它怎麼知道有三個?
用戶端連線時,伺服器會宣告自己的**能力**:它會回應哪幾類請求。用戶端據此決定究竟該要求什麼。這份宣告不是你寫的,`MCPServer` 替你宣告好了。
自己看看吧。SDK 的 `Client` 直接接受伺服器物件,並在**記憶體內**與它連線(沒有子處理程序,沒有連接埠):
```python
import asyncio
from mcp import Client
from server import mcp
async def main() -> None:
async with Client(mcp) as client:
print(client.server_capabilities.model_dump(exclude_none=True))
asyncio.run(main())
```
```text
{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}}
```
那個字典就是伺服器宣告的**能力**,也是每個連線進來的用戶端最先得知的事:
| 能力 | 用戶端現在可以呼叫 |
|-------------|------------------------------------------------------------|
| `tools` | `tools/list`, `tools/call` |
| `resources` | `resources/list`, `resources/templates/list`, `resources/read` |
| `prompts` | `prompts/list`, `prompts/get` |
`MCPServer` 三種基本元件都提供,所以三者永遠都會宣告。
注意少了什麼。`completions`(資源範本和提示詞的引數自動完成)需要你寫一個處理函式,這個伺服器沒有,所以這項能力不存在,守規矩的用戶端也不會去問。所有選用的東西都照這條規則:註冊了,能力就出現;**[自動完成](../servers/completions.md)** 會證明這一點。
!!! info
`Client(mcp)` 就是這份文件裡每個範例測試時用的同一個記憶體內用戶端,你也會用它來測試自己的。它有專屬的一整頁:**[測試](testing.md)**。
## 你沒寫的東西 {#what-you-did-not-write}
回頭看這一頁。你寫了三個小小的 Python 函式。你**沒有**寫:
* JSON Schema。`a: int, b: int` **就是** `add` 的 schema。
* 請求處理函式。`tools/list`、`resources/read`、`prompts/get`:全都替你處理好了。
* 能力宣告。`MCPServer` 替你做了。
* 任何一行協定。版本協商、JSON-RPC 訊框、能力交換:全都發生在 `mcp dev` 和 `Client(mcp)` 裡面,你完全沒看到。
這個比例正是 SDK 的意義所在。
## 重點回顧 {#recap}
* **主機**是 LLM 應用程式,**用戶端**是它講 MCP 的那一半,**伺服器**是你打造的東西。
* 工具由**模型**控制,資源由**應用程式**控制,提示詞由**使用者**控制。
* 每種基本元件一個裝飾器:`@mcp.tool()`、`@mcp.resource(uri)`、`@mcp.prompt()`。名稱、描述和 schema 都來自函式。
* 帶 `{param}` 的 URI 會產生資源**範本**,和具體資源分開列出。
* 伺服器的**能力**會替你宣告好,而用戶端只會要求伺服器宣告過的東西。
* `Client(mcp)` 在記憶體內連上伺服器物件:從第一天起就是你的測試工具。
接下來是 **[連接真正的主機](real-host.md)**:把這個伺服器真的放進 Claude Desktop 或 IDE 裡。然後是 **[測試](testing.md)**:一頁、一個記憶體內用戶端,從此不用猜它到底能不能動。再之後,每種基本元件各有自己的一頁,從模型主導的那個開始:**[工具](../servers/tools.md)**。

View file

@ -0,0 +1,53 @@
---
translation:
sections: [ed4a756b4c53c585, 97e2fb315b7fe398, 4d04f1c6f4bf6c1d, 577d73078fc62baf]
tool: 1
---
# 開始使用 {#get-started}
剛接觸 MCP,或剛接觸這個 SDK?從這裡開始。這幾頁會帶你從零開始,做出一個能運作、經過測試的伺服器:[安裝 SDK](installation.md)、建立[第一個伺服器](first-steps.md)、[把它接上真正的 MCP 主機(host)](real-host.md),再用記憶體內用戶端[測試它](testing.md)。
## 執行程式碼 {#run-the-code}
所有程式碼區塊都可以直接複製使用:每一個都是完整、可運作的檔案。
想跟著做的話,把程式碼區塊貼進 `server.py`,然後用 MCP Inspector 開啟:
```console
uv run mcp dev server.py
```
**強烈建議**自己寫下(或複製)程式碼、動手修改,並在本機執行。在自己的編輯器裡用過,才真正看得出重點在哪:要寫的東西有多少、自動完成的體驗,以及型別檢查在執行之前就幫你抓出錯誤。
## 不需要猜 {#you-will-not-be-guessing}
這份說明文件裡的每個範例,都是 SDK 自己的儲存庫中 [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) 底下的完整檔案,而且每一個都由 SDK 的測試套件透過**記憶體內用戶端**實際跑過:
```python
import pytest
from mcp import Client
from server import mcp
@pytest.mark.anyio
async def test_add() -> None:
async with Client(mcp) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.structured_content == {"result": 3}
```
沒有子處理程序、沒有連接埠、沒有傳輸方式。`Client(mcp)` 直接連到伺服器物件。
如果 SDK 的某個改動弄壞了這幾頁上的範例,CI 會比頁面先亮紅燈。你在這裡讀到的程式碼,就是實際執行的程式碼。
在[測試](testing.md)那一頁你會自己用到這個做法;測試自己的伺服器時也是這樣做。
## 接下來往哪裡走 {#where-to-go-next}
伺服器跑起來之後,這份說明文件的其他部分是參考資料,不是課程。每一頁都可以獨立閱讀,直接跳到需要的地方即可:
* 伺服器對外提供什麼(工具、資源、提示詞),請見 **[伺服器](../servers/index.md)**。
* 註冊的函式裡有哪些東西可用,請見 **[在處理函式內部](../handlers/index.md)**。
* 怎麼把伺服器交到用戶端面前(stdio、HTTP、現有的 FastAPI 應用程式),請見 **[執行伺服器](../run/index.md)**。
* 打造另一端,也就是**使用** MCP 伺服器的應用程式,請見 **[用戶端](../client/index.md)**。

View file

@ -0,0 +1,45 @@
---
translation:
sections: [6e2f9bab94d5ed36, 8cf653388f69e28b, 6fd9ea2f65de0df6]
tool: 1
---
# 安裝 {#installation}
Python SDK 在 PyPI 上的套件名稱是 [`mcp`](https://pypi.org/project/mcp/),需要 **Python 3.10+**。
這份文件描述的是 **v2**,也就是目前的穩定版本線:
=== "uv"
```bash
uv add "mcp[cli]"
```
=== "pip"
```bash
pip install "mcp[cli]"
```
!!! note "從 v1 過來的嗎?"
v2 是含有破壞性變更的主要版本,**[遷移指南](../migration.md)**逐一說明了每一項變更。如果你的**套件**依賴 `mcp` 而且還沒準備好遷移,請保留 `<2` 的版本上限(例如 `mcp>=1.28,<2`),這樣沒有鎖定版本的解析結果就會停留在 1.x 版本線。
## 會安裝哪些東西 {#what-gets-installed}
使用 SDK 不需要知道這些,但如果你好奇每個相依套件的用途:
* `mcp-types`:所有協定型別(請求、結果、內容區塊)獨立成一個套件,版本與 SDK 同步。依賴 `mcp` 的程式碼透過 `mcp.types` 這個別名匯入(這份文件裡每一個 `from mcp.types import ...` 都是如此);只有在安裝了 `mcp-types` 但沒有安裝 SDK 的專案裡,才直接匯入 `mcp_types`。
* [`anyio`](https://anyio.readthedocs.io/):非同步執行環境。整個 SDK 都是基於 anyio 寫的,所以在 `asyncio` 或 `trio` 上都能執行。
* [`pydantic`](https://docs.pydantic.dev/):每個 `mcp.types` 模型的基礎,也負責所有 schema 的產生與驗證。
* [`httpx2`](https://pypi.org/project/httpx2/):Streamable HTTP 和 SSE **用戶端**傳輸背後的 HTTP 用戶端,內建 server-sent events 支援。
* [`starlette`](https://www.starlette.io/)、[`uvicorn`](https://www.uvicorn.org/)、[`sse-starlette`](https://pypi.org/project/sse-starlette/) 和 [`python-multipart`](https://pypi.org/project/python-multipart/):HTTP **伺服器**傳輸。
* [`jsonschema`](https://pypi.org/project/jsonschema/):依照工具宣告的輸出 schema 驗證它的結構化輸出。
* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/):授權用的 OAuth 權杖處理。
* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/):只有輕量的 API,所以除非你自己安裝 OpenTelemetry SDK 和匯出器,否則 SDK 的追蹤中介軟體不會帶來任何負擔。
* [`typing-extensions`](https://typing-extensions.readthedocs.io/) 和 [`typing-inspection`](https://pypi.org/project/typing-inspection/):讓 Python 3.10 也能使用新的型別功能。
* [`pywin32`](https://pypi.org/project/pywin32/):僅限 Windows,用於 `stdio` 子處理程序管理。
## 選用的 extra {#optional-extras}
* `mcp[cli]` 會加裝 [`typer`](https://typer.tiangolo.com/) 和 [`python-dotenv`](https://pypi.org/project/python-dotenv/),供 `mcp` 命令列工具使用(`mcp dev`、`mcp run`、`mcp install`)。開發期間會用到;部署後的伺服器可能就不需要了。
* `mcp[rich]` 會加裝 [`rich`](https://rich.readthedocs.io/),讓伺服器記錄更好看。

View file

@ -0,0 +1,168 @@
---
translation:
sections: [3c4f2f06b4e978b6, 22520eecae3d1961, f4e1709db18d635a, 2eb57992049671d9, 1ba83e9af37cc1b4, 4822586344b08d9e, 1c93afef72478992, b6b448f9eddd51dc, fe55370fd931815b]
tool: 1
---
# 連接到真正的主機 {#connect-to-a-real-host}
**主機(host)** 指的是伺服器最後會被放進去的那個應用程式:Claude Desktop、Claude Code、IDE。使用者直接面對、互動的就是主機。在主機內部,MCP **用戶端**會把你的伺服器當成子處理程序啟動,並透過該處理程序的 stdin 和 stdout 與它溝通。
也就是說,連接到主機只有一個動作:告訴它**啟動伺服器的指令**。這一頁上的所有內容(兩個 CLI 指令、三個 JSON 檔案),都只是放這同一道指令的不同位置。
## 一個伺服器,所有主機 {#one-server-every-host}
```python title="server.py" hl_lines="3 33-34"
--8<-- "docs_src/real_host/tutorial001.py"
```
兩個工具加一個資源,全在一個檔案裡。這個檔案有三件事對下面每個主機都很重要:
* `mcp.run()` 不帶引數時會啟動 **stdio** 伺服器:它會阻塞,從 stdin 讀取協定訊息,並把訊息寫到 stdout。這一頁上每個主機說的都是這種傳輸方式。主機把你的檔案當成子處理程序啟動,並掌管這兩條管道,所以連接永遠只是「指令在這裡」。不需要挑連接埠,也沒有任何東西在監聽連接埠。
* `run()` 放在 `if __name__ == "__main__":` 底下。下面所有做法都是**匯入**這個檔案而不是執行它,所以沒有這層保護的 `run()` 會在任何東西載入模組的那一刻就啟動伺服器。
* 伺服器物件是模組層級的全域變數,名稱是 `mcp`。`mcp run` 找的就是這個名稱(`server` 和 `app` 也可以)。如果取別的名字,就要明確指定:`mcp run server.py:bookshop`。
這是這一頁最後一行 Python。從這裡往下全都是主機設定。
## 啟動指令 {#the-launch-command}
下面每個主機拿到的都是同一道指令:
```bash
uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py
```
所有主機共用一道指令,是因為 `uv run --with` 會當場把 SDK 解析進一個全新的環境:從任何目錄都能執行,不需要專案,也不需要啟用虛擬環境。這一點在這裡比任何地方都重要,因為主機是從**它自己**的工作目錄、帶著幾乎空白的環境來啟動伺服器,而不是從你的 shell。
這也是 `mcp install` 替你寫進 Claude Desktop 設定檔的指令(見下文),所以手動輸入的和工具產生的會一致,差別只在工具多加了精確的版本鎖定。
!!! tip "如果主機找不到 `uv`"
主機用極簡的 `PATH` 產生你的伺服器處理程序,`uv` 可能不在裡面。把單獨的 `uv` 換成 `which uv`(macOS/Linux)或 `where uv`(Windows)給出的絕對路徑。`mcp install` 寫的正是這個。
!!! note "這一頁講的是本機情境"
這裡的一切都是在主機所在的那台機器上執行伺服器:主機透過 stdio 啟動你的檔案。對個人用或單機工具來說,這完全正確。要把伺服器交給**沒有**你這個檔案的人,給出去的是 **URL** 而不是指令:同一個 `mcp` 物件,改用 Streamable HTTP 提供服務。**[執行伺服器](../run/index.md)** 用一張表講清楚這個抉擇,**[部署與擴展](../run/deploy.md)** 則是從那裡走到真正主機名稱的路。
而主機不過就是內含 MCP 用戶端的應用程式,所以你自己的 Python 也能扮演主機的角色:**[用戶端傳輸方式](../client/transports.md)** 用 `stdio_client(...)` 把同一個檔案當成子處理程序啟動,**[測試](testing.md)** 則在記憶體內連接它,完全不需要處理程序。
## Claude Desktop {#claude-desktop}
SDK 唯一能替你設定的主機:
```bash
uv run mcp install server.py
```
就這樣。`mcp install` 會匯入檔案來讀取伺服器名稱,找到 Claude Desktop 的設定檔,然後把啟動指令寫進去。過程中它會把你的路徑轉成絕對路徑,不用自己動手。
沒什麼神祕的。它寫進去的項目長這樣:
```json
{
"mcpServers": {
"Bookshop": {
"command": "/absolute/path/to/uv",
"args": [
"run",
"--frozen",
"--with",
"mcp[cli]==2.0.0",
"mcp",
"run",
"/absolute/path/to/server.py"
]
}
}
}
```
這就是上一節的啟動指令,外加三樣東西:`uv` 的絕對路徑、`--frozen`(讓 `uv` 永遠不會改寫它剛好碰到的 lockfile),以及精確鎖定在你已安裝的 `mcp` 版本。它會寫進 `claude_desktop_config.json`,這個檔案位於:
* **macOS**:`~/Library/Application Support/Claude/claude_desktop_config.json`
* **Windows**:`%APPDATA%\Claude\claude_desktop_config.json`
這個檔案可以手寫。`mcp install` 存在的意義,是讓你手寫時不會犯那個經典錯誤(相對路徑)。
完全結束 Claude Desktop(不只是關掉視窗),再重新開啟。
!!! warning
如果 Claude Desktop 的設定**目錄**還不存在,`mcp install` 會以 `Claude app not found` 失敗。安裝 Claude Desktop 並執行一次:目錄就是這樣建立的。
!!! tip
Claude Desktop 在它自己的處理程序裡啟動你的伺服器,所以 shell 的環境變數不會在那裡。`uv run mcp install server.py -v API_KEY=abc123`(或 `-f .env`)會把它們記錄在項目的 `env` 欄位裡。`--name` 可以覆寫項目名稱;預設為伺服器的 `name`。
## Claude Code {#claude-code}
沒有檔案要編輯。用 `claude` CLI 註冊伺服器;`--` 之後的全部都是啟動指令。
```bash
claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py
```
在 Claude Code 工作階段裡執行 `/mcp`,確認 `bookshop` 已連線且列出了它的工具。
## Cursor {#cursor}
在專案根目錄建立 `.cursor/mcp.json`。
```json
{
"mcpServers": {
"bookshop": {
"command": "uv",
"args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"]
}
}
}
```
同樣的 `command` 加 `args`,放在 Claude Desktop 也在用的同一個 `mcpServers` 鍵底下。伺服器會出現在 Cursor 的 MCP 設定裡,兩個工具都會列出來。
## VS Code {#vs-code}
在專案根目錄建立 `.vscode/mcp.json`。
```json
{
"servers": {
"bookshop": {
"type": "stdio",
"command": "uv",
"args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"]
}
}
}
```
和 Cursor 的檔案只有兩處不同,就這兩處:外層的鍵是 `servers` 而不是 `mcpServers`,而且每個項目都要宣告 `type`。確認信任提示後,在命令選擇區執行 **MCP: List Servers**,就會看到 `bookshop` 正在執行。
!!! note
需要 VS Code 1.99 以上,並安裝已登入的 **GitHub Copilot** 延伸模組(Copilot Free 就夠了),而且 Copilot Chat 必須在 **Agent** 模式,因為其他模式都不會呼叫工具。
## 沒有出現 {#it-doesnt-show-up}
動任何主機設定之前,先自己執行一次啟動指令:
```bash
uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py
```
什麼都不會印出,也不會結束返回。這種沉默是正確的:stdio 伺服器正在等主機先從 stdin 開口(按 `Ctrl-C` 停止)。出現 traceback 或立刻結束才是真正的 bug,而現在可以直接讀到它,不用隔著主機瞎猜。
一旦這道指令乖乖停在那裡等,剩下的問題幾乎一定是這三件事之一:
* **相對路徑。** 主機是從**它自己**的工作目錄啟動伺服器,不是你註冊時所在的目錄。該寫 `/absolute/path/to/server.py` 卻寫成 `server.py`,是最常見的失敗原因。如果主機也找不到 `uv`,那個路徑也得是絕對路徑。
* **主機還在用舊的設定。** 主機在啟動時讀取設定。特別是 Claude Desktop,必須**完全結束**(不只是關掉視窗)再重新開啟,對 `claude_desktop_config.json` 的修改才會生效。
* **有東西在轉向的時段之外寫到了 stdout。** 在 stdio 上,stdout **就是**協定。SDK 在提供服務期間會把已 flush 的雜散輸出轉到 stderr,但在那之前就 flush 到 stdout 的輸出(包裝腳本的 echo、未緩衝處理程序中匯入階段的 `print()`),或是在直譯器結束時才排出的緩衝 `print()`,都會交給主機一則損壞的訊息,主機就會斷線。用預設的 `logging` 設定來記錄,它的 stderr handler 會逐筆 flush;自訂 handler 也必須避開 stdout。完整說明請見 **[記錄](../handlers/logging.md)**。
Claude Desktop 會為每個伺服器各留一份記錄:`mcp-server-<NAME>.log` 是伺服器的 stderr,旁邊的 `mcp.log` 記錄連線,macOS 在 `~/Library/Logs/Claude` 底下,Windows 在 `%APPDATA%\Claude\logs`。
超出這三件事的問題,請見 **[疑難排解](../troubleshooting.md)**。
## 重點回顧 {#recap}
* **主機**(Claude Desktop、IDE)執行一個 MCP 用戶端,透過 stdio 把你的伺服器當成子處理程序啟動。連接就是給它一道啟動指令。
* 這道指令是 `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py`:不用啟用 venv,從任何目錄都能執行。
* **Claude Desktop** 是 `mcp install` 唯一能替你設定的主機。它把同一道指令(加上 `uv` 的絕對路徑、`--frozen`,以及精確鎖定你已安裝的版本)寫進 `claude_desktop_config.json`,你永遠不必自己動手。
* **Claude Code** 是 `claude mcp add bookshop -- <launch command>`。**Cursor** 是 `.cursor/mcp.json`,放在 `mcpServers` 底下。**VS Code** 是 `.vscode/mcp.json`,放在 `servers` 底下,每個項目都有 `type`。
* 到處都用絕對路徑,改完設定後重新啟動主機,而且除了 SDK 之外,絕不讓任何東西寫到 stdout。
這一頁上每個主機都連到同一個檔案,用的是同一道指令。這個檔案能**公開**什麼,就是這份文件其餘的內容:**[工具](../servers/tools.md)**、**[資源](../servers/resources.md)**,以及 **[執行伺服器](../run/index.md)** 裡 stdio 以外的每一種傳輸方式。

View file

@ -0,0 +1,96 @@
---
translation:
sections: ['4926721070127497', c52a1de2b6b32f40, 2e410b412c25f314, 627195f7159e24ef]
tool: 1
---
# 測試 {#testing}
Python SDK 附帶一個 `Client` 類別,內建**記憶體內傳輸**:把伺服器物件傳給它,它就會直接連上去。
沒有子處理程序,沒有連接埠,根本沒有傳輸層。概念和 FastAPI 的 `TestClient` 一樣。
## 基本用法 {#basic-usage}
假設有一個簡單的伺服器,只有一個工具:
```python title="server.py"
--8<-- "docs_src/testing/tutorial001.py"
```
要執行下面的測試,需要兩個額外的(開發用)相依套件:
=== "uv"
```bash
uv add --dev pytest inline-snapshot
```
=== "pip"
```bash
pip install pytest inline-snapshot
```
!!! info
這份說明文件假設你已經會用 [`pytest`](https://docs.pytest.org/en/stable/)。
下面的測試用 [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) 來在一行內對整個結果物件做斷言。它會把測試的輸出記錄成你看到的 `snapshot(...)` 字面值。如果不想用它,拿掉 import,像其他測試一樣對你在意的欄位做斷言(`result.content[0].text == "3"`)即可。
接著是測試:
```python title="test_server.py"
import pytest
from inline_snapshot import snapshot
from mcp import Client
from mcp.types import CallToolResult, TextContent
from server import mcp
@pytest.fixture
def anyio_backend(): # (1)!
return "asyncio"
@pytest.fixture
async def client(): # (2)!
async with Client(mcp, raise_exceptions=True) as c:
yield c
@pytest.mark.anyio
async def test_call_add_tool(client: Client):
result = await client.call_tool("add", {"a": 1, "b": 2})
# Drop the server identity stamp in `_meta`; it is not what this test is about.
result.meta = None
assert result == snapshot(
CallToolResult(
content=[TextContent(type="text", text="3")],
structured_content={"result": 3},
)
)
```
1. 如果用的是 `trio`,改成回傳 `"trio"`。細節請見 [anyio 說明文件](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on)。
2. 這個 fixture 會 yield 一個已連線的用戶端。每個接收 `client` 的測試都會拿到一條連到同一個伺服器的全新記憶體內連線。
這樣就完成了!現在可以擴充測試,涵蓋更多情境。
## 為什麼要 `raise_exceptions=True`? {#why-raise_exceptionstrue}
可能出錯的地方有兩種,而這個旗標只影響其中一種。
**你的工具**內部引發的例外不算協定失敗。它會變成一個帶有 `is_error=True` 的正常結果,模型會讀到那則訊息。`raise_exceptions` 不會改變這一點:不管有沒有設定,`call_tool` 都回傳同樣的 `is_error=True` 結果。這部分有一整頁的說明:**[處理錯誤](../servers/handling-errors.md)**。
發生在工具本體**之外**的失敗就不一樣了。在 `Client(mcp)` 給你的連線上,伺服器會先把它淨化成通用的 `"Internal server error"`,用戶端才看得到。意外當掉的細節本來就不該洩漏給遠端呼叫端。但在測試裡,這正是你**不**想要的,也正是 `raise_exceptions=True` 改變的地方:測試會看到真正的訊息,而不是淨化過的版本。
測試裡就開著它。在正式環境的程式碼裡它沒有任何意義。
## 預設為處理程序內連線 {#in-process-by-default}
!!! note
`Client(mcp)` 以處理程序內的方式連線,而且預設是**世代中立**的:它會探測伺服器,選出合適的協定路徑。如果測試要驗證舊版特有的語意(取樣(sampling)或徵詢(elicitation)的推送、`message_handler`),就固定用 `mode="legacy"`,並且在那裡拿掉 `raise_exceptions=True`:舊版連線本來就不會淨化,而這個旗標會讓失敗在伺服器任務裡重新引發,而不是在你的測試裡。
也正是因為這一行,這份說明文件才敢保證範例都能跑:每個範例檔案都由 SDK 自己的測試套件實際執行過,幾乎全部都是透過這個用戶端。你用的工具和 SDK 用在自己身上的是同一個。
現在有了一個可以運作、也測試過的伺服器。要把它放進真正的應用程式(Claude Desktop、IDE)裡,請見 **[連接真實的主機(host)](real-host.md)**;其他所有提供服務的方式請見 **[執行伺服器](../run/index.md)**。

Some files were not shown because too many files have changed in this diff Show more