在客户端失败的工具调用,在服务端仍可能已经完成。如果调用方重试,工具就会执行两次。本文记录了这一现象的四种复现、相关的规范文本,以及参考实现中幂等性支持的现状。

概述

# 场景 传输方式 重试发起方 调用次数 副作用
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_VERSION2025-11-25SUPPORTED_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
pinglogging/setLevel 重大变更 5

重大变更 9 规定了替代行为:「响应流中断会丢失进行中的请求;客户端必须以新的请求 ID 作为新请求重新发出。」

该修订版保留了六个 _meta 键(clientCapabilitiesclientInfologLeveloauthprotocolVersionserverInfo)。其中没有一个是去重键或幂等键。规范中并未出现 exactly-onceat-least-oncededuplicate 这些字符串。

场景 1:超时与取消

客户端设置 1 秒超时,工具耗时 3 秒,随后进行一次重试。

[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 相同的超时与重试,但走 StreamableHTTPServerTransportStreamableHTTPClientTransport。一次调用产生两次副作用,请求 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。随后它对 ValueErrorTypeErrorRuntimeErrorArithmeticErrorLookupErrorNameError 等返回 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 的一个字段声明。

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

客户端幂等性:@cloudflare/think

有一个实现带有幂等性机制。@cloudflare/think 0.17.0 在 15 个文件中出现 idempotencyKey,在 4 个文件中有 ActionPendingError 类型,文档中写道「投递是至少一次的;请使用 idempotencyKeyoccurrenceKey 来实现你自己的持久化幂等」。它列举了残留的至少一次情形,以及其中哪些是可以接受的。

只搜索其运行时文件,排除 .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 所提议的方案。或者,提供一种方式让客户端能向服务端查询某个丢失了响应的请求最终结果如何。

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


资源: