简而言之

  • 一次在客户端失败的 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 的使用方

从 2025-03-26 修订版起,MCP 就在工具标注中携带 idempotentHint。在两套参考实现中搜索读取它的代码:

TypeScript,@modelcontextprotocol/sdk 1.30.0。 只有一处运行时出现,即 dist/esm/types.js 中的一个 Zod schema 字段。其他所有出现都在 .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 的一个字段声明。

两套实现中都没有使用方。这个标注被传输、被接收,并在 tool.metadata 中暴露给智能体层,正如场景 4 所示。

客户端侧的幂等性:@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 所提议的。或者,提供一种机制让客户端向服务端查询某个丢失了响应的请求最终结果如何。

今天两者都不存在。在其中之一出现之前,需要工具调用只发生一次的应用只能在工具参数里自带一个键,并在服务端去重,因为协议不会替它携带。


资源: