MCP 工具呼叫的重試安全性
在用戶端失敗的工具呼叫,仍可能已在伺服器端完成。若呼叫方重試,工具就會執行兩次。本文記錄了四種重現方式、相關的規格條文,以及參考實作中冪等性支援的現況。
摘要
| # | 情境 | 傳輸方式 | 重試發起方 | 呼叫次數 | 實際效果 |
|---|---|---|---|---|---|
| 1 | 用戶端逾時,接著取消 | stdio | 測試框架 | 1 | 2 |
| 2 | 伺服器 SIGKILL 後重啟 | stdio | 測試框架 | 1 | 2 |
| 3 | 用戶端逾時,接著取消 | Streamable HTTP | 測試框架 | 1 | 2 |
| 4 | 工具回傳後出現 ConnectionError |
stdio | LangGraph,預設策略 | 1 | 3 |
在每個案例中,工具都宣告了 idempotentHint: false,而用戶端也收到了該註記。
環境
版本已固定,因為這些東西每週都在變。
| 元件 | 版本 |
|---|---|
@modelcontextprotocol/sdk(TypeScript) |
1.30.0 |
mcp(Python) |
1.29.1 |
langgraph |
1.2.11 |
langchain-mcp-adapters |
0.3.2 |
langchain-core |
1.6.1 |
@cloudflare/think |
0.17.0 |
| 協商出的協定版本 | 2025-11-25 |
| Node | 26.7.0 |
TypeScript SDK 的 LATEST_PROTOCOL_VERSION 是 2025-11-25,而 SUPPORTED_PROTOCOL_VERSIONS 也止於此,因此參考用戶端無法協商 2026-07-28。重現實驗是在 2025-11-25 上執行的。
測試伺服器只提供一個工具 charge,它會在帳本檔案中附加一行,然後進入休眠。寫入發生在休眠之前,因此效果在呼叫早期就已提交,之後任何失敗都不會將它復原。
2026-07-28 修訂版改了什麼
目前修訂版中有兩組變更與此相關。
重試在兩項功能中都作為一般控制流程出現。Multi Round-Trip Requests 會在用戶端「於原始請求的重試中」提供 inputResponses 時解析。徵詢(elicitation)完成通知則被移除,因為「用戶端是透過重試原始請求來得知頻外互動的結果」。
四項跨連線攜帶狀態的機制被移除:
| 移除項目 | 變更 |
|---|---|
協定工作階段、Mcp-Session-Id |
重大變更 1 |
SSE 可續傳、Last-Event-ID、事件 ID |
重大變更 9 |
initialize / notifications/initialized |
重大變更 2 |
ping、logging/setLevel |
重大變更 5 |
重大變更 9 規定了替代行為:「中斷的回應串流會使進行中的請求遺失;用戶端必須以新的請求 ID 將其作為新請求重新發出。」
該修訂版保留了六個 _meta 鍵(clientCapabilities、clientInfo、logLevel、oauth、protocolVersion、serverInfo)。其中沒有一個是去重或冪等性鍵。字串 exactly-once、at-least-once 和 deduplicate 都未出現在規格中。
情境 1:逾時與取消
針對一個三秒的工具設定一秒的用戶端逾時,接著重試。
[client] attempt 1 {"timeoutMs":1000}
[server] SIDE EFFECT COMMITTED {"reqId":"2","amount":100}
[client] attempt 1 FAILED {"code":-32001,"message":"Request timed out"}
[client] retrying
[server] <<< INBOUND notifications/cancelled {"requestId":2}
[server] SIDE EFFECT COMMITTED {"reqId":"3","amount":100}
[server] handler finishing {"reqId":"2","aborted":true}
[client] RESULT {"logicalInvocations":1,"actualCharges":2}
這個順序中有三點值得注意。
notifications/cancelled 是在用戶端已把錯誤回傳給呼叫方之後才抵達伺服器。任何位於 SDK 之上的重試邏輯,都會在伺服器被告知任何事情之前先執行。
伺服器處理請求 2 的處理器觀察到 aborted: true,並仍執行到完成。寫入早已發生。
用戶端沒有收到請求 2 的任何結果。規格指示伺服器不要回應已取消的請求,因此呼叫方對第一次嘗試所知道的只是它沒有回傳,而這無法區分「沒有發生」與「發生了但回應被丟棄」。
情境 2:行程重啟
stdio 傳輸章節規定了復原程序:
若伺服器行程意外結束,用戶端應該重新啟動它。由於協定是無狀態的,任何進行中的請求就只是遺失,用戶端可以對新的行程重試這些請求。
照這段文字執行,在寫入提交後送出 SIGKILL:
[server] SIDE EFFECT COMMITTED {"reqId":"1","amount":100}
[client] SIGKILL the server process
[client] attempt 1 FAILED {"code":-32000,"message":"Connection closed"}
[client] restarting per spec guidance and retrying
[server] SIDE EFFECT COMMITTED {"reqId":"1","amount":100}
[client] RESULT {"logicalInvocations":1,"actualCharges":2}
文中給出的理由是協定是無狀態的。協定無狀態描述的是連線,而不是伺服器端的效果,並不足以確立一個請求可以安全地重複執行。
兩筆帳本紀錄都帶有 reqId: 1。重啟後的行程會重新開始它的請求計數器,因此請求 ID 在重啟前後並不唯一。
情境 3:Streamable HTTP
與情境 1 相同的逾時與重試,改為透過 StreamableHTTPServerTransport 與 StreamableHTTPClientTransport。一次呼叫產生兩次效果,請求 ID 分別為 1 和 2。傳輸方式的選擇並未改變結果。
情境 4:LangGraph 的預設重試策略
情境 1 到 3 使用的是測試框架自己寫的重試。這一個則使用框架的重試。
一個 LangGraph 節點透過 langchain-mcp-adapters 呼叫 charge,然後拋出 ConnectionError,這正是 MCP 傳輸中斷時所表現出的形式。過程中不涉及任何 LLM,因此執行是確定性的。該節點使用預設的 RetryPolicy,未覆寫 retry_on。
[client] tool 'charge' metadata: {'title': None, 'readOnlyHint': None,
'destructiveHint': True, 'idempotentHint': False, 'openWorldHint': None}
[client] node attempt 1: calling charge -> 'charged 100.0'
[client] node attempt 2: calling charge -> 'charged 100.0'
[client] node attempt 3: calling charge -> 'charged 100.0'
[client] RESULT {"logicalInvocations":1,"nodeAttempts":3,"actualCharges":3}
預設策略有兩個特性造成了這個結果。
langgraph/_internal/_retry.py 會先檢查 isinstance(exc, ConnectionError) 並回傳 True。接著它對 ValueError、TypeError、RuntimeError、ArithmeticError、LookupError、NameError 等回傳 False,理由是這些代表程式錯誤而非暫時性錯誤。這樣的分類是刻意的,而傳輸錯誤正是完成狀態未知的那一類。
重試的粒度是節點。工具呼叫位於節點內部,因此重新執行節點就會重新執行該呼叫。這一點普遍成立:這些框架中的檢查點邊界比單次工具呼叫更粗。
讀取 idempotentHint 的程式碼
MCP 自 2025-03-26 修訂版起就在工具註記上帶有 idempotentHint。在兩套參考堆疊中搜尋會讀取它的程式碼:
TypeScript,@modelcontextprotocol/sdk 1.30.0。 只有一處執行期出現,是 dist/esm/types.js 中的一個 Zod 結構欄位。其他所有出現都在 .d.ts 宣告中,而這些在執行前就被抹除了。
Python,跨 langgraph 1.2.11、langchain-core 1.6.1、langchain-mcp-adapters 0.3.2、mcp 1.29.1。 只有一處出現,是 mcp/types.py:1276 的一個欄位宣告。
兩套堆疊中都沒有任何程式碼會讀取它。如情境 4 所示,該註記被傳輸、被接收,並在 tool.metadata 中曝露給代理層。
用戶端側的冪等性:@cloudflare/think
有一個實作帶有冪等性機制。@cloudflare/think 0.17.0 在 15 個檔案中有 idempotencyKey、在 4 個檔案中有 ActionPendingError 型別,文件並說明「傳遞是至少一次;請使用 idempotencyKey 或 occurrenceKey 來實作你自己的持久化冪等性。」它列舉了殘留的至少一次案例,以及它接受哪些。
只搜尋它的執行期檔案,排除 .d.ts:
- 5 個執行期檔案呼叫
callTool - 8 個執行期檔案引用
idempotencyKey - 0 個執行期檔案同時包含兩者
依賴樹中唯一同時出現兩者的地方是 agents/dist/agent-routing-*.d.ts 中的一個型別宣告。這些冪等性鍵由它自己的排程層使用,並不會傳到 tools/call,而後者也沒有保留任何欄位來攜帶它。
範圍
本文未涵蓋:
- 協定修訂版 2026-07-28。參考用戶端無法協商它。
- 網路分區與時鐘偏移。兩者皆未測試。
- LangGraph 以外的框架。
- 在協定之外自行維護去重的伺服器實作。
- 是否有任何生產環境部署遇到過此問題。這些都是人為建構的重現。
先前的研究
Sajjad Khan 的〈Resume Means Resume〉(2026 年 8 月)對持久化層那一半的探討比本文更深入:一份以 TLA+ 與 TLAPS 驗證的六項性質一致性合約、一個 39 格的故障矩陣,以及在真實 SIGKILL 下對五個已部署框架的量測。它量測到 LangGraph 1.2.9 為「跨中斷恰好一次,跨崩潰至少一次」,並報告沒有任何兩個框架共用相同的一致性設定檔。它不涵蓋 MCP、網路分區或時鐘偏移。
相關提案
SEP-3182 提議在 tools/call 上加入一個選用的 idempotencyKey 欄位,讓伺服器能辨識出重試並回傳原始結果,而不是重新執行。
該討論串持續了三週。一位審閱者在 08-01 提出多輪往返的等價性缺口,以及參考實作中的線路層級不一致;作者在 08-02 針對兩者推送了修正,並在 08-05 詢問這個鍵應該放在專用欄位還是保留的 _meta 鍵中。該問題是向工作小組提出的,但十八天無人回覆。08-23 一位維護者關閉了這個 PR,留言為「未揭露且無脈絡的 AI 生成 PR」。作者詢問還需要哪些額外脈絡,截至 2026-09-01 仍未獲回覆。
該討論串中的審閱都來自其他非成員。沒有任何維護者在任何時點觸及該提案的技術內容,包含關閉時也沒有。
重現方式
不需要 API 金鑰,也不需要呼叫模型。
npm i @modelcontextprotocol/sdk@1.30.0 zod@3 tsx@4
LEDGER=$PWD/ledger.jsonl DELAY_MS=3000 TIMEOUT_MS=1000 node --import tsx client.ts
LEDGER=$PWD/ledger2.jsonl node --import tsx client-restart.ts
uv pip install langgraph langchain-mcp-adapters mcp
LEDGER=$PWD/ledger.jsonl python graph.py
給想延伸此實驗的人一個提醒。情境 2 的早期版本以 npx tsx server.ts 啟動伺服器,這會使傳輸層的子行程變成 npx,而伺服器成為孫行程。SIGKILL 打到的是 npx,伺服器存活下來,第 1 次嘗試成功,測試框架於是重試了一個已成功的呼叫。它報告了兩次效果,卻什麼也沒證明。請以 node --import tsx server.ts 啟動,讓子行程就是伺服器,並在計算任何數字之前先斷言第 1 次嘗試以 -32000 失敗。
什麼能解決這個問題
在 tools/call 上加一個去重鍵,這正是 SEP-3182 所提議的。或者,提供一種方式讓用戶端能向伺服器查詢某個回應已遺失的請求最終發生了什麼。
今天這兩者都不存在。在其中之一出現之前,需要工具呼叫只發生一次的應用程式必須自行在工具參數中攜帶自己的鍵,並在伺服器端去重,因為協定不會替你攜帶它。
資源:
- 重現測試框架 - 四種情境,版本已固定
- MCP 2026-07-28 變更記錄
- SEP-3182:請求冪等性
- Resume Means Resume - Khan,工作流程持久化的一致性合約