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

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

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

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

## 重连并不授权再次执行

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

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

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

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

请遵循这条规则：

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

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

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

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

看下面这两次调用：

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

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

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

再看两个 ID 不同的调用：

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

```json
{"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 操作，它可以是这样的结构：

```json
{
  "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 客户端的连接在它看到响应前就断开了。如果因为结果交付失败而将执行标为失败，那就是在记录错误事实。如果将它标为已完成，恢复代码就能据此处理：返回已知结果，或重新构造结果，而无需再次分发请求。

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

```json
{
  "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` 离线验证，这有助于确认事件发生后记录没有被悄悄改写。

## 将未知结果视为独立结果

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

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

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

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

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

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

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

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

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

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

```text
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 使用固定的判断阶梯：保险库锁定时拒绝操作；新的代理进程默认按会话获得授权；选定的凭据可以要求每次使用都审批。这些控制回答的是谁可以操作，执行记录仍然需要回答操作是否已经越过边界。

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

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

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

```http
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 传给脚本，让脚本在主机或共享存储中写入持久状态记录，并拒绝两次启动同一操作。如果无法修改命令或检查外部标记，就应将该命令归入不可逆操作，并在断开结果不确定后要求审核。

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

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

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

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

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

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

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

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

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

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

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

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