# 嵌套 MCP 请求：安全追踪每一次外部操作

代理可能调用一个工具，而这个工具又调用另一个工具，后者要求 helper 解析环境，最后发送 HTTP 请求或运行 SSH 命令。如果记录只停留在第一个工具名称，你得到的只是关于意图的故事，而不是进程之外实际发生了什么。

嵌套 MCP 请求需要沿着因果链一直追踪到每个使用凭据的外部操作。这意味着记录的内容要多于代理转录，也多于普通请求日志。你需要一张能够回答以下问题的图：哪个运行触发了这次出站调用？调用解析成了什么？谁批准了它？它是否真的执行了？

我见过团队把整齐的工具追踪当成控制措施有效的证明。直到一次事件要求他们找出修改生产资源的调用，追踪记录却只写着 `release_service`。友好的名称方便操作人员使用，审计证据需要记录具体操作。

## 真正重要的是外部操作记录

嵌套链中的最后一次调用往往带来实际后果，因此需要自己的持久记录，即使前面的每次调用都有 span。检查分支名称的工具可能没有风险，后续使用检查结果调用部署端点的 helper 则是另一回事。

把团队经常混为一谈的三件事分开：

- 工具调用是带参数执行某项命名能力的请求。
- trace span 描述一项工作单元，以及它在因果图中的位置。
- 外部操作是跨越信任边界的具体动作，例如携带注入凭据的 HTTP 请求，或在远程主机上执行 SSH 命令。

混淆这些概念会造成两种相反的问题。有些团队为每次内部函数调用都创建审计记录，日志很快被噪声淹没，没人能找出真正改变了什么的少数调用。另一些团队只记录最后一行成功信息，于是失去了说明哪个代理决策、查找和审批导致这次调用的链路。

为外部操作分配稳定的 `action_id`。把它连接到发起操作的 span，但不要把 span ID 当成它的身份。一个 span 可能覆盖本地准备、HTTP 尝试、重定向和响应解析，这些都是有用的细节，但实现变化后，动作记录仍应保持清晰。

动作记录必须描述解析后的目标。只记录 `environment=production` 或 `target=customer-api` 不够。应保存解析后的主机、端口、协议、请求方法或 SSH 命令，以及执行器选用的凭据引用。保存密钥引用，不要保存密钥值。

这个区别也会改变你判断成功的方式。包装工具可能因为任务已排队而返回 `ok`，底层随后却可能在建立连接前失败。分别记录包装 span 和外部操作的结果。两者不一致并不意味着其中一条记录错误。

## MCP 不会提供完整的调用图

MCP 为客户端和服务器提供发现及调用工具的协议，但不会要求每种实现公开内部调用图。主机可以编排多个服务器，服务器可以调用本地 helper，工具也可以触发在响应返回后继续运行的任务。审计设计必须明确处理这些形态。

Model Context Protocol 规范将 `tools/call` 描述为客户端向服务器请求调用指定工具并传入参数。这是接口契约，不是追踪契约。协议不会把本地函数调用自动变成可观察的子事件，也没有规定每个中间环节都必须保留的通用父级字段。

当团队说某个工具「调用了另一个 MCP 工具」时，这句话可能指真正的第二次协议调用，也可能指一个服务器调用了名称相近的库函数，还可能指代理主机收到结果、进行推理后发起全新的调用。最终的图可能相似，但信任边界不同。

在记录中把这些边分成不同类型：

- `protocol_call` 将 MCP 客户端请求连接到服务器工具调用。
- `local_call` 连接同一个受信任进程中的代码。
- `delegated_job` 将请求连接到稍后由其他 worker 执行的工作。
- `external_action` 将 span 连接到 HTTP 或 SSH 操作。

不要根据工具名称推断边类型，应在交接发生的地方记录。只有在那里，你才知道身份、凭据和取消规则是否跨入了另一个进程。

延迟任务需要特别处理。如果工具将任务排队后返回，应把原始 trace ID 和 root run ID 随任务载荷保存。worker 稍后运行时，为这次执行创建新的 span，并将它指回原始动作计划。不要假装 worker 一直处于原始请求中。它的执行时间、身份和授权状态都可能已经改变。

## 在每个信任边界创建标识符

只有当每个参与者都能把工作连接到同一条因果链，同时不会把伪造的历史当成事实时，追踪才有帮助。当请求进入你控制的组件时生成自己的标识符。除非上下文由经过认证的对等方提供，否则应把上游上下文视为不受信任的诊断输入。

W3C Trace Context 建议定义了 `traceparent` 标头，其中包含版本、32 个十六进制字符的 trace ID、16 个十六进制字符的 parent ID 以及标志位。OpenTelemetry 广泛使用这种格式。HTTP 或其他传输可以携带标头时，优先使用它，因为现有追踪工具能够理解。但不要把兼容性误认为审计模型。

对于基于 stdio 的 MCP，可能根本没有 HTTP 标头。可以把等效上下文放进应用信封，也可以在调度调用的进程中维护上下文。机制不如以下两点重要：每个子操作都必须知道自己的直接父级，接收组件必须记录是谁把上下文交给了它。

最小事件结构可以如下：

```json
{
  "event_id": "evt_01J8...",
  "time": "2025-03-08T14:32:11.214Z",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "parent_span_id": "b7ad6b7169203331",
  "root_run_id": "run_8d43",
  "edge_type": "external_action",
  "actor": {
    "kind": "agent_process",
    "identity": "signed-process-identity"
  },
  "action": {
    "action_id": "act_5f17",
    "channel": "http",
    "method": "POST",
    "host": "deploy.internal.example",
    "path_template": "/v1/releases/{name}",
    "credential_ref": "ops-deploy"
  },
  "outcome": {
    "state": "sent",
    "http_status": 202
  }
}
```

上面的时间戳只是结构示例，不是推荐的保留格式。在真实系统中，应使用日志验证器能够稳定解析的时钟格式。若原始端点和参数包含租户名称、仓库路径或个人数据，不要把它们发送到范围广泛的遥测导出中。模板加上单独保护的取证记录，通常足以让操作人员了解上下文，也能避免把敏感内容复制到每个仪表板。

接收服务不应因为代理提供了 `parent_span_id` 就直接信任它。应创建新的本地 span，记录收到的值，并附加 `upstream_context_source=authenticated_mcp_client` 或 `upstream_context_source=unverified_input` 等字段。这个小小的区别可以防止恶意调用者事后把自己的操作挂到一个无害的运行上。

## 记录解析结果，而不只是工具参数

代理请求的动作与执行器最终执行的动作，可能因模板、默认值、别名、重定向、环境查找和凭据选择而不同。记录必须保留两者，因为不一致之处往往正是危险设计藏身的地方。

例如，代理调用可能带有以下参数：

```json
{
  "tool": "publish_release",
  "arguments": {
    "environment": "prod",
    "release": "2025.03.08-rc2"
  }
}
```

工具可能把 `prod` 转换为基础 URL，选择一个凭据，将版本字符串转换为请求路径，并添加标头。最初的调用无法证明目标。完整链路应呈现如下变化：

1. 代理在运行 `run_8d43` 中调用 `publish_release`。
2. 工具将 `prod` 解析为某个明确且获准的端点，并选择凭据引用 `ops-deploy`。
3. 执行器在发送请求前立即记录 `act_5f17`。
4. 执行器将响应或传输错误记录到该动作上。
5. 包装工具返回引用 `act_5f17` 的结果，但不暴露凭据。

这条顺序为调查人员提供了从代理请求到网络操作的路径，也让审批人员在执行器发送请求前有具体内容可检查。

不要把完整的授权标头、cookie、私密命令或任意请求正文当成良好建模的替代品。人们常在压力下这样做，因为原始转储能解决眼前的调试问题，却会制造明天的凭据泄露。应改为记录凭据引用、标头名称、正文摘要、选定的非敏感字段，以及明确的脱敏状态。

SSH 也需要同样的纪律。如果 helper 后来展开主机别名、选择身份并构造远程命令，只记录 `ssh deploy` 就过于模糊。应记录解析后的主机和端口、账户名、身份引用、命令模板或命令摘要，以及退出状态。如果命令包含敏感数据，只有在有明确理由和保留规则时，才保留受保护的取证副本。

## 重试是另一次尝试，不是脚注

重试和扇出会把简单的树变成图。如果把它们塞进一个带最终 `success` 字段的 span，就会抹去解释重复副作用和部分失败所需的信息。

可能重试的操作应使用三个 ID：表示整个运行的 trace ID，表示预期逻辑操作的 action ID，以及表示每次实际发送的 attempt ID。每次尝试都应有自己的 span，动作记录则指向所有尝试。

假设工具发送版本请求，远程服务已经接受请求后本地却超时，于是工具重试。如果远程端点没有幂等处理，第二次请求可能会创建同一个版本两次。最终的 `200` 几乎说明不了问题。日志应显示第一次尝试已经到达网络，本地以超时结束，第二次尝试收到了响应。

只要目标支持幂等令牌，就应使用它。令牌应从逻辑 action ID 派生，而不是从临时 span ID 派生。这样，即使执行器重启或追踪库生成新的 span，远程服务仍能识别重复请求。

```text
trace_id=4bf92f... action_id=act_5f17 attempt=1 state=timeout bytes_sent=418
trace_id=4bf92f... action_id=act_5f17 attempt=2 state=completed http_status=200
```

`bytes_sent` 有助于区分请求传输前的连接失败与客户端写入数据后的超时，但它不能证明远程服务提交了什么。应在记录中明确这种不确定性。不要把第一次尝试标为 `failed`，以免让人误以为远程端什么也没做。

并行工作需要兄弟 span，而不是所有 worker 都会覆盖的共享可变追踪字段。如果一个规划工具调用四次环境检查，就创建四个子 span 和四个独立结果。如果两个分支导致外部操作，就分配两个 action ID。操作人员必须能够单独撤销或调查一个分支，而不会把它与兄弟分支混淆。

## 审批必须绑定解析后的操作

只有当审核人员能看到解析后即将发生的操作时，人工审批才有用。批准 `deploy` 这样的宽泛标签几乎没有判断价值，尤其是在嵌套工具稍后才选择实际主机和凭据的情况下。

应根据待执行的外部动作记录生成审批内容：通道、解析后的目标、操作形态、凭据引用、进程身份以及简洁的影响说明。审批决定中要保留 trace ID 和 action ID。执行器发送请求时，必须证明它使用的正是获批的动作，而不只是来自同一代理运行的一次审批。

不要因为链条中的第一个工具看起来无害，就审批整条链。链条可能从 `find_release` 开始，最终以修改主机的 SSH 命令结束。如果设计允许后续解析扩大链条能力，就应在出站边界要求重新决策。

这不意味着要让人审批工具内部的每一次字符串拼接，而是要把决策放在能力离开受信任执行路径的地方。这个边界能给人清晰的提示，也能让日志持久地连接同意与实际效果。

Sallyport 对支持的 HTTP 和 SSH 通道采用了这种方式：凭据保存在保险库中，由它自行执行操作，并可要求每次使用选定凭据时都进行审批。真正有用的集成点是最终的外部操作，而不是代理对嵌套 helper 意图的描述。

审批记录还需要过期和绑定规则。将决定绑定到 action ID、解析后的目标、凭据引用和参数摘要。如果这些内容在提示与执行之间发生任何变化，就丢弃决定并重新请求审批。会跟随代理进程进入无关调用的可复用审批令牌，最终会批准没人看过的操作。

## 审计日志需要顺序和验证

普通应用日志有助于诊断故障，但很少能告诉你是否有人删除了那条不方便的记录。对于自主代理执行的操作，应保留事件顺序，并让后续改动显而易见。

简单的哈希链会记录每个事件规范化序列化后的字节、前一事件的哈希以及新的事件哈希。验证器从第一条保留记录开始，重新计算每个链接。如果攻击者修改、插入或删除中间记录，验证会在受影响的位置失败。

规范化序列化很重要。如果一个进程对 JSON 字段排序而另一个不排序，即使记录内容等价，哈希也会不同。应定义字段顺序、Unicode 规范化、时间戳精度、缺失字段处理方式和字节编码，并在写入事件的每种语言中测试这些规则。大多数损坏的审计链都失败在这里，而不是哈希函数本身。

验证器应输出操作人员能够采取行动的结果：

```text
$ audit verify journal.events
records_checked: 1842
first_sequence: 91001
last_sequence: 92842
chain: valid
signature: valid
```

发现损坏时，应指出第一条错误序列，以及预期的前置哈希和实际观察到的前置哈希。不要尝试悄悄修复文件。修复会破坏关于问题经过的证据。

哈希链无法解决所有威胁。如果某人同时控制签名材料和存储，就可能重写一份完整的替代历史。将定期签名的检查点存放在写入者无法正常控制的位置，可以降低这种风险。写入事件和读取事件也应使用不同的访问路径。应准确描述已有的保护，不要把任何日志都称为不可变。

动作事件应靠近执行点保存。事后接收批次的后台收集器可能丢失证明出站调用发生过的唯一记录。执行器应在传输前追加一条「planned」记录，并在收到结果后立即追加完成记录。如果它在两条记录之间崩溃，这个不完整的记录对会说明该动作可能已经发出。

## 针对真实故障测试追踪设计

追踪设计只有在经受住格式错误的上下文、worker 丢失、重复发送和操作人员撤销后才可信。只展示正常路径的演示，恰好会隐藏代理行为异常时最重要的边界。

针对可丢弃的端点或主机运行一组小型测试。每个测试都应验证审计图，而不只是工具响应：

- 发送带伪造父级 ID 的嵌套调用，确认接收方将该上下文标记为未验证。
- 在请求字节离开客户端后强制超时，然后重试，确认两次尝试共享同一个 action ID。
- 将任务排队、重启 worker，确认恢复后的 span 能连接到原始运行，同时不会假装它一直连续运行。
- 在规划之后、执行之前撤销授权，确认没有外部动作记录进入 `sent` 状态。
- 并行发起两个兄弟调用，确认任一分支都不会采用另一个分支的父 span 或结果。

第三项测试能发现日志中的常见谎言。系统常常报告一个连续不间断的请求，但实际上 worker 可能在数小时后以不同的进程身份重启。这会掩盖真正执行操作的人。应将 worker 身份和启动时间作为新 span 的事实记录下来。

第四项测试能发现另一个常见故障。工具在解析最终目标前获得授权，然后用过期决定执行解析后的调用。测试应在审批后改变目标或凭据引用，并预期执行器拒绝该操作。

不要满足于追踪查看器的截图。导出原始记录，验证其链条，并针对父级 ID、action ID、目标、授权绑定和结果编写断言。查看器只是便利工具，事件流才是证据。

## 先建立边界，再补全调用图

从执行 HTTP 和 SSH 操作的代码开始。让它接受明确的追踪上下文，在发送前创建动作记录，在发送后创建结果记录，并在授权未绑定到解析后的操作时拒绝执行。

边界行为正确后，再为上层的嵌套工具调用添加检测。你会知道每个父级必须向下传递哪些信息，因为执行器会要求这些信息。反过来做，往往只能得到漂亮却没有可信叶节点的树。

把工具名称、规划笔记和模型推理与外部动作日志分开。它们能帮助人理解某次运行为何发生，却不能替代以下记录：哪个远程系统收到了请求、使用了哪个凭据引用、经过了哪次审批，以及结果是什么。当代理的回答听起来合理，而远程系统给出相反信息时，你需要的正是这条链。
