# MCP 服务器重建会重置代理会话吗？

本地 MCP 服务器的重建并不是一个单一事件。它可能改变代理看到的工具目录，替换处理调用的代码，或改变某个人批准过的权限。把这些变化都当成一种笼统的“刷新”，会带来最严重的两类问题：代理使用过时的模式调用工具，或者新代码继承旧代码获得的权限。

实际规则很简单：广告中的契约发生变化时刷新元数据，能够执行的代码发生变化时替换可执行身份，批准的权限发生变化时重置授权。服务器重建后，这三件事经常同时发生，但它们并不是同一个概念。将它们分开的主机，可以让一个长期运行的代理任务继续进行，同时避免在不知不觉中扩大信任范围。

## 一次重建有三种独立影响

重建可以分别改变元数据、可执行身份和授权状态。应在架构中明确记录每一种变化，因为刷新工具列表并不能证明可执行文件没有变化，而批准对话框也无法告诉代理它缓存的模式已经过时。

**工具元数据**是通过 `tools/list` 暴露的描述，包括工具名称、描述、输入模式、提供时的输出模式以及注释。代理和主机会根据这些信息判断某次调用是否可用，以及如何构造调用。

**可执行身份**回答的是另一个问题：现在到底是哪段代码会接收这次调用？对于本地服务器，答案可能包括编译后的二进制文件、解释器入口文件、锁文件、运行时版本、容器镜像摘要或监管器配置。诸如 `payments-dev` 这样的显示名称不是身份，`/Users/dev/work/payments/dist/server.js` 这样的路径也不是身份。

**授权状态**记录了谁批准了什么，以及批准对象是谁。人可以批准代理进程执行一次任务，也可以逐次批准工具调用，或者批准连接到某台服务器。每种模型都需要明确的范围。如果获批主体发生变化，不能因为工具名称没有变化，就让批准自动延续。

团队之所以会混淆这些边界，是因为开发循环往往看起来像一个动作：

1. 修改处理器。
2. 构建或保存。
3. 重启本地服务器。
4. 继续代理会话。

这个循环实际改变了不止一件事。处理器可以增加副作用，但名称或模式不变。模式可以增加 `environment` 字段，而可执行文件的字节暂时不变。监管器可以替换子进程，而父进程仍保持打开的 stdio 连接。如果把这些都叫作“热重载”，就没有可靠规则来判断主机必须使什么失效。

在代码和运维讨论中使用三个词：**目录修订版**、**执行时期**和**批准范围**。目录修订版描述工具声称自己是什么。执行时期标识当前能够执行操作的代码。批准范围描述授权的确切对象和持续时间。名称本身并不重要，重要的是保持这些概念彼此分离。

## 广告契约发生变化时必须刷新元数据

只要重建改变了客户端可能用来选择、组合、展示或限制调用的任何信息，就应刷新工具元数据。这包括添加或删除工具，也包括修改现有工具的困难情况。

MCP Tools 规范为服务器提供了 `notifications/tools/list_changed`，用于通知客户端工具列表发生变化。这个机制很有用，但它有意保持简单：通知只表示列表发生了变化，不说明具体变更，也不说明客户端是否已经重新获取。发送该通知的服务器应预期客户端再次执行 `tools/list`。客户端也不应假设每个服务器或主机会立即响应，尤其是在缓存策略激进的不同客户端实现之间。

以下任何字段发生变化时都应刷新：

- 添加、删除或重命名工具。
- 描述发生变化，并影响预期用途或副作用。
- 输入模式发生变化，包括默认值、枚举值、必填字段、边界和对象结构。
- 输出模式发生变化，并且代理会根据该结果做出下一步决定。
- 注释、标题或展示字段发生变化，并影响主机审核。

最后一项需要判断。MCP 模式将注释描述为提示，并警告客户端不要根据不可信服务器发送的注释做出安全决定。这个警告是正确的。`readOnlyHint` 可以帮助主机展示调用，但不能把写操作变成读操作。如果本地服务器把 `readOnlyHint` 从 true 改成 false，应刷新目录，让人工界面保持真实，但不要让这个字段决定调用是否获得凭据。

模式变化值得比许多团队通常给予的更多关注。假设某个工具最初采用以下契约：

```json
{
  "name": "publish_preview",
  "inputSchema": {
    "type": "object",
    "required": ["branch"],
    "properties": {
      "branch": { "type": "string" }
    },
    "additionalProperties": false
  }
}
```

开发者重建后增加了以下可选字段：

```json
"target": {
  "type": "string",
  "enum": ["preview", "production"],
  "default": "preview"
}
```

这看起来可能没什么问题。然而，代理可能缓存了第一版模式，主机可能在没有 target 的情况下渲染批准卡片，而实现中还可能存在将省略值当作 `production` 的错误。正确做法不只是接受这个额外字段，而是刷新目录，让审核界面显示默认值，并针对运行中的代码测试省略参数的情况。

当可执行文件没有变化且现有批准范围仍然有效时，刷新元数据就够了。例如，服务器根据远程数据生成工具，并在同一份代码继续运行时发布新的列表。主机在服务器进程之外修正文档时也属于这种情况。不要因为修正描述中的拼写错误就重启会话。

但不要把元数据刷新当成执行边界的替代品。它告诉代理服务器声称自己能做什么，却不能告诉你服务器实际上会做什么。

## 可执行文件发生变化时需要新的身份

只要新的代码镜像、运行时配置或依赖集合能够处理调用，就应替换服务器身份。这包括重启后的二进制文件、重新加载的 JavaScript 模块、新的容器镜像、变化后的解释器环境，以及会将调用分派给另一个程序的包装脚本发生变化。

我最常见到的错误，是把信任绑定到命令行。主机保存的内容可能类似这样：

```text
server = "inventory"
command = "node"
args = ["/work/inventory/server.js"]
```

然后主机假定配置不变，服务器就一直相同。事实并非如此。重建后，`node` 进程可能加载不同的 `server.js`。该文件可能解析出不同的依赖树。即使文件逐字节完全相同，本地原生扩展、环境变量或 shell 包装器也可能把执行导向别处。

不需要寻找一种完美且通用的指纹，就能改善这一点。你需要的是一个明确说明范围，并采用保守失效规则的身份。对于本地开发服务器，可以在启动时创建执行记录：

```json
{
  "serverLabel": "inventory-local",
  "launchCommand": ["node", "/work/inventory/dist/server.js"],
  "entryDigest": "sha256:9e4c...71af",
  "lockfileDigest": "sha256:344b...0d19",
  "runtime": "node 22.14.0",
  "workingDirectory": "/work/inventory",
  "epoch": "01JQ7R4S4S0QJ7GZP1S2",
  "processId": 48192
}
```

摘要可以防止路径冒充身份。运行时和工作目录解释了主机如何解析入口点。即使重建产物碰巧产生相同摘要，执行时期也能让每次重启拥有唯一句柄。进程 ID 便于操作员调查，但不是身份，因为操作系统会重复使用它。

对于编译型服务器，在构建完成后计算实际可执行文件的哈希。对于脚本服务器，至少要计算入口文件和依赖锁文件的哈希。如果运行时会加载锁文件之外的代码，应加入已解析的包树，或者运行打包后的产物。如果 shell 脚本会启动真正的服务器，就同时计算脚本和子进程产物的哈希。忽略分派器的身份，只能证明错误的程序没有变化。

在 macOS 上，开发者可以在启动本地服务器前用下面的基本检查生成可重复的记录：

```sh
shasum -a 256 dist/server.js package-lock.json
```

典型输出是每行一个摘要和一个路径：

```text
9e4c1b2d8f3a6d...71af  dist/server.js
344bb81b6c09de...0d19  package-lock.json
```

不要等代理恢复工作后才计算摘要。应在进程启动时捕获它，将其绑定到服务器时期，并把它记录在每个授权决定旁边。否则，审计记录只能证明某个文件最终存在过。

即使重建改变了代码，却保留完全相同的工具目录，也仍然需要刷新身份。假设 `get_invoice` 的名称、模式、描述和只读注释都没有变化，但重建后的处理器会在返回同一张发票前，把每个发票编号发送到外部调试端点。元数据没有变化，权限边界却变了。

反过来也可能发生。运行中的服务器根据更新后的配置发布不同的租户专属工具，但可执行身份保持不变。此时刷新元数据，保留执行时期，并根据新目录是否超出批准范围来决定授权。

## 授权必须跟随执行时期

除非授权明确覆盖受信任的发布者和定义好的更新渠道，否则执行时期变化时必须使服务器授权失效。对于本地重建的服务器，这个例外通常比重新批准更麻烦。

有人认为应跨重建继续保留批准，否则开发体验会变得令人烦恼。这个理由有一定道理：每保存一次文件都要求重新批准，会让控制机制失去意义。但答案不是让批准永久有效，而是把它绑定到正确的单位。

实际的批准授权可以绑定以下字段：

```json
{
  "agentRun": "run_01JQ7R1",
  "serverLabel": "inventory-local",
  "executionEpoch": "01JQ7R4S4S0QJ7GZP1S2",
  "toolScope": ["inventory_lookup", "inventory_adjust"],
  "credentialScope": ["inventory-api-staging"],
  "issuedAt": "2026-07-22T14:31:08Z",
  "expiresWhen": "agent-run-ends"
}
```

这份授权表达了审核者能够理解的内容：本次代理运行可以通过这个确切的服务器实例，使用这些工具和指定的凭据范围。服务器重启后会获得新的时期。主机会在注入凭据前拒绝旧授权，如果调用仍然需要权限，就请求新的批准。

不要只把授权绑定到工具名称。工具名称只是接口约定。重建可能把 `inventory_adjust` 从“修改预发布环境中的库存数量”变成“调用由环境变量选择的生产端点”。即使代理继续调用同一个名称，主机也必须发现处理该名称的可执行代码已经改变。

同样的规则也适用于代理端包装器。如果代理通过一个会自行重建或改写的启动器启动本地 MCP 服务器，启动器必须纳入身份记录。恶意或损坏的包装器可以保留所有面向用户的工具名称，同时把调用重定向到另一个程序。

批准可以在某些代码更新后继续有效，但这需要的结构远多于本地构建通常具备的条件。例如，组织可以批准由指定发布者签名的产物，限制其部署渠道，要求经过验证的版本策略和固定凭据范围，并对权限变更采用单独的审核流程。这属于发布管理。不要假装文件监视器和未固定版本的开发目录能提供同等保证。

逐次调用批准会改变取舍。如果某个凭据或工具每次使用都需要确认，重建仍然必须创建新的身份以便审计，但当前操作已经有了新的人工决定。这并不意味着可以跳过模式刷新，只是减少了旧会话批准带来的风险。

Sallyport 对代理操作采用了相关做法：保险库网关在锁定时拒绝操作，每会话授权会识别新连接的代理进程，单个凭据设置还可以要求每次使用都获得批准。重要的设计经验是，人工决定需要清晰的对象和终点，而不是含糊地认为熟悉的标签仍然安全。

## Stdio 会把进程替换隐藏在一根管道后面

stdio MCP 连接尤其容易掩盖重建行为，因为连接属于进程，而不是文件。重建文件不会对正在运行的子进程产生影响，除非有机制替换或重新加载该进程。

在简单情况下，MCP 主机会启动子服务器，并持有它的标准输入和输出管道。子服务器在启动时加载代码。开发者再次运行构建，但现有子进程仍留在内存中。代理仍在与旧实现通信，尽管目录里已经有了新的产物。

这种情况下不需要协议刷新，因为实际的可执行身份没有变化。错误在于告诉开发者重建已经生效，实际却没有。开发工具应输出明确的状态行，例如：

```text
build complete: dist/server.js changed
running server unchanged: pid=48192 epoch=01JQ7R4S4S0QJ7GZP1S2
```

更复杂的情况使用文件监视器。父进程拥有 stdio 管道，监视文件，终止工作进程，再启动新的工作进程。父进程可以保持管道不断开，同时让调用悄悄开始到达新的子进程。从 MCP 客户端看来，连接从未关闭；从安全审核者看来，可执行身份在已有会话下发生了变化。

不要让这种替换保持不可见。可以选择以下设计之一：

1. 子进程重启时关闭 MCP 连接，迫使主机重新连接、初始化并批准新实例。
2. 保留外部连接，但让监管器在转发任何新调用前向主机发布新的执行时期。
3. 开发期间避免进程内重载，在主机控制下重启整个服务器进程。

第一种设计最清晰。第二种可以保留长期运行的代理上下文，但要求监管器与主机之间有可信边界。第三种只需多花几秒，却能避免数周解释为什么一次会话批准覆盖了未知代码。

不要依赖服务器自己的通知来证明它已被替换。新代码可能撒谎，遭到入侵的服务器更有理由宣布自己仍然是原来的身份。进程管理器、主机或凭据网关应观察启动过程并创建时期。如果这些层无法观察重启，就无法安全地区分重建和稳定运行的服务器。

## 工具列表通知是提示，不是交接

`notifications/tools/list_changed` 应促使主机获取目录，但它不会重启会话、重新协商能力，也不会携带授权决定。应根据通知真正表达的内容设计控制流程。

MCP 生命周期规范将初始化描述为客户端和服务器协商协议版本与能力的阶段。之后进入正常运行。服务器声明 `tools.listChanged`，只表示它能够通知客户端工具列表发生变化。它并不表示服务器可以在会话中途无后果地改写自身身份，也不要求主机把通知当作安全证明。

设计重载路径时，这个区别很重要。薄弱的实现会这样做：

```text
watcher rebuilds server
server sends tools/list_changed
client fetches tools/list
agent continues
```

演示中可以成功，但无法回答四个运维问题：

- 现有进程是否加载了重建后的代码？
- 是否有另一个进程接管了连接？
- 新代码是否拥有相同的已批准权限？
- 主机是否丢弃了代理根据旧模式准备的调用？

更强的路径会让不同层承担不同责任。构建系统报告产物，监管器报告进程替换，MCP 服务器报告目录变化，主机刷新代理可见的元数据，授权层将执行时期与授权进行比较，审计日志记录每次转换。

如果主机收到列表变更通知，随后又发现执行时期发生变化，应先处理执行时期变化。将缓存的工具目录标记为可疑，在获取当前目录并作出授权决定前阻止带凭据的调用，然后再恢复。代理仍然可以保留对话历史，但不能假定重建前构造的工具调用仍然有效。

能力变化也需要同样谨慎。如果重建增加了资源、提示、日志行为或实验性扩展，旧的已初始化会话可能没有协商这些功能。此时应重新连接，而不是尝试在原连接中就地修改协商结果。长期会话很方便，但当凌晨两点出错时，连接契约必须仍然容易理解。

## 添加热重载前先写好刷新契约

刷新契约应明确谁检测重建、重建会改变哪些状态、哪些调用需要暂停，以及哪些证据进入审计日志。如果不写清楚，每个组件都会做出局部看来合理的选择，组合起来却可能不安全。

使用一个小型状态机即可，不需要策略语言或复杂规则。

```text
ready(epoch A, catalog 12, approval A)
  build artifact changes
ready(epoch A, catalog 12, approval A)
  worker restarts
identity-pending(epoch B, catalog unknown, approval A invalid)
  host fetches tools/list
catalog-ready(epoch B, catalog 13, approval A invalid)
  reviewer approves required scope
ready(epoch B, catalog 13, approval B)
```

关键转换是 `identity-pending`。在这个状态下，主机不能仅仅因为代理已经准备好调用，就把带凭据的调用放行。可以允许无害的发现请求，但前提是对“无害”有清晰定义。对于大多数本地服务器，暂停所有工具调用，直到目录和授权都更新，是更简单的做法。

契约应以通俗语言包含以下决定：

- 哪个组件创建执行时期。
- 可执行身份包括哪些产物和运行时事实。
- 哪些元数据变化要求再次执行 `tools/list`。
- 执行时期变化时哪些授权范围会失效。
- 重启期间如何处理正在执行的调用。

正在执行的调用需要明确规则。如果工作进程在收到工具调用后、生成响应前退出，应返回能够说明时期转换的错误。不要自动对新进程重试写操作。重试可能重复付款、重复发布，或者在参数含义已经变化后再次应用修改。

如果主机能够证明第一次尝试从未到达操作边界，只读调用可以自动重试。但在本地子进程和远程 API 之间证明这一点很困难。超时不是证明，空响应也不是证明。应先采用明确失败，只有在能够证明幂等性时再加入安全重试。

## 将重建作为权限变化来测试

重建测试不应只证明“新工具出现了”。它还应证明过时的元数据无法形成危险调用，旧授权无法触达新代码，并且审计记录能够区分两个执行时期。

在本地测试装置中运行以下测试。服务器包含一个需要凭据的写工具和一个无害的读工具。

