簡要說明

  • 一個在用戶端失敗的 MCP 工具呼叫,在伺服器端仍可能已經完成,因此重試會讓它執行兩次。四種重現:逾時、伺服器重啟、Streamable HTTP,以及 LangGraph 的預設重試策略。
  • 每個工具都宣告了 idempotentHint: false,而兩套參考實作中都沒有任何程式碼讀取這個旗標。
  • 規範要求用戶端重新發出遺失的請求,而 tools/call 上沒有任何欄位能攜帶去重鍵。SEP-3182 提出了一個,卻在沒有任何維護者討論其內容的情況下被關閉。
  • 在協定本身攜帶這樣的鍵之前,必須只執行一次的工具需要在參數中自帶一個鍵,並在伺服器端去重。

一個在用戶端失敗的工具呼叫,在伺服器端仍可能已經完成。如果呼叫方重試,工具就會執行兩次。本文記錄了四種重現方式、相關的規範文字,以及參考實作中冪等性支援的現況。

摘要

# 情境 傳輸方式 重試發起方 呼叫次數 實際效果
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 修訂版改了什麼

目前修訂版中有兩組變更與此相關。

重試在兩項功能中成為一般的控制流程。多輪往返請求會在用戶端「於原始請求的重試中」提供 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 一位維護者以「AI 產生的 PR,未揭露亦無脈絡」的評論關閉了該 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,伺服器存活下來,第一次嘗試成功,測試框架於是重試了一個成功的呼叫。它回報了兩次效果,卻什麼也沒證明。請以 node --import tsx server.ts 啟動,讓子行程就是伺服器,並在計算任何數字之前先斷言第一次嘗試以 -32000 失敗。

什麼能解決這個問題

在 tools/call 上加一個去重鍵,也就是 SEP-3182 所提議的。或者,提供一種方式讓用戶端能向伺服器詢問某個回應遺失的請求究竟怎麼了。

這兩者今日都不存在。在其中之一出現之前,需要工具呼叫只發生一次的應用程式必須在工具參數中自帶一個鍵並在伺服器端去重,因為協定不會替你攜帶它。


資源: