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

两段输出对应两件事。

第一段是发现结果。这里有三个字段值得单独记住:

第二段是版本拒绝:请求 2099-01-01,被拒。注意这行来自 Python 异常的消息字符串,不是规范要求的 -32022 错误对象。

必须纠正的两处教学简化

本项目为了代码短做了两处简化,换成生产必须改回来:

  1. 扩展标识符必须是带前缀的 DNS 式名字,且 extensions 是映射不是数组。 规范里 Tasks 写作 io.modelcontextprotocol/tasks,形态是「标识符 → 该扩展的配置对象」:

    {"capabilities": {"tools": {}, "extensions": {"io.modelcontextprotocol/tasks": {}}}}
    

    本项目写成 extensions=("tasks",)。此外规范规定:一方支持某个扩展而另一方不支持时,支持方 MUST 要么退回核心协议行为,要么用合适的错误拒绝这条请求;扩展应该有文档说明它的降级行为。

  2. 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 列表的错误对象,而不是抛异常。

本章检查点

现在能解释什么

你现在能解释 Globex 那次事故的完整链条:legacy 客户端 → 握手声明旧版本 → 服务端快速失败(正确)→ 但错误没带 supported 列表,对方无法自助恢复(待改进)。你也知道了 server/discover 解决什么问题、为什么发现结果必须按身份过滤、以及为什么 serverInfo 不能作为安全决策依据。下一章进入三个服务端原语:工具、资源、提示模板——它们怎么组成一份可发现清单,以及那份清单为什么不等于权限。

进入 keel 阅读