1. 启动服务器修订版 A。记录执行信息，获取 `tools/list`，并为一次代理运行批准写工具。
2. 使用类似 `revision=A` 的标记调用写工具一次。确认日志记录了时期 A 以及适用于时期 A 的批准授权。
3. 重建修订版 B。保留工具名称不变，但增加必填模式字段，或让处理器写入 `revision=B`。
4. 使用正常开发流程中的同一种机制替换正在运行的工作进程。
5. 在刷新元数据和批准之前，尝试旧的已准备调用。由于时期发生变化，主机应拒绝该调用。
6. 获取新目录，如果调用需要权限则获得新的批准，然后再次调用。确认日志记录了时期 B 和新的授权。

预期的拒绝应足够具体，便于调试：

```json
{
  "error": "authorization_stale",
  "reason": "server execution epoch changed",
  "approvedEpoch": "01JQ7R4S4S0QJ7GZP1S2",
  "currentEpoch": "01JQ7R9KQ6K2Y8W4JH0M",
  "retry": "refresh tool metadata and request authorization"
}
```

不要用笼统的“工具不可用”掩盖这个错误。代理需要知道自己应该刷新目录、等待服务器重启，还是请求人工处理。操作员也需要知道监视器是否意外替换了工作进程。

还要加入开发者常常跳过的失败测试：

- 构建成功，但旧进程仍在运行。
- 进程重启，但工具列表没有变化。
- 模式发生变化，但客户端忽略了 `tools/list_changed`。
- 写调用等待响应时发生重启。
- 服务器路径不变，但已解析的依赖树发生变化。

这些测试能够暴露你的设计是否依赖服务器友好地说真话。它不应依赖这一点。本地开发代码正是意外扩大信任范围的地方，因为开发者频繁重建，假设也会逐渐变得不可见。

## 审计记录必须回答是哪段代码执行了操作

审计记录必须让你确定哪个可执行时期处理了某次调用，否则它无法解决与重建有关的事件。工具名称、参数和时间戳都很有用，但仍然没有回答最困难的问题。

每次工具调用都记录执行时期。主机向代理展示工具信息时，记录目录修订版或元数据摘要。批准决定要记录其覆盖的对象。如果调用跨越凭据边界，记录凭据范围名称，但不要记录秘密本身。

紧凑的事件序列可以是这样：

```json
{"type":"server_started","epoch":"01JQ7R4...","entryDigest":"sha256:9e4c...71af"}
{"type":"approval_granted","run":"run_01JQ7R1","epoch":"01JQ7R4...","scope":"inventory-api-staging"}
{"type":"tool_called","run":"run_01JQ7R1","epoch":"01JQ7R4...","tool":"inventory_adjust"}
{"type":"server_replaced","oldEpoch":"01JQ7R4...","newEpoch":"01JQ7R9..."}
{"type":"authorization_denied","run":"run_01JQ7R1","epoch":"01JQ7R9...","reason":"stale_epoch"}
```

这种结构也有助于日常调试。当有人报告代理在重建后使用了旧模式时，你可以看出是主机没有刷新元数据、服务器从未重启，还是监管器改变了代码却没有发出通知。这些是不同的缺陷，不应被归入同一个问题类别。

Sallyport 的 Sessions journal 和 Activity journal 是分离代理运行记录与单项操作记录的有用例子，同时又从一份加密、哈希链式审计日志生成两者。这里也应采用相同的分离方式：一条记录说明谁获准运行，另一条记录说明哪个调用在什么执行时期下发生。

不要让审计系统依赖服务器自行报告身份。应在进程启动或凭据分发旁边捕获身份，并在环境允许时独立验证审计链。能够在会话期间改变代码的服务器，不能成为证明实际运行了哪段代码的唯一见证者。

## 重建速度不能成为继承信任的理由

快速重建是开发便利，不会让新代码自动变成此前审核过的代码。如果本地 MCP 服务器能够接触凭据、文件或远程系统，重建就应在已批准的代码和接下来将执行的代码之间建立一个可见边界。

先让进程替换变得可观察。在启动时加入时期，将会话授权绑定到该时期，契约发生变化时刷新工具元数据。然后故意让一项测试失败：在代理会话运行期间重建服务器，并确认下一次带凭据的调用会停止，直到主机拥有当前元数据和当前授权。

如果测试确实因为正确的原因通过，代理就能在重建后继续工作，同时不会获得属于更早可执行文件的权限。
