工具一旦发布,它就不再是「你的代码」,而是「别人 Agent 的依赖」。有人在提示词里写了 search_docs(query, top_k),有人在网关里按名字做了白名单,有人把返回的 JSON 字段直接映射进了自己的 UI。此时你顺手把 top_k 改名成 limit,或者把返回里的 count 改成 total,就是一次静默的破坏性变更——它不会报错,只会让别人的 Agent 开始胡言乱语。
MCP 的协议层没有给工具做版本号(tools/list 里没有 version 字段),所以兼容性治理必须由服务器作者自己设计。本文要回答的是:哪些改动是安全的、哪些必须走新工具名、list_changed 通知在升级里扮演什么角色、网关侧怎么灰度、以及怎么把弃用做成一个可执行流程。
1. 三种「版本」不要混淆
| 版本 | 位置 | 语义 | 变更影响 |
|---|---|---|---|
| 协议版本 | initialize 的 protocolVersion | 通信协议契约 | 握手失败,连接不可用 |
| 服务器版本 | serverInfo.version | 服务器实现版本 | 仅信息展示,无自动行为 |
| 工具契约版本 | 无(需自定义) | 工具入参/出参契约 | 破坏 Agent 与调用方 |
1.1 协议版本协商
// 客户端 → 服务器
{ "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {...} } }
// 服务器 → 客户端(不兼容时返回自己支持的版本)
{ "result": { "protocolVersion": "2025-06-18", "capabilities": { "tools": {} }, "serverInfo": { "name": "docs-server", "version": "2.4.0" } } }
规则很简单:客户端提出自己支持的版本,服务器要么接受,要么返回自己支持的版本,由客户端决定是否继续。协议版本不匹配是连接级失败,不是工具级失败——它和本文讨论的工具契约演进是两回事。
1.2 工具契约版本为什么要自己管
1. 工具定义会进模型上下文(tools/list 的结果通常整体注入)
2. 模型对参数名敏感:改名 ≈ 换了一个工具
3. 客户端与网关会缓存工具定义,缓存过期前会继续按旧契约调用
4. 提示词里可能硬编码了工具名与参数名
2. 变更分类清单
把每一次改动归入下面三类,是兼容性治理的全部基础。
2.1 安全变更(向后兼容)
| 变更 | 说明 | 注意 |
|---|---|---|
| 新增可选参数 | 老调用不传也能跑 | 必须有合理默认值 |
扩展 enum 取值 | 老值仍有效 | 客户端若有穷举校验需同步 |
| 新增返回字段 | 老字段不动 | 严格模式的消费者可能报错 |
| 放宽约束 | 如 maxLength 调大 | 几乎无风险 |
| 优化 description | 提升模型选择率 | 无契约影响 |
2.2 需谨慎的变更
| 变更 | 风险 | 缓解 |
|---|---|---|
| 新增必填参数 | 老调用直接失败 | 改为可选 + 服务器侧推导默认 |
收紧约束(如 minLength) | 老参数值被拒 | 先告警统计一段时间再收紧 |
| 修改默认值 | 行为静默改变 | 显式记录在变更日志,通知调用方 |
| 调整返回字段类型 | 消费者解析失败 | 加新字段,老字段保留一个周期 |
| 更换错误码/错误文案 | Agent 依赖文案做分支 | 用稳定错误码,文案可自由改 |
2.3 破坏性变更(必须新版本)
| 变更 | 为什么破坏 |
|---|---|
| 重命名工具 | 名字是模型选择的唯一键 |
| 重命名参数 | 模型按名填参,改名等于新参数 |
| 删除参数 | 老调用传了未知参数 |
| 删除/重命名返回字段 | 消费者解析失败 |
收缩 enum(移除取值) | 老调用传了被移除的值 |
| 改变语义(同名不同义) | 最危险:调用成功但结果错了 |
最后一行值得强调:语义变更比签名变更更危险。把 search 的默认排序从「相关度」改成「时间」,签名一字未改,所有依赖相关度的调用方都会静默降级。
3. 两条演进路线
3.1 路线 A:版本化工具名
search_docs_v1 → search_docs_v2
优点:新旧并存、可独立下线、回滚就是切回旧名
缺点:工具列表膨胀(模型选择准确率下降)、描述重复、维护两套实现
适用:破坏性变更不可避免、且调用方无法同步升级(第三方生态、跨团队)。
3.2 路线 B:就地演进 + 兼容窗口
search_docs (保持名字不变)
第 1 阶段:新增 limit 参数,top_k 标记 deprecated 但继续接受
第 2 阶段:两者都传时以 limit 为准,top_k 触发告警日志
第 3 阶段:观察 N 周无 top_k 调用 → 移除 top_k
const schema = {
type: "object",
properties: {
query: { type: "string" },
limit: { type: "integer", minimum: 1, maximum: 100, default: 10 },
top_k: { type: "integer", deprecated: true, description: "已弃用,请改用 limit" },
},
required: ["query"],
};
function normalize(args: any) {
const limit = args.limit ?? args.top_k ?? 10;
if (args.top_k != null) metrics.inc("deprecated.top_k");
return { query: args.query, limit };
}
优点:工具列表干净、模型选择不受干扰
缺点:需要兼容层与用量统计、下线时机依赖可观测性
适用:自有生态、调用方可控、有指标能判断何时可以下线。
3.3 选择建议
| 条件 | 推荐 |
|---|---|
| 变更破坏性强、调用方不可控 | 路线 A(新名字) |
| 变更可兼容、有调用指标 | 路线 B(就地演进) |
| 语义发生根本改变 | 路线 A(并保留旧工具的只读兼容期) |
| 仅调整 description | 两条都不用,直接改(但要在变更日志记录) |
无论走哪条,弃用必须带日期与迁移指引:只写 deprecated: true 而不说「何时删、换成什么」,等于把问题推给调用方猜。API 版本演进的通用方法论可参考 API 版本化策略
。
3.4 兼容层应该放在哪
就地演进需要一层「新旧参数归一化」。它的位置决定了维护成本:
| 位置 | 优点 | 缺点 |
|---|---|---|
| 工具 handler 入口 | 实现简单、就近可见 | 每个工具都要写一遍 |
| 中间件 / 装饰器 | 统一处理、易测试 | 需要约定命名规范 |
| 网关层 | 服务器无感、可集中下线 | 网关需理解每个工具的契约 |
// 用装饰器统一做参数归一化与弃用打点
function withDeprecations<T extends object>(map: Record<string, keyof T>) {
return (handler: (args: T) => Promise<CallToolResult>) => async (raw: Record<string, unknown>) => {
const args = { ...raw } as Record<string, unknown>;
for (const [oldKey, newKey] of Object.entries(map)) {
if (args[oldKey] !== undefined && args[newKey] === undefined) {
args[newKey] = args[oldKey];
metrics.inc(`deprecated.${oldKey}`);
}
delete args[oldKey];
}
return handler(args as T);
};
}
归一化必须幂等且单向:只做「旧名 → 新名」的映射,绝不反向补写旧名,否则会在返回结果里凭空多出已弃用字段,让调用方以为该字段仍受支持。
另外要注意:归一化会掩盖契约不一致。当两个参数同时传入且值不同时,必须明确优先级(通常新名优先)并打点,而不是静默择一——否则调用方永远不知道自己传错了。
4. 通知与缓存失效
4.1 list_changed 的角色
工具集合或工具定义变化后,服务器应推送:
{ "method": "notifications/tools/list_changed" }
客户端收到后重新 tools/list,刷新本地工具定义与注入模型的上下文。
必须推送的时机:
新增/删除工具
工具参数或返回值契约变化
工具 description 大幅调整(影响模型选择)
工具被临时禁用(如依赖服务故障)
4.2 不推通知的代价
很多客户端会在会话开始时缓存工具列表并长期复用。如果服务器升级后不推 list_changed:
旧客户端 + 新服务器 → 按旧契约调用 → 参数被忽略或报错
更糟:新增的工具永远不会出现在模型的候选里
注意能力协商:客户端在 initialize 里声明 tools.listChanged: true 才表示它愿意处理该通知。未声明时,服务器只能寄希望于客户端下次会话重新拉取。
4.3 与提示词缓存的关系
工具定义通常位于请求的前部(system 区),一旦变化会使提示词缓存整体失效:
缓存命中条件:前缀逐字节一致
工具描述改一个字 → 前缀变化 → 缓存失效 → 下一轮请求成本上升
所以频繁微调 description(「优化一下措辞」)不是零成本的:每次改动都可能让一整批会话的缓存作废。建议把 description 调整批量发布,而不是逐字提交。提示词版本管理与 A/B 的做法可参考 提示词版本管理与 A/B 。
5. 网关与多服务器场景
5.1 网关聚合下的版本问题
网关把多个服务器聚合为一个工具命名空间时,版本策略要额外考虑:
1. 命名冲突:两个服务器都叫 search → 网关需加前缀(docs__search)
2. 版本混装:同一逻辑工具在两个服务器上版本不同 → 网关需路由到指定版本
3. 下线联动:某服务器下线某工具 → 网关需同步移除,否则调用直接失败
# 网关侧的工具路由与版本固定
routes:
- public_name: docs__search
upstream: docs-server@2.4.x # 固定主版本,避免小版本漂移
tool: search_docs
timeout_ms: 8000
- public_name: docs__search_legacy
upstream: docs-server@1.x
tool: search_docs
sunset: 2026-12-31
网关侧的注册、发现与配额细节见 https://plumephp.com/mcp-gateway-registry-proxy/。
5.2 灰度发布
工具契约变更的灰度比代码灰度更麻烦——同一会话内不能混用新旧契约,否则模型会看到互相矛盾的参数定义。
可行做法:
1. 按会话分流(同一 session 固定路由到同一版本)
2. 按客户端分流(内部客户端先升级,第三方后升级)
3. 影子调用:新版本同时接受新旧参数,但只对新会话暴露新描述
不可行做法:
1. 按请求随机分流(模型上下文里工具定义会抖动)
2. 同一会话中途切换版本(缓存与上下文不一致)
5.3 回归测试
每次契约变更都要跑三类测试:
1. 契约测试:旧参数组合仍能调用成功(兼容层是否真的生效)
2. 模型侧测试:用基准提示词检查工具选择率与参数填充正确率是否下降
3. 端到端测试:真实任务成功率与耗时对比
第 2 类最容易被跳过,但它是唯一能发现「description 改坏了」的手段——契约测试全绿而模型不再选这个工具,是典型的静默退化。评估方法见 https://plumephp.com/mcp-evaluation-benchmarking/,服务器侧的功能与协议测试见 https://plumephp.com/mcp-server-testing/。
6. 弃用流程模板
一个可执行的弃用流程,四步闭环:
第 0 步|评估
统计调用量、调用方分布、参数使用分布
确认没有调用方在用将被移除的能力
第 1 步|公告
工具 description 里加「已弃用,请改用 X,将于 YYYY-MM-DD 移除」
返回结果里附警告(不要用 isError,避免中断现有流程)
变更日志与文档同步
第 2 步|告警与观测
每次使用弃用能力 → 打点 + 日志
设置看板:弃用能力调用量趋势
第 3 步|移除
连续 N 周(建议 ≥ 4)调用量为 0 → 移除
移除同时推送 tools/list_changed
保留一个次要版本的"未知参数友好提示"(返回明确错误而非崩溃)
第 1 步里的「不要用 isError」很关键:如果弃用直接返回错误,所有还在用旧参数的 Agent 会立刻失败,等于把兼容期变成了强制升级。正确做法是继续正常工作 + 附警告,把失败留到真正的移除日。
7. 常见陷阱
| 陷阱 | 症状 | 解决 |
|---|---|---|
| 参数改名直接上线 | 老 Agent 参数填不上 | 新名新增 + 旧名兼容一个周期 |
| 改了不推 list_changed | 客户端按旧契约调用 | 任何契约变化都推通知 |
| 逐字微调 description | 提示词缓存频繁失效 | 批量发布 |
| 语义变更不公告 | 调用成功但结果错 | 视为破坏性变更走新版本 |
| 灰度按请求随机分流 | 模型看到的定义抖动 | 按会话或按客户端分流 |
| 弃用直接返回错误 | 调用方立刻全线失败 | 继续可用 + 附警告 |
| 版本化工具名无限累积 | 工具列表膨胀、选择率下降 | 设定下线的 sunset 日期 |
| 只做契约测试 | 描述改坏无人发现 | 补模型侧选择率回归 |
8. 小结
工具版本治理的核心是「把契约当成对外接口,而不是内部实现细节」:
| 层面 | 要点 |
|---|---|
| 分类 | 安全变更直接发;谨慎变更加兼容层;破坏性变更走新版本 |
| 路线 | 调用方不可控 → 版本化工具名;自有生态 → 就地演进 + 兼容窗口 |
| 通知 | 任何契约变化都推 tools/list_changed,别指望客户端自己刷新 |
| 成本 | description 变更会使提示词缓存失效,批量发布而非逐字提交 |
| 灰度 | 按会话/客户端分流,绝不按请求随机 |
| 弃用 | 继续可用 + 警告 + 指标观测 + 到期移除,四步闭环 |
工具是会被人依赖的。给它一个明确的变更分类、一个能观测的弃用流程、一套能发现静默退化的回归测试,升级就不再是「赌调用方没在用这个参数」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。