KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03 · 版本和能力怎样协商? — keel 龙骨
## 现场:卖给客户后,全线连不上
现场:卖给客户后,全线连不上
Incident Bridge 在 Acme 内部跑了两个月很稳。然后公司把它卖给了 Globex,对方要求接进自己的运维门户。
上线第一天,对方所有请求都失败,错误信息只有一句:
unsupported protocol version: 2025-11-25
而服务端审计日志里,这条请求被记成了 protocol.rejected——连接根本没建立。奇怪的地方在于:Acme 自己的 Web 助手一切正常,同样的包、同样的部署方式,只有 Globex 的门户不行。
排查结果:Globex 运维门户用的 MCP SDK 是半年前的版本,它握手时声明的 protocolVersion 是 2025-11-25。而我们的服务端只接受 2026-07-28,并且在 initialize 里直接抛异常:
def initialize(self, protocol_version: str, client_capabilities: dict[str, Any] | None = None) -> dict[str, Any]:
if protocol_version != self.protocol_version:
self.events.record("protocol.rejected", requested=protocol_version, supported=self.protocol_version)
raise ValueError(f"unsupported protocol version: {protocol_version}")
代码没有 bug——快速失败是对的,在产生任何副作用之前拒绝,比让它跑一半再炸好得多。真正的问题是另一件事:这个失败没有被转化成「对方能看懂、能自助解决」的信息。
本章要解决三件事:版本在哪声明、不匹配时规范要求的错误长什么样、以及能力协商的产物「发现结果」该怎么用、能不能缓存。
直觉模型:版本不是装饰信息
版本决定了双方能否解释同一份字段。这不是吹毛求疵——举例:一个工具有 structuredContent 字段,只有协商到某个版本后客户端才知道要读它;若客户端按老语义只读 content,结构化数据就丢了,而且不会报错。
这就是为什么规范把它放在最前面:必须先对版本达成一致,才能解释后续字段。
能力协商回答的是另一个问题:「这一端愿意处理哪些方法或扩展」。规则很简单:
- 未知能力应该被忽略或降级;
- 不支持的版本应该尽早失败,不要等到副作用发生。
精确定义:版本在哪传递
这是 2026-07-28 最大的结构性变化,值得背下来。
Legacy(2025-11-25 及以前) |
Modern(2026-07-28 及以后) |
|
|---|---|---|
| 版本位置 | initialize 握手时协商一次 |
每个请求的 _meta 里自带 |
| 会话状态 | 有会话,后续请求依赖它 | 无状态,请求自包含 |
| 不匹配时 | 握手失败,连接建不起来 | 该请求被拒,返回版本错误,客户端换版本重试 |
Modern 版本每个请求携带 _meta(来源:modelcontextprotocol.io 规范 Base protocol 章节,检索于 2026-09-29,原文 "Stateless, self-contained requests / Per-request capability negotiation"):
{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {"roots": {}},
"io.modelcontextprotocol/clientInfo": {"name": "acme-portal", "version": "2.1.0"}
},
"name": "incident.search", "arguments": {"service": "payments"}
}
}
_meta 里的键都用 DNS 式前缀:
| 键 | 必填 | 说明 |
|---|---|---|
io.modelcontextprotocol/protocolVersion |
是 | 该请求使用的协议版本 |
io.modelcontextprotocol/clientCapabilities |
是 | 客户端能力 |
io.modelcontextprotocol/clientInfo |
否 | 客户端身份(名称 + 版本) |
io.modelcontextprotocol/logLevel |
否 | 已被后续提案弃用 |
对称地,服务端 SHOULD 在每个响应上戳 io.modelcontextprotocol/serverInfo。另外在 HTTP 上,版本还会同步出现在 MCP-Protocol-Version 头里——规范明确要求 body 里的 _meta 才是唯一真相来源,头只是镜像。
不匹配时,规范要求的错误长什么样
版本不匹配不是随手写一句 message。规范定义了 UnsupportedProtocolVersionError,错误码 -32022,并且 data 里必须给出服务端支持的版本列表:
{
"jsonrpc": "2.0", "id": 1,
"error": {
"code": -32022,
"message": "Unsupported protocol version",
"data": {"supported": ["2026-07-28", "2025-11-25"], "requested": "1900-01-01"}
}
}
客户端的规范动作是:从 supported 里挑一个双方都支持的版本重试;一个都挑不到才向用户报错。
回头看现场——我们的实现抛了 ValueError("unsupported protocol version: 2025-11-25"),消息里只说了不支持,没告诉对方支持什么。对方拿不到重试用处的信息,只能来找我们。错误要能让对方自助恢复,别人才不必找你。
一次完整运行
python courses/foundation/mcp-protocol-engineering/course/project/examples/02_discovery.py
实跑输出(为可读性做了换行):
{'name': 'incident-bridge', 'version': '0.1.0',
'capabilities': {'tools': {'listChanged': False}, 'resources': {'subscribe': False},
'prompts': {'listChanged': False},
'tasks': {'requests': {'list': True, 'get': True, 'cancel': True}},
'extensions': ['tasks']},
'tools': [{'name': 'incident.search', 'description': '按服务检索已授权的事件摘要',
'inputSchema': {'type': 'object', 'required': ['service'],
'properties': {'service': {'type': 'string'}}}},
{'name': 'service.health', ...}],
'resources': [{'uri': 'knowledge://runbook/{id}', 'name': '授权运行手册'}]}
rejected: unsupported protocol version: 2099-01-01
两段输出对应两件事。
第一段是发现结果。这里有三个字段值得单独记住:
capabilities:服务端实际实现的 feature。listChanged: False、subscribe: False这类显式声明「我不支持这个子特性」——显式声明不支持比省略好,客户端据此可以决定不订阅、不轮询。tools:注意这里给了完整的inputSchema。第 04 章会讲这份清单为什么不能直接当成"用户能用的清单"。extensions: ['tasks']:这是教学简化,下面马上纠正。
第二段是版本拒绝:请求 2099-01-01,被拒。注意这行来自 Python 异常的消息字符串,不是规范要求的 -32022 错误对象。
必须纠正的两处教学简化
本项目为了代码短做了两处简化,换成生产必须改回来:
扩展标识符必须是带前缀的 DNS 式名字,且
extensions是映射不是数组。 规范里 Tasks 写作io.modelcontextprotocol/tasks,形态是「标识符 → 该扩展的配置对象」:{"capabilities": {"tools": {}, "extensions": {"io.modelcontextprotocol/tasks": {}}}}本项目写成
extensions=("tasks",)。此外规范规定:一方支持某个扩展而另一方不支持时,支持方 MUST 要么退回核心协议行为,要么用合适的错误拒绝这条请求;扩展应该有文档说明它的降级行为。serverInfo是自报的,协议不验证它。 官方原话说它用于展示、日志和调试,客户端 SHOULD NOT 用它改变行为,SHOULD NOT 把它作为安全决策依据。把"服务器自称是谁"当授权输入,是一个真实存在的越权入口(第 07 章会正面处理)。
失败注入:把发现结果缓存到全局
这是本章最值钱的一条,因为它藏在一个看起来很合理的优化里:
发现结果 supports caching,服务端返回
ttlMs和cacheScope两个字段就是为了这个。假设你的实现为了省一次往返,用一个全局变量缓存了 discovery 结果。
于是 Globex 门户的第一个用户(管理员,scope 很全)触发发现,结果被缓存。第二个用户是只读分析师,他的会话直接命中缓存,看到了完整工具清单——包括他永远调不动的那些。
看本项目的实现为什么没这个洞:
def discover(self, principal: Principal) -> dict[str, Any]:
visible = self.list_tools(principal)
self.events.record("server.discover", subject=principal.subject, tools=len(visible))
return {..., "tools": [tool.to_dict() for tool in visible], ...}
discover 强制要求传入 principal,可见工具列表按主体过滤。这个签名本身就是防呆设计:调用方不可能"忘记"传身份,因为在 Python 里漏参会直接报错。
推论很实用:缓存键必须包含身份维度(subject + tenant + scopes 摘要 + 版本),并且在 cacheScope: private 时禁止跨主体共享。规范把 cacheScope 设计成必填字段(ttlMs 默认 0,即立即过期),含义就是让你显式回答这个问题。
兼容矩阵:别靠人工点击
如果你只能记住本章一件事,记这张兼容矩阵(六种组合,整理自规范的版本化章节):
| Client | Server | 结果 |
|---|---|---|
| Modern | Modern | 正常。版本不匹配会在该请求上报错,客户端重试即可 |
| Modern | Legacy | 失败。服务端可能沉默、也可能按 legacy 语义处理语义含糊的方法 |
| Dual-era | Legacy | 正常。探测失败后回退到 initialize 握手 |
| Dual-era | Modern | 正常。探测返回发现结果,保持 modern |
| Legacy | Modern | 失败。Legacy 客户端没有向前回退机制 |
| Legacy | Legacy | 按 legacy 版本规则工作 |
现场那次事故是最后第二行:Globex 是 Legacy 客户端,我们是 Modern-only 服务端。规范对此有一条很体贴的建议——只支持 modern 的服务端,SHOULD 在任何传输上、对 initialize 请求返回的错误里列出自己支持的版本,因为 legacy 客户端没有别的地方可以拿到这个诊断信息。
这也是我们服务端实现该补的地方。
生产替换点
| 教学实现 | 生产替换 |
|---|---|
只在 initialize 校验一次版本 |
每个请求入口校验 _meta 里的版本 |
抛 ValueError |
返回 -32022 错误对象,带 supported 列表 |
extensions=("tasks",) 数组 |
{"io.modelcontextprotocol/tasks": {}} 映射 |
| 每次计算发现结果 | 带 ttlMs / cacheScope 的缓存,缓存键含身份维度 |
| 手工点击验证 | 版本 × 能力 × 扩展的自动化测试矩阵(第 09 章) |
练习与验收
练习(可观察结果):写一个函数,输入请求声明的版本和服务端支持的版本列表,输出三种结果之一:直接放行、返回 -32022 错误对象、或选择共同版本。用 {"2026-07-28", "2099-01-01"} 两个客户端版本与 ["2026-07-28", "2025-11-25"] 的服务端列表做两组测试。
验收标准:对着 2025-11-25 的 legacy 客户端,你的函数必须产出包含完整 supported 列表的错误对象,而不是抛异常。
本章检查点
- 为什么版本不匹配必须在产生副作用之前失败?举一个"晚一步发现"会怎样更糟的例子。
- 如果发现结果被跨租户共享,会发生什么?哪些可以被共享(
serverInfo?capabilities?tools 列表?),哪些不行? extensions写成数组而不是官方要求的映射形式,语义上丢失了什么信息?(提示:映射的值用来放什么。)
现在能解释什么
你现在能解释 Globex 那次事故的完整链条:legacy 客户端 → 握手声明旧版本 → 服务端快速失败(正确)→ 但错误没带 supported 列表,对方无法自助恢复(待改进)。你也知道了 server/discover 解决什么问题、为什么发现结果必须按身份过滤、以及为什么 serverInfo 不能作为安全决策依据。下一章进入三个服务端原语:工具、资源、提示模板——它们怎么组成一份可发现清单,以及那份清单为什么不等于权限。