阅读需 8 分钟

重复的 MCP 工具调用需要执行身份

重复的 MCP 工具调用不能只靠重试解决。使用请求指纹和活动记录,区分连接重试与真正的第二次操作。

重复的 MCP 工具调用需要执行身份

重连是传输问题,第二次操作是执行问题。把两者混为一谈,并让客户端重试所有没有收到明确响应的请求,最终会让团队付出代价。

MCP 很容易让人忽略这一点,因为一次工具调用可能跨越多个边界:代理进程、MCP 传输层、操作网关,以及 HTTP API 或 SSH 目标。目标可能已经接受了任务,连接却在代理收到结果前断开。如果系统随后再次发送调用,目标看到的就是两个有效请求。它没有理由自行判断第二个请求是意外产生的。

解决办法不是增加重试次数,而是为每个请求的操作赋予执行身份,记录其生命周期,并根据这份记录决定是否重试。请求指纹告诉你调用方想做什么,活动记录告诉你系统是否已经开始或完成了这件事。两者缺一不可。

重连并不授权再次执行

连接流中断后,客户端重连只能证明通信失败,不能证明原来的工具调用失败。

这一区别听起来很明显,但在 MCP 客户端下面加入通用重试中间件后,问题就会出现。中间件看到超时、连接重置或缺少响应,却不知道 POST 请求执行的是读取、写入、远程命令,还是不可逆操作。它会再次发送字节,因为许多 HTTP 重试代码就是这样工作的。

对于 GET /repos/acme/api/branches 这样的读取操作,这可能还能接受。但对于 POST /paymentsDELETE /projects/atlas,或会修改生产主机的 SSH 命令,它可能造成第二个副作用。工具层事后只向模型返回一个结果,也无法修复这个问题。

MCP Streamable HTTP 传输规范明确允许客户端在流中断后使用 Last-Event-ID 恢复服务器到客户端的事件传递。这是用于恢复流中消息的机制,不会把第二个 JSON-RPC tools/call 请求变成同一次执行。TypeScript SDK 文档也将恢复令牌与请求路径分开,并允许客户端在 fetch 外层加入中间件。正因如此,团队应在代码中明确重试规则,而不是假设传输层会替自己处理好一切。

请遵循这条规则:

协议支持时,恢复响应流。只有当操作层能够确认它属于同一次执行时,才重新发起有副作用的操作。

最棘手的并不是明确失败的情况,而是服务器已经开始工作,响应却消失,客户端无法判断应该等待、恢复、查询状态,还是重试。设计必须让这种不确定性清晰可见。

JSON-RPC ID 标识消息,不标识持久操作

JSON-RPC 请求 ID 适合关联请求和响应,但不足以在重连、进程重启或不同代理运行实例之间去重操作。

看下面这两次调用:

{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}

它们可能是连接断开后发送了两次的同一个请求,也可能来自两个分别从 1 或 41 开始编号的客户端进程。即便在同一个进程中,实现缺陷也可能导致 ID 重用。除非将这个值与经过认证的调用方及特定协议会话绑定,否则它几乎说明不了什么。

再看两个 ID 不同的调用:

{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
{"jsonrpc":"2.0","id":42,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}

它们可能是客户端在传输重试时分配了新的 ID,也可能是代理在收到不确定结果后有意要求第二次部署。消息 ID 是证据,不是最终判断。

也不要反过来,把所有匹配的工具调用永久去重。两次部署同一版本可能无害,甚至可能是有意为之。两次创建同一个外部工单可能就是错误。两次轮换凭据可能导致服务无法登录。执行身份应保留多久,取决于操作类别。

一个实用模型会将以下三种标识分开:

  • 协议关联 ID:JSON-RPC ID,以及适用时的 MCP 会话或流上下文。
  • 执行 ID:服务器为一次已接受的工具操作尝试创建的标识。
  • 意图指纹:对请求效果计算出的稳定摘要,用于在协议关联信息发生变化后寻找之前的执行。

一旦分开这三者,日志就不会再假装自己能回答实际上无法回答的问题。

好的指纹应描述操作效果

请求指纹在传输方式改变时应保持不变,在请求效果改变时应发生变化。不要直接对原始 JSON 字节做哈希,然后把结果叫作指纹。原始 JSON 会因属性顺序、空白、可选默认值、请求 ID 和无意义的格式变化而不同。

先构建规范化的操作记录。对于 HTTP 操作,它可以是这样的结构:

{
  "actor": "signed-process:com.example.agent",
  "tool": "deploy_release",
  "channel": "http",
  "target": "deploy-api.internal.example/releases",
  "credential_ref": "deploy-service",
  "method": "POST",
  "arguments": {
    "service": "catalog",
    "version": "2026.07.22",
    "region": "us-east-1"
  },
  "intent_scope": "run:5f8097"
}

在计算摘要前,统一字段顺序,删除没有语义意义的字段,并规范化已知的等价表示。如果 region 的默认值是 us-east-1,就始终写入它,或者在目标会提供默认值时始终省略它。两种方式混用会造成错误匹配。

actor 字段很重要。两个不同的授权代理进程即使发送完全相同的参数,也可能代表两个独立的有意操作。credential_ref 同样重要。通过一个服务身份发出的请求,不一定等同于通过另一个服务身份发出的相同路径和请求体。对于 SSH,应包含主机身份、账户、命令,以及会影响行为的工作目录;在可以安全做到时,还应包含规范化后的命令表示。

不要把秘密信息放进规范化记录。绝不能将 bearer token、私钥或原始授权标头放入指纹输入。如果某个参数本身包含秘密信息,请将它替换为受保护的内部引用,或使用 HMAC 这样的带密钥结构计算指纹。对低熵秘密信息进行不加盐的普通哈希,会把审计存储变成猜测验证器。

“直接对请求做哈希”之所以常见,是因为这句话很简短。但它不适合控制操作。哈希只能证明某些字节被送进了一个函数,不能说明这些字节是否代表同一个操作者、同一个目标效果,或同一个重试窗口。

活动记录需要状态,而不是一行日志

好的活动记录应说明操作停在了哪里。如果只记录成功和失败,重连时恰恰会在最需要明确答案的时刻留下猜测空间。

至少为每个执行 ID 记录这些状态变化:

  1. 已接受:网关验证请求并分配执行 ID。
  2. 已授权:所需审批或会话授权允许执行操作。
  3. 已分发:网关将操作交给 HTTP 客户端或 SSH helper。
  4. 已观察到结果:收到目标响应、退出状态或明确的传递失败信息。
  5. 结果已交付:代理收到了工具结果,前提是传输层能够确认这一点。

第四和第五个状态必须分开。目标可能返回 HTTP 201,但 MCP 客户端的连接在它看到响应前就断开了。如果因为结果交付失败而将执行标为失败,那就是在记录错误事实。如果将它标为已完成,恢复代码就能据此处理:返回已知结果,或重新构造结果,而无需再次分发请求。

这是我希望在事件调查期间看到的记录形态:

{
  "execution_id": "act_01J4K8J7DX7V",
  "fingerprint": "hmac-sha256:4a1e...d90c",
  "tool": "deploy_release",
  "actor": "signed-process:com.example.agent",
  "target": "deploy-api.internal.example/releases",
  "state": "completed_result_not_delivered",
  "accepted_at": "2026-07-22T14:03:18Z",
  "dispatched_at": "2026-07-22T14:03:19Z",
  "completed_at": "2026-07-22T14:03:25Z",
  "target_status": 201,
  "result_reference": "result_01J4K8JFM2"
}

活动记录不必向每位操作人员公开完整的目标响应,但必须为网关提供足够的受保护细节,以便做出恢复决定,也要提供足够清晰的信息,让人理解发生了什么。

Sallyport 的 Activity journal 会记录单次调用,Sessions journal 则记录代理运行实例。在这类调查中,这种分离很有用:运行记录告诉你哪个代理进程存在,调用记录告诉你某次具体的外部操作是否越过了分发边界。它的审计链还可以使用 sp audit verify 离线验证,这有助于确认事件发生后记录没有被悄悄改写。

将未知结果视为独立结果

让操作经过 Sallyport
使用随附的 sp mcp shim,在 MCP 代理与目标之间加入操作网关。

大多数重复操作都始于一个只有两种结果的系统:成功和失败。网络操作需要第三种结果:未知。

未知不代表系统什么也没做,而是代表系统无法证明目标是否接受了操作。在任何字节离开进程前发生的超时,通常可以安全重试。请求体已经交给操作系统后发生的超时,则不是同一类事件。SSH 连接在远程 shell 启动命令后断开,情况更糟,因为远程命令可能在本地进程退出后继续运行。

在决定恢复方式前,先为每个工具操作分类:

操作类型示例结果未知后的默认处理
只读获取构建状态按普通限制重试
幂等写入将命名资源设为声明的状态使用相同的幂等身份重试
条件写入仅在版本匹配时更新查询状态,只有条件仍成立时才重试
不可逆操作发送付款、撤销访问权限、轮换凭据停止并请求明确审核
远程命令通过 SSH 执行迁移查询持久标记,或停止并请求审核

API 的 HTTP 方法并不能决定这张表。PUT 通常被认为是幂等的,但设计不佳的端点可能每次收到请求都发送通知、触发构建或追加审计事件。只要 API 支持幂等键,POST 也可以安全重复。应检查目标实际提供的契约。

对于长时间运行的远程命令,应在执行工作前添加持久标记。迁移命令可以先创建一条带执行 ID 的记录,开始工作时更新它,通过验证后再标记完成。重连时,先查询这个标记,再决定是否重新分发。没有标记时,“大概没有运行”不能算恢复策略。

在有限的意图范围内匹配重试

单独使用指纹会错误匹配合法操作。应将它限制在一个合理的时间和上下文范围内,在这个范围内,重复请求才有可能是重试。

最简单的范围是一次代理运行。如果同一个已签名进程在第一次结果尚未确定时再次发送相同操作,可以把第二个请求视为潜在重试。如果另一个进程在数小时后发送同一操作,除非该操作本身提供持久幂等键,否则应将它视为新的意图。

一个好的匹配规则大致如下:

if prior.fingerprint == incoming.fingerprint
  and prior.actor == incoming.actor
  and prior.intent_scope == incoming.intent_scope
  and prior.state in {accepted, authorized, dispatched, completed_result_not_delivered}:
    recover_or_attach_to(prior.execution_id)
else:
    create_new_execution()

recover_or_attach_to 不能盲目返回成功,它的行为取决于之前的状态。

如果之前的执行已接受但尚未分发,网关可以继续这次执行。如果它已经分发但结果未知,网关应查询目标状态端点、幂等能力或持久标记。如果它已完成但结果交付失败,应返回已存储的结果引用。如果它被授权流程拒绝,应返回该拒绝,而不是因为同一个有歧义的重试请求重新创建审批流程。

范围必须与操作相匹配。对于超时的 API 请求,五分钟可能合理,但对于持续一小时的软件部署就不够。凭据轮换可能需要持久指纹,直到确认哪个凭据处于活动状态。不要因为全局 TTL 容易配置,就给所有操作使用同一个 TTL。应按操作制定保留和恢复规则。

审批是证据,不是幂等机制

让代理始终不持有凭据
Sallyport 自己执行需要凭据的请求并返回结果,代理不会接触密钥。

人工审批可以证明某个进程获准尝试操作,但不能证明之前的尝试是否已经发生。

对于每次敏感调用都会弹出提示的系统,这一点尤其重要。假设代理请求轮换生产凭据,某人批准了请求。网关分发请求后,客户端断开。代理重连并生成相同的工具调用。再次提示审批人员会制造一种误导性的选择。操作人员看到熟悉的请求,可能再次批准,但真正需要回答的问题是第一次轮换是否已经完成。

逐次调用审批仍然有价值,它控制的是使用当下的授权。请将它与重复处理分开:

  • 授权决定当前调用方是否可以发起执行。
  • 指纹决定传入请求是否对应已有执行。
  • 活动记录决定已有执行能否继续、恢复,还是必须审核。

当重试对应一个待处理执行时,应展示原始活动记录,而不是像什么都没发生过一样重新发起审批。审核人员应看到目标、第一次分发时间、已知结果,以及网关没有再次发送操作的原因。

Sallyport 使用固定的判断阶梯:保险库锁定时拒绝操作;新的代理进程默认按会话获得授权;选定的凭据可以要求每次使用都审批。这些控制回答的是谁可以操作,执行记录仍然需要回答操作是否已经越过边界。

HTTP 幂等键只能解决部分问题

如果上游 API 接受幂等键,就使用它。为一次执行创建一个稳定值,保存目标响应,并且只有在恢复同一次执行时才复用这个值。

例如,网关可以在分发前创建执行 ID,并将它映射到 API 期望的请求头:

POST /v1/releases HTTP/1.1
Host: deploy-api.internal.example
Idempotency-Key: act_01J4K8J7DX7V
Content-Type: application/json

{"service":"catalog","version":"2026.07.22","region":"us-east-1"}

API 必须定义重复收到该请求头时的行为。最理想的行为是:对于相同语义的请求返回原始结果;如果不同请求试图复用同一个值,则拒绝它。如果 API 在同一个键下悄悄接受变化后的请求体,网关就无法从重放中安全推断任何信息。

如果指纹可能跨越多个有意操作而持续存在,就不要直接把指纹用作外部幂等键。执行 ID 对一次已接受的尝试来说是唯一的,指纹则用于定位可能相关的尝试。两者承担不同职责。

HTTP 幂等性本身对 SSH 没有帮助。你需要远程协议。一种安全模式是将生成的执行 ID 传给脚本,让脚本在主机或共享存储中写入持久状态记录,并拒绝两次启动同一操作。如果无法修改命令或检查外部标记,就应将该命令归入不可逆操作,并在断开结果不确定后要求审核。

调查事件顺序,而不是最终数量

控制 HTTP 和 SSH 操作
HTTP 凭据注入和无状态的 sp-ssh helper 会让目标访问始终留在应用内部。

两条参数相同的活动记录并不能证明发生了重复操作。应从事件顺序开始,跟踪第一次调用是否到达分发边界。

一次真正的调查应按顺序回答这些问题:

  1. 是一个代理进程,还是两个不同的进程发出了调用?
  2. 第一次调用是否获得授权并进入分发阶段?
  3. 网关是否从目标收到响应或退出状态?
  4. 是否在目标完成后,结果传递发生了失败?
  5. 第二次调用是否复用了原执行 ID,携带了幂等键,还是创建了新的尝试?

这样的顺序可以避免一个常见的错误结论:“日志里有两次调用,所以代理操作了两次。”你可能发现网关记录了一次已完成执行,另一次只是连接到它的客户端重试。也可能发现两个拥有不同规划上下文的独立授权进程都发起了操作。这两种情况需要不同的修复方案。

让活动存储只追加,或以其他方式确保记录具备防篡改能力。重复操作调查往往发生在造成重大损失之后,那时有人可能希望得到一个比系统实际能支持的更干净的故事。哈希链记录不能让原始决定变正确,但能让后续重建更难被操纵。

也不要向代理隐藏歧义。应返回明确说明之前的执行仍待验证,或已经完成但结果传递被中断的结果。看到伪造失败的模型会再次尝试;看到清晰未知状态的模型可以查询状态、请求审核,或选择更安全的路径。

将重放行为写进每个工具契约

每个有副作用的工具都需要明确回答一个问题:调用方在分发后丢失响应时,会发生什么?

将答案写在工具定义旁边。说明操作是只读、可通过幂等身份重复、可通过状态查询恢复,还是在结果未知后禁止继续。说明哪些字段属于指纹,以及未完成的执行可以保持多长时间的可关联状态。如果没有人能把这些内容写下来,这个工具就还没有准备好用于自主操作。

与重复部署、重复账户、重复付款或第二次凭据轮换之后的清理工作相比,前期工程工作通常并不多。把执行 ID 加在目标调用之前。持久化分发前后的状态变化。保留结果引用。然后让重连代码在再次接触外部世界前先查询这份记录。

这才是应坚持的标准:传输中断可能打断对话,但不能悄悄把不确定性变成第二次操作。

常见问题

什么算是重复的 MCP 工具调用?

它指的是两个执行都到达目标后产生的重复副作用,而不只是日志里出现了两条消息。重复读取数据的 HTTP 请求可能只是麻烦,但重复转账、删除分支、轮换凭据或创建账户就需要完全不同的处理方式。先确认下游影响,再讨论客户端是否“真的打算”重试。

MCP 重连会让同一个工具调用执行两次吗?

有时会。服务器可能已经完成调用,但客户端在收到响应前丢失了连接,重连后又发送了相同请求。不过,外观相同的调用也可能来自重新考虑计划的代理、重启工作进程的管理器,或重复发出指令的人。

JSON-RPC 请求 ID 足够用于去重吗?

不够。JSON-RPC 请求 ID 只标识一次协议会话中的消息,无法在客户端重启或建立新连接后继续作为持久的执行身份。可以把它当作有用的线索,再结合请求指纹和操作记录进行判断。

MCP 请求指纹应包含哪些内容?

应包含经过授权的进程或主体、工具名称、规范化参数、目标身份、凭据身份,以及有限的时间范围或意图范围。不要加入 socket 编号、临时 SSE 事件 ID 或随机生成的请求 ID 等传输噪声。指纹应描述请求产生的效果,而不是描述请求经过的传输路径。

我应该自动重试失败的工具调用吗?

对于带有外部副作用的操作,除非目标支持幂等键,或你能证明第一次尝试尚未开始,否则不要自动重新执行。重连响应流和重新发送工具调用是两回事。安全的恢复路径通常是先恢复结果或检查活动记录。

调查重试时,活动记录应显示什么?

活动记录应包含请求指纹、执行尝试、授权上下文、时间戳、目标和结果状态。它必须区分“已接收”“已开始”“已完成”和“结果传递失败”。如果这些状态都压缩成一行日志,操作人员就无法判断是否真的发生了第二次操作。

代理何时应发送幂等键?

当接收方 API 支持幂等键时,请使用幂等键,并在同一意图的传输重试中保持不变。即使参数相同,也不要将它复用于之后有意发起的操作。如果 API 没有幂等机制,网关就需要自行维护待处理和已完成的执行记录。

如何区分重试和有意发起的第二次操作?

重连通常会改变连接相关的证据,例如进程启动时间、传输会话、请求 ID 或流状态。有意重复操作往往拥有新的规划上下文、新的授权决定、不同的参数,或与上一次结果之间存在有意义的时间间隔。但这些信号单独都不能定论,因此系统需要一套记录在案的判断规则。

可以只保存每个工具请求的哈希吗?

不行。摘要只是索引。应保存受保护的规范化表示,或保存足够的结构化字段,以便解释两个调用为何匹配,同时限制操作人员界面中的敏感值。如果指纹涵盖秘密信息,应使用秘密密钥派生指纹,这样日志读取者就无法根据摘要测试猜测值。

第一次调用的结果未知时应该怎么办?

应将它视为一次有歧义的执行,并对该操作类别停止自动重放。向审批人员展示之前的活动记录、已知结果以及确切的目标效果。快速重复操作的代价可能低于不可逆的重复操作,但它仍然说明设计存在需要修复的问题。

Sallyport

Sallyport 替你的 AI 智能体执行 API 调用和 SSH 命令。密钥留在你 Mac 上的本地密钥库里;每次运行由你批准,每个操作都落入一份密封的审计日志。

© 2026 Sallyport · 依据 Apache-2.0 开源 · Oleg Sotnikov