# MCP 架构漂移如何破坏长期运行的代理

长期运行的代理会让一个错误假设变得代价高昂：会话开始时加载的工具描述，在整个运行期间都不会改变。现实往往并非如此。代理还在根据昨天的工具形状制定计划时，服务器可能已经部署了新版本、启用了特定账户的能力、替换了上游 API，或修正了结果契约。

这就是 MCP 架构漂移。它并不是什么罕见的协议边界情况，而是客户端已经形成计划、服务器却已经继续前进时发生的契约变更。如果你只在部署后测试新连接，测到的只是简单场景，真正危险的场景仍未覆盖。

Model Context Protocol 为服务器提供了宣布工具列表变更的方式。但它不会把旧的客户端缓存变成新契约，也不会修复模型已经提出的工具调用，更不会判断旧请求是否仍然安全。这些都属于工程决策。应当有意识地做出决定，并在会话仍然存活时进行测试。

## 工具描述属于会话状态的一部分

工具架构是一种可执行上下文。代理会使用名称、描述、输入架构、注解，有时还会使用输出架构，来决定要请求什么操作。许多客户端还会把 `tools/list` 解析为本地结构，编译验证器，或将精简后的工具描述放进模型上下文。服务器部署另一个版本后，这些副本不会自动改变。

即使线路协议完全按照设计工作，这一点仍然重要。假设客户端开始时拿到的是这个工具：

```json
{
  "name": "deploy_preview",
  "description": "Deploy the current branch to a preview environment.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "branch": { "type": "string" }
    },
    "required": ["branch"],
    "additionalProperties": false
  }
}
```

一小时后，服务器修改了这个操作，要求明确提供 `region` 字段。新连接的客户端会看到新架构，也能提供该字段。旧客户端仍然认为 `{"branch":"fix-login"}` 是完整请求。

这里有三种不同的状态，团队却经常把它们混为一谈：

1. **已公布的架构**，指服务器现在从 `tools/list` 返回的内容。
2. **客户端架构快照**，指某个客户端上次列出工具时保留下来的内容。
3. **执行契约**，指服务器收到 `tools/call` 时会接受并执行的内容。

服务器可以立即更新第一种状态，却不能假定第二种状态已经更新。至于第三种状态，则必须选择如何处理。

把这称为缓存失效问题并不完整。缓存失效听起来像是显示数据过时了。工具架构还可能定义目标账户、写入范围、确认字段和结果含义。当代理持有旧版本时，错配可能导致任务失败、重复重试，或者让请求表达出超出模型原本意图的含义。

最安全的默认做法很简单：除非接受旧输入会使操作不安全，否则在过渡窗口内，让已部署工具的输入契约保持向后兼容。如果安全性与兼容性发生冲突，就清楚地拒绝旧形状，并要求重新做出决定。

## `tools/list_changed` 会宣布变更，但不会完成同步

MCP 规范为工具列表发生变化的服务器定义了 `notifications/tools/list_changed`。服务器发送通知，客户端可以用 `tools/list` 刷新工具。规范还规定，服务器可以在初始化时协商的工具能力中声明是否支持列表变更通知。

这很有用，但「可以」二字包含了很多前提。通知没有响应。服务器仅凭通知无法知道客户端是否收到、是否刷新、工具缓存是否更新，或者模型是否已经根据旧描述拟好了调用。

应把通知当作失效信号，而不是同步屏障。

能够妥善处理漂移的客户端，在收到通知后应完成四件事：

- 再次请求 `tools/list`，并以原子方式替换相应的本地定义。
- 保留旧快照一段时间，以便将已经规划好的调用关联到生成它的架构。
- 发送排队调用前，使用刷新后的架构重新验证。
- 如果调用来自旧上下文且已经无法执行，就向模型返回可修复的错误。

第三点正是客户端经常偷懒的地方。它们刷新了可见的工具列表，却允许已经排队的调用带着旧参数对象发出。这样就形成了竞态：界面说的是一件事，代理发送的是另一件事，服务器只能负责收拾错配。

服务器也需要遵守相应的纪律。工具发生变化时，应在新的 `tools/list` 响应已经准备好之后再发送通知。不要先宣布新契约，却让旧进程在任意时长内继续处理调用。如果部署拓扑可能导致这种情况，就在服务器响应中明确加入契约版本，并拒绝落到行为不兼容工作进程上的调用。

通知也无法解决不支持通知的客户端、在事件期间断开连接的客户端，或经过拥有自身缓存的中间层发出的调用。`tools/call` 层面的兼容性仍然必不可少。如果服务器只有在每个客户端都完美响应通知时才能工作，那它就无法在生产环境中可靠工作。

## 输入变更会带来不同类型的破坏

新增字段并不只有一种情况。风险取决于该字段改变的是验证、含义，还是权限。

新增可选的显示偏好通常是安全的。旧调用者会省略它，服务器则采用稳定的默认值。如果省略可选筛选条件时返回的结果与以前相同，新增可选筛选条件也可能是安全的。

为 `deploy_preview` 新增必填的 `region` 就不同了。旧调用者不再满足验证器的要求。你可以拒绝请求，也可以提供默认值。第一种做法会中断代理，但能如实表达契约变化。第二种做法只有在该默认值一直都是这个仓库预期的区域，并且不会把部署导向更敏感的环境时才可以接受。

改变字段含义比新增必填字段更糟糕。假设工具原本接受人类可读的项目标识 `project`。服务器后来决定，`project` 应该改成不透明的组织标识符。旧代理可能仍发送 `payments`，服务器则可能在意外的命名空间中解析这个字符串。验证通过了，请求也成功了，但执行的操作是错误的。这种语义破坏比干净的验证错误更加危险。

删除输入字段也需要同样谨慎。JSON Schema 的 `additionalProperties: false` 会让破坏变得明显。旧客户端发送以前有效的参数，然后收到失败。如果服务器静默忽略已删除字段，调用可能成功，却采用了与模型预期不同的解释。

「对接受的内容要宽容」这条流行建议并不适合操作型工具。它之所以流行，是因为能让集成在混乱的变更中继续运行。对于只读的格式偏好，这种容忍可能没有危害。但对于带凭据的 HTTP 请求、SSH 命令、部署、删除或支付操作，宽松解析会把含义不明确的请求变成服务器端的猜测。

只有在能够精确说明兼容适配器行为时，才使用它。例如：

```ts
function normalizeDeployArgs(raw: unknown) {
  if (!isPlainObject(raw)) {
    throw executionError("Expected an object for deploy_preview.");
  }

  if (typeof raw.branch !== "string" || raw.branch.length === 0) {
    throw executionError("The branch field must be a non-empty string.");
  }

  if (raw.region === undefined) {
    return { branch: raw.branch, region: "us-east-preview", schemaRevision: 1 };
  }

  if (raw.region !== "us-east-preview" && raw.region !== "eu-preview") {
    throw executionError("region must be us-east-preview or eu-preview.");
  }

  return { branch: raw.branch, region: raw.region, schemaRevision: 2 };
}
```

这个适配器有一个可接受的特性：旧请求产生的预览目标与过去相同。如果 `us-east-preview` 只是账户所有权发生变化后方便猜出的默认值，那它就不可接受。

对于不兼容的变更，应返回代理可以利用的错误。说明服务器期望的工具版本，指出缺少或已废弃的字段，并要求客户端刷新工具。不要只返回「输入无效」这种模糊消息。模型会用细微变化反复重试模糊错误。清晰的错误才有机会被修复。

## 结果形状的变化可能污染下一步决策

团队会关注输入验证，因为错误请求会在服务器处停止。对于结果变化却常常不够重视，因为操作已经完成。对代理来说，情况恰恰相反。结果往往是下一次调用所依据的证据。

假设原来的 `create_issue` 结果是：

```json
{
  "issue": {
    "id": "I-482",
    "url": "https://tracker.example/issues/I-482",
    "state": "open"
  }
}
```

代理可能提取 `issue.id`，将它保存到工作记忆中，之后再用该标识调用 `add_comment`。如果修改后的服务器把 `id` 重命名为 `issueId`，把结果包在 `data` 下，或将 `state` 从字符串改成对象，代理的下一步操作就可能在远离原始调用的地方失败。更糟的是，纯文本回退内容可能仍包含一句看起来合理的话，模型于是从这段话中自行猜出一个标识符。

MCP 工具结果可以同时包含供模型使用的内容和供程序处理的结构化内容。如果你发布了输出架构，就应把结构化输出作为权威的机器契约。文本应简洁，方便人阅读记录，但不要指望客户端能可靠地从文本中抓取数据。

这里与 JSON Schema 2020-12 相关的 MCP 架构工作很重要。协议后续的架构指导明确了方言，输出架构也可以描述不局限于对象形式子集的 JSON。这提高了表达能力，却不意味着可以随意重塑正在使用的结果。客户端可能用特定草案、生成的类型或解码器验证结果，而这些组件可能无法容忍你新增的联合类型或数组形式。

结果演进可以遵循以下规则：

1. 先新增字段，再考虑重命名或删除字段。
2. 保持字段含义稳定，尤其是标识符、状态值和时间戳。
3. 当必须让多种解释并存时，在结构化输出中加入 `schema_revision` 或 `result_version` 字段。
4. 执行失败时返回完整的结构化错误对象，不要把成功结果改成无结构的道歉文本。
5. 只有在结束旧会话或完成公开的迁移窗口后，才移除旧形状。

结果版本字段不是装饰。它能让客户端区分「服务器返回了不完整的旧响应」和「服务器返回了一个新响应，只是缺少可选字段」。当代理要决定重试、询问用户，还是继续执行重要操作时，这一区分非常关键。

不要因为可以就把每个输出都包装成带版本的信封。只有在多个独立部署的消费者需要知道不同解释时，才加入版本标记。对于小型私有服务器和单一捆绑客户端，增量且稳定的形状可能已经足够。对于由多个代理宿主、工作进程和插件共享的工具，明确的版本信息能在事故期间省下数天的排查时间。

## 值得测试的失败场景，是旧计划遇到新服务器

新客户端连接新服务器，只能说明新架构有效，无法说明漂移是否可处理。你需要让客户端先保存快照，服务器发生变化，然后测试源自该快照的调用。

围绕两个服务器夹具构建测试。夹具 A 发布旧工具定义。夹具 B 发布新定义，并决定如何处理旧参数。客户端在切换期间保持连接。如果服务器不能不重启就改变行为，可以在工具注册表后放置一个确定性的测试开关，不要试图让部署系统重现这种时序。

下面是最小的有用记录：

```text
1. Client initializes and receives tools.listChanged capability.
2. Client calls tools/list and stores deploy_preview revision 1.
3. Client prepares arguments: {"branch":"fix-login"}.
4. Server switches to revision 2, where region is required for new clients.
5. Server emits notifications/tools/list_changed.
6. Client sends the already prepared revision 1 call.
7. Client refreshes tools/list.
8. Client retries only if its repair policy permits it.
9. Client calls revision 2 with {"branch":"fix-login","region":"us-east-preview"}.
```

测试不应只检查成功或失败。捕获实际的 JSON-RPC 消息、服务器规范化后的参数、外部效果桩调用，以及客户端事件日志。服务器即使返回了整洁的错误，却已经启动外部部署，也应判定测试失败。

使用一个带追加写入请求日志的虚假下游服务。它应记录方法、路径、与授权有关的请求头、请求体以及测试关联 ID。然后断言：在本应失败并安全停止时，过时调用没有向下游发出请求。

下面的简表能让预期行为更容易审查：

| 漂移事件 | 旧客户端行为 | 服务器行为 | 下游影响 |
| --- | --- | --- | --- |
| 新增可选 `label` | 不带 `label` 调用 | 应用原有默认值 | 一次预期请求 |
| 新增必填 `region`，且存在安全的历史默认值 | 不带 `region` 调用 | 规范化为稳定默认值 | 一次预期请求 |
| 新增必填审批范围 | 不带范围调用 | 返回可修复的执行错误 | 不发送请求 |
| 改变 `project` 的语义 | 使用旧的 `project` 调用 | 作为不兼容请求拒绝 | 不发送请求 |
| 新增输出字段 | 解析原有字段 | 返回旧字段及新字段 | 不产生额外操作 |
| 删除输出标识符 | 尝试下一次依赖调用 | 客户端停止并报告契约错误 | 不发送依赖请求 |

不要只测试顺利重试。代理很擅长重试，这正是它们可能放大错误迁移的原因。还要测试连续发送过时调用、通知在调用进入队列后才到达、刷新失败，以及客户端在过渡期间重新连接。

最棘手的情况是正在执行的工具调用。服务器应让每次调用都在一个完整一致的契约版本下执行。不要在版本 1 下开始验证，重新加载配置后，再在版本 2 下构建下游请求。调用被接纳时就应快照保存处理器配置。如果操作的持续时间足以让目标契约本身发生变化，就暴露一个持久化任务，或在不可逆阶段之前拒绝调用。不要在一次操作中拼接两个版本。

## 客户端修复逻辑需要明确的重试边界

服务器拒绝过时请求时，客户端可以刷新、要求模型修复参数、使用映射后的请求重试，或停止并请求用户输入。正确选择取决于变更影响的是语法，还是操作权限。

只有同时满足以下条件时，才应自动刷新并重试：

- 服务器明确指出架构版本已过时或缺少字段。
- 刷新后的工具定义提供了明确且非敏感的默认值，或确定性的映射。
- 原始操作仍处于相同的目标和权限边界内。
- 第一次尝试没有产生外部副作用。

其他情况都需要重新做决定。如果版本新增了 `environment`、`account_id`、`repository`、`host`、`user` 或确认文本，自动重试可能扩大或改变操作目标。即使模型能够推断出一个可能的答案，也应取得新上下文或人工批准。

要把请求身份与重试身份分开。如果工具调用可能已经到达外部系统，但客户端没有收到响应，客户端就不能在刷新架构后盲目重新发送。下游 API 支持幂等令牌时，应使用它。对于没有通用幂等机制的 SSH，应设计命令，让重复执行要么安全，要么可被检测。架构迁移不是发现超时会造成重复工作的好时机。

好的客户端错误应向模型提供信息，但不能给它一个错误指令。例如：

```json
{
  "isError": true,
  "content": [
    {
      "type": "text",
      "text": "deploy_preview rejected this request because its input contract changed. Refresh tools before retrying. The current schema requires branch and region. No deployment was started."
    }
  ],
  "structuredContent": {
    "error_code": "STALE_TOOL_SCHEMA",
    "tool": "deploy_preview",
    "required_action": "refresh_tools",
    "side_effect_started": false,
    "current_revision": 2
  }
}
```

错误信封的具体形式由你设计，但以下事实不可省略：说明是否已经产生效果，说明刷新是否有帮助，如果服务器提供版本则说明当前版本。模型可以利用这些事实，泛化的传输错误却做不到。

不要把架构验证问题标记为传输失败。MCP 规范区分协议层失败和工具执行失败，当前指导也倾向于将无效工具输入作为工具执行错误返回，让模型有机会自行纠正。应使用这一差异。未知的 JSON-RPC 方法，与已知工具拒绝过时参数，是两种不同的事件。

## 安全审查必须覆盖含义，而不只是秘密

当旧描述通过默认值、重命名字段、新增范围和过度积极的服务器适配器，含蓄地授权了一个更新后的操作时，架构漂移就会变成安全问题。

假设工具原本叫 `run_report`，参数是 `{"team":"sales"}`。服务器后来改为接受一个 `target` 字符串，它可以指向团队、已保存报告或原始查询。旧代理仍然发送 `team`。如果适配器把它转换成 `target: "sales"`，它到底授权了什么？是一个团队、名为 sales 的报告，还是一个查询别名？服务器在操作边界制造了歧义。应拒绝这种请求，并发布独立工具或明确的迁移路径。

凭据会进一步提高风险。直接持有 API 密钥的代理，可能在自身进程和日志中同时处理架构修复与秘密信息，这会给过时计划造成更多伤害空间。Sallyport 将 HTTP 和 SSH 凭据保存在加密保险库中，并执行操作，而不是把凭据交给代理。这种隔离本身不会让变化后的工具契约变得安全，但能让实际请求、授权点和审计记录更容易检查。

批准必须关联到当前将要发生的具体操作，而不是某个记忆中的工具描述。如果工具从固定主机变成可灵活选择主机，基于较早代码身份的会话级批准不足以覆盖新目标。应要求对敏感操作进行新的逐次调用批准，或使用新工具名称，让权限扩大变得明显。

这也是审计记录发挥作用的地方。记录工具名称、可用时记录声明的架构版本、收到的原始参数、实际使用的规范化参数、服务器构建版本或处理器版本、审批决定以及下游目标。不要用规范化参数覆盖原始参数。发生事故时，你需要知道客户端是否发送了旧形状、适配器是否修改了它，以及外部请求是否符合适配器的承诺。

Sallyport 的 Activity 日志和 Sessions 日志很好地展示了如何把一次代理运行与各次外部调用分开。对于漂移测试，你需要同时查看两个视角：一条记录说明这次运行已获授权，另一条记录说明每个 HTTP 或 SSH 操作是否真正离开了机器。

## 使用兼容窗口，然后有意识地移除它们

向后兼容应当有结束日期。这个日期可以绑定到发布节奏或会话生命周期，而不一定是日历上的某一天。否则每个过时的输入映射器都会永久存在，服务器也会变成一座没人敢安全修改的假设博物馆。

先对变更分类。

增量变更会让旧调用继续有效，并保留原有含义。保留相同的工具名称，宣布列表变更，并在活动会话逐步结束期间同时接受两种形状。

受限迁移会改变语法，但存在安全且确定性的映射。只有在能够彻底测试映射器并记录其使用情况时，才保留原工具名称。向新客户端提供当前架构；只在短暂窗口内接受旧输入。

语义或权限变更需要新的工具名称。`deploy_preview` 和 `deploy_environment` 可以共用实现，但如果前者只能选择已知的预览目标，后者却可以选择生产环境，就不应共用契约。工具列表可能因此显得冗长，但代价仍低于让代理以为自己调用的是权限更窄的操作。

删除功能时应明确失败。返回一个执行错误，说明替代工具，或说明该能力已经消失。不要保留一个什么也不做的工具名称。对自动化工作来说，静默成功十分危险，因为代理会记录任务完成，而预期效果根本没有发生。

用遥测决定何时删除适配器，但不要只统计总体成功次数。分别统计每个旧版本被规范化的调用、被拒绝的过时调用、客户端自动修复次数，以及需要人工介入的调用。旧输入数量很少也可能很重要，尤其当它们来自运行时间最长或权限最高的代理任务时。

删除前，反向运行漂移测试：让旧版本客户端启动，在兼容窗口之后更新服务器，并确认失败信息清楚、没有副作用，而且可以通过重新连接或刷新工具恢复。干净的中断胜过安静的重新解释。

## 在用户发现之前拦截漂移的发布门禁

为每个能够执行操作的 MCP 服务器，把架构漂移纳入发布门禁。第一天不必建立庞大的测试矩阵，但每一类契约变更都应有一个严谨的夹具。

对于每个发生变化的工具，在拉取请求或发布评审中回答以下问题：

1. 现有会话还能提交之前有效的参数吗？
2. 如果可以，这些参数是否仍然准确表达过去相同的操作含义？
3. 如果不可以，拒绝是否发生在任何外部效果之前？
4. 服务器是否只在替换后的列表可用之后发送 `notifications/tools/list_changed`？
5. 客户端能否解释修复路径，而不会自行臆造缺失的权限？

接着让答案真正成为可执行的测试。把新旧 `tools/list` 夹具放在测试旁边。使用旧客户端快照运行完整序列。验证下游请求，而不只是 MCP 响应。迁移上线后保留回归夹具，因为下一次重构可能会删除兼容分支，而没人记得它为什么存在。

最值得添加的第一个测试非常小：列出一个工具，修改一个必填参数，发起旧调用，并证明服务器要么保留旧的安全行为，要么什么也不执行。这个测试会迫使团队回答部署中最常被回避的问题：当你在一个正在运行的代理脚下修改工具时，它手中的旧计划究竟被允许代表什么？
