8.4 能力协商与版本发现
一句话结论:一个协议如果不能演进,就会死。版本发现与能力协商让不同版本的客户端与服务端可以共存——客户端不必预先知道服务端是什么,问一句就知道。
读这篇之前
需要先读 8.2(两层结构)。这一篇讲 MCP 里最像"基础设施"的部分。
一、没有版本协商会怎样
假设 MCP 只有一种形态,且不协商版本。会发生什么?
| 场景 | 后果 |
|---|---|
| 服务端升级加了新工具 | 老客户端不认识,可能报错 |
| 客户端升级用了新字段 | 老服务端解析失败 |
| 服务端只支持部分能力 | 客户端调用不支持的功能,失败 |
这就是所有协议都做版本协商的原因——不是洁癖,是生存需要。
二、MCP 的做法:问一次就知道
数据层定义了一个关键请求:server/discover——客户端通过它查询服务端支持的协议版本、能力与身份。[核验:MCP 官方架构]
客户端 → server/discover
服务端 ← {
protocolVersion: "2026-07-28",
capabilities: {
tools: {...},
resources: {...},
prompts: {...}
},
serverInfo: { name: "...", version: "..." }
}
三个信息各自解决一个问题:
| 信息 | 解决什么 |
|---|---|
| protocolVersion | 双方用哪个版本的规则说话 |
| capabilities | 服务端支持哪些原语、有哪些子特性 |
| serverInfo | 这是谁(用于日志、诊断、权限策略) |
三、能力协商的实际价值
价值一:渐进式演进
服务端可以只实现一部分能力。比如只做 tools 不做 resources——客户端通过 capabilities 就知道,不会去调不存在的功能。
价值二:特性开关
即使都支持 tools,也可能在子特性上有差异(比如是否支持流式、是否支持批量)。capabilities 可以携带这些细节。
价值三:每个请求自带上下文
一个值得注意的设计细节:每个请求在其 _meta 字段中携带协议版本与该请求相关的能力,服务端可以独立处理每个请求。[核验:官方架构]
为什么这样设计? 因为 MCP 可能跑在无状态传输上(如 Streamable HTTP)。如果没有"每请求自带上下文",服务端就必须维护会话状态——那会牺牲可扩展性。
这是一个典型的分布式系统取舍:把状态放进消息里,而不是放在服务端内存里。
四、与 HTTP 协商的类比
熟悉 HTTP 的人会觉得这个模式眼熟:
| MCP | HTTP |
|---|---|
server/discover |
OPTIONS / Accept 头 |
protocolVersion |
HTTP/2 vs HTTP/1.1 协商 |
capabilities |
服务端声明的支持特性 |
核心思想完全一致:先问对方能做什么,再决定怎么说话。
五、实践中的四个注意点
| # | 注意 | 后果 |
|---|---|---|
| 1 | 客户端要处理"服务端不支持" | 否则调用失败 |
| 2 | 版本不匹配要有降级路径 | 硬失败会中断整个 Agent |
| 3 | 发现结果要缓存但可失效 | 每次都问浪费延迟 |
| 4 | 身份信息用于权限策略 | 不同服务端应有不同信任等级 |
第 4 条容易被忽略但很重要:服务端身份(serverInfo)应该参与你的权限判断。一个"自己写的本地服务端"和一个"从网上装的第三方服务端",信任等级显然不同。
六、动手:版本不匹配怎么办
协商机制设计了,实际会遇到不兼容。三种情况三种处理。
情况一:客户端版本更高,服务端较旧
← {"protocolVersion":"2025-06-18", ...} ← 服务端只支持旧版
处理:客户端降级到旧版语义,或明确拒绝并提示用户升级服务端。
| 做法 | 优点 | 缺点 |
|---|---|---|
| 自动降级 | 兼容性好 | 客户端要维护多版本逻辑 |
| 拒绝并提示 | 简单、明确 | 用户体验差 |
建议:核心能力做降级,新特性缺失时告警而非失败。
情况二:服务端声明不支持某原语
← {"capabilities":{"tools":{}}, ...} ← 只支持 tools,没有 resources
处理:客户端不要尝试调用 resources。这是最容易被忽略的一点——很多客户端硬编码假设"一定有 resources"。
情况三:_meta 里带了不支持的能力
→ {"jsonrpc":"2.0","id":5,"method":"tools/call",
"_meta":{"protocolVersion":"2026-07-28","capabilities":["resources"]},
"params":{...}}
处理:服务端应忽略不认识的能力,而不是报错。这保证了向前兼容。
七、一个容易被低估的实践:缓存发现结果
server/discover 每次都问会很浪费(尤其是 stdio 每次 spawn 进程)。
| 缓存策略 | 适用 |
|---|---|
| 进程内缓存(连接期间) | stdio,连接稳定 |
| 带 TTL 的缓存(如 5 分钟) | HTTP,服务端可能升级 |
| 不缓存 | 调试期 |
要能失效:服务端升级后,客户端必须能在下一次会话拿到新能力。
八、为什么"每请求自带 _meta"很重要(展开)
官方架构里提到:每个请求在 _meta 携带协议版本与相关能力,服务端可以独立处理每个请求。[核验]
这个设计的价值在无状态部署:
| 有状态 | 无状态(每请求带 _meta) |
|---|---|
| 连接期间记录"这个客户端支持什么" | 每个请求自带 |
| 不能随意负载均衡 | 可任意分发到任一实例 |
| 实例重启丢失会话 | 无影响 |
这是分布式系统的基本取舍:把状态放进消息,服务端就容易横向扩展。
九、协商失败的处理矩阵
协商不通过时,不同失败类型要有不同反应:
| 失败类型 | 例子 | 正确反应 |
|---|---|---|
| 完全不能通信 | 版本差太远 | 明确报错,提示升级 |
| 能力缺失 | 没有 resources |
降级:只用 tools |
| 字段不认识 | 新字段 | 忽略,继续 |
| 服务端报错 | server/discover 失败 |
重试 → 标记不可用 |
最忌讳的是"悄悄降级"——用户不知道某项能力没生效。
差:连接成功但静默少了 3 个工具
好:连接成功,日志与 UI 提示「服务端不支持 resources,已降级」
这与 10.5 的可观测性原则一致:状态变化要可见。
十、一个实践建议:把协商结果当作能力清单
把 server/discover 的结果缓存成一张能力表,供后续所有决策使用:
能力表(启动时构建)
服务端 A:tools ✅ resources ✅ prompts ❌
服务端 B:tools ✅ resources ❌ prompts ✅
用途:
| 决策 | 依据 |
|---|---|
| 界面显示哪些功能 | 能力表 |
| 工具清单怎么生成 | 各服务端 tools 的并集 |
| 出错时怎么提示 | 能力表 + 报错类型 |
| 是否降级 | 缺哪个能力 |
这张表比"每次现问"更高效,也比"硬编码假设"更可靠。
补充误区
误区七:版本协商只在连接时做一次。
无状态传输下,每个请求都要带版本信息。
误区八:客户端可以假设服务端支持全部原语。
必须按 capabilities 判断。
误区九:不认识的能力应该报错。
应该忽略,否则无法向前兼容。
误区十:发现结果可以永久缓存。
服务端会升级,必须能失效。
十一、落地时的版本管理策略
协议协商解决"运行时兼容",但你还需管理"发布时的版本"。
| 策略 | 做法 | 适用 |
|---|---|---|
| 语义化版本 | 主版本不兼容、次版本加能力、补丁修 bug | 通用 |
| 向后兼容优先 | 新能力放可选,不删旧字段 | 长期服务 |
| 双版本并行 | 同时暴露 v1/v2,给客户端迁移期 | 大改动 |
| 弃用告警 | 旧能力标记 deprecated,返回告警 | 渐进淘汰 |
最关键的一条:加能力容易,删能力难。
安全:加一个新工具、加一个可选参数 → 老客户端不受影响
危险:删一个工具、改一个字段类型 → 老客户端直接崩
所以设计时的原则是:多留可选字段,少删已有字段。
十二、常见误区
误区一:版本协商是形式主义。
没有它,协议演进就意味着全生态断代。
误区二:客户端可以硬编码服务端能力。
那就失去了插拔性。必须动态发现。
误区三:发现结果可以永久缓存。
服务端可能升级。要能失效。
误区四:所有服务端应同等信任。
不同来源的服务端信任等级不同(见 5.4 关于 Skill 的提醒)。
误区五:无状态传输和版本协商冲突。
恰恰相反——每请求自带 _meta 正是为了支持无状态。
十三、动手:能力发现的完整交互
8.4 讲了机制,这里看它怎么用。
一次完整的协商
① 客户端 → server/discover
② 服务端 ← {protocolVersion, capabilities, serverInfo}
③ 客户端据此构建"能力表"
④ 后续所有决策都查这张表
能力表的三种用途
| 用途 | 怎么用 |
|---|---|
| 界面 | 只显示服务端支持的功能 |
| 工具清单 | 取各服务端 tools 的并集 |
| 降级判断 | 缺哪个能力就走哪条备用路径 |
一个具体的降级示例
caps = discover(server)
if "resources" in caps:
# 优先用 resources(客户端可控暴露范围,更安全)
ctx = load_resources(server)
else:
# 降级为工具读取(权限更松,需要额外校验)
ctx = call_tool(server, "read_context")
# 但一定要让用户知道降级了
log.warning(f"{server} 不支持 resources,已降级为工具读取")
最后那行日志很重要 —— 悄悄降级会让用户以为一切正常,而实际上安全模型变松了。
一个反面案例
错:客户端硬编码假设"所有服务端都支持 resources"
→ 遇到只支持 tools 的服务端直接崩
MCP 的能力协商就是为了消除这种假设。 不用它,等于白定协议。
十四、动手:能力协商的完整实现
8.4 的机制落到代码,就是"发现 → 缓存 → 查询"三步。
class CapabilityTable:
"""缓存各服务端的能力,供后续所有决策使用"""
def __init__(self):
self.table = {} # server -> {version, caps, info}
self.fetched_at = {}
def discover(self, server, force=False):
if not force and server in self.table and self._fresh(server):
return self.table[server]
raw = call(server, "server/discover", {})
self.table[server] = {
"version": raw["protocolVersion"],
"caps": raw["capabilities"],
"info": raw["serverInfo"],
}
self.fetched_at[server] = time.time()
return self.table[server]
def has(self, server, cap):
return cap in self.table.get(server, {}).get("caps", {})
def _fresh(self, server, ttl=300):
return time.time() - self.fetched_at.get(server, 0) < ttl
三个设计要点
| 要点 | 为什么 |
|---|---|
| 带 TTL 的缓存 | 每次都问浪费;永久缓存会错过升级 |
has() 查询接口 |
让后续代码不直接读原始结构 |
force=True 可刷新 |
服务端升级后能重新发现 |
一个典型用法
caps = table.discover("github")
if table.has("github", "resources"):
ctx = load_resources("github")
else:
log.warning("github 不支持 resources,改用 tools 读取(权限更松)")
ctx = call_tool("github", "read_context")
中间那行日志不能省 —— 悄悄降级会让用户以为安全模型没变。
十五、一个反例:把能力当"常量"写死
# 错:假设所有服务端都支持 resources
ctx = load_resources(server)
后果:遇到只支持 tools 的服务端(很常见),直接抛异常,整个任务中断。
正确做法:
ctx = load_resources(server) if table.has(server, "resources") \
else call_tool(server, "read_context")
这就是 MCP 定义 server/discover 的意义 —— 让客户端能适配未知服务端,而不是把所有服务端当成同一种。
十六、一个补充:协商失败该不该"硬失败"
| 失败类型 | 硬失败 | 降级 |
|---|---|---|
| 协议版本差太远(无法通信) | ✅ 硬失败 | —— |
| 缺 resources 但只需要 tools | —— | ✅ 降级 |
| 缺 tools(根本无法工作) | ✅ 硬失败 | —— |
| 某个工具不可用 | —— | ✅ 跳过该工具 |
判据:
缺的这个能力,是"必须的吗"?
必须 → 硬失败,明确报错
可选 → 降级,但要告知
最忌讳的两种极端:
| 极端 | 后果 |
|---|---|
| 一律硬失败 | 一个小能力缺失导致整个系统不可用 |
| 一律静默降级 | 用户不知道功能少了,事后才发现 |
正确的中间态是"降级 + 显式告知",这也符合 10.5 的可观测性原则。
16、下一篇
原语和协商都有了。还剩一个 MCP 里最能体现"工程成熟度"的设计:双向协议与懒连接。
它解决的是"服务端也需要反过来问客户端"这类问题,以及"启动时不要把所有连接都建立起来"的实际困难。
下一篇讲这两点。
十四、一个有价值的副产品:能力表可以驱动 UI
协商结果不只能驱动逻辑,还能驱动界面。
| 能力表内容 | 界面上可以 |
|---|---|
tools 存在 |
显示"可用工具"区域 |
resources 存在 |
显示"资料浏览"入口 |
prompts 存在 |
显示"快捷模板"菜单 |
| 某服务不可用 | 灰显对应入口 + 提示原因 |
这比硬编码界面强得多:新接一个服务端,界面自动多出对应入口,不用改前端。
这也是为什么"能力发现"不只是协议细节,而是产品能力的来源。
一句收尾
协商不是握手仪式,而是"让客户端能适配它从没见过的服务端"的机制。用不用它,决定了你的实现能不能进生态。