Model Context Protocol 2026-07-28 规范移除了会话层和状态头部,改为完全无状态设计。作者分享了在规范变更中维持测试套件通过的细节,包括错误代码重映射。
企业系统会在状态存续之处逐渐积累信任。session identifier 不只是一个路由键——它还是十几个下游假设悄然依附的基础。移除它,并不只是少了一个字段,而是会让所有建立在它之上的假设失效,包括那些从未被任何人记录下来的假设。
Model Context Protocol(MCP)在 2026-07-28 发布的规范正是这么做的。项目方称其为“协议发布以来规模最大的一次修订”,而两项最重要的变更都是在做减法。以下是 changelog 中重大变更的第 1 项和第 2 项:
从 Streamable HTTP transport 中移除协议层 session 和 Mcp-Session-Id header。
让 MCP 变为无状态:移除 initialize/notifications/initialized handshake。现在,每个请求都通过 _meta 携带其协议版本和 client capabilities。
在协议层面,每个请求现在都能独立成立。与此同时,授权要求也变得更加严格:在兑换 authorization code 之前,如果存在 iss 参数,client MUST 按照 RFC 9207 将其与已记录的 issuer 进行校验,从而阻止一类 authorization-server mix-up attacks。规范还引入了正式的 extensions framework,并规定任何已弃用功能在被移除前,必须经历至少十二个月的弃用期。
这是一份优秀的规范。但本文讨论的并不是规范本身。
早在 2026-07-22,也就是最终规范发布前六天,我就已经让 MCP 测试针对无状态 profile 运行了。我想准确说明这件事带来了什么,又付出了什么代价,因为前者是人们喜欢写进文章里的部分,而后者才是真正重要的部分。
从 release candidate 到最终修订版之间,规范新增了错误码分配策略。JSON-RPC server-error 的范围被进一步划分:-32000 到 -32019 保持由实现自行定义,并保留已有用法;-32020 到 -32099 则留给规范使用。随后,规范还重新编号了 draft 中引入的错误码:
我的 harness 检查的是 -32004。
读取这个错误码的函数只负责判断一件事:当 server 拒绝现代协议版本时,这究竟是一个明确的无状态协议响应,还是一个无法理解该请求的旧 server?判断正确,probe 就会停止。判断错误,client 就会回退到 initialize——也就是规范刚刚移除的那套 handshake。
面对任何按照最终规范构建的 server,我的检查都会返回 false。harness 把符合规范的版本拒绝误读成旧版 server 的证据,继而发送已被移除的 handshake,并针对一套 server 已明确拒绝的协议给出测试结果。
这个函数自己的 docstring 明明写着,这种情况绝不能发生。但它还是发生了。
等最终规范发布时,这个假设已经出现在 PyPI 上。4.10.0 版本于 2026-07-25 发布,其中原封不动地带着针对 RC 的比较逻辑。你不必相信我的一面之词:下载 wheel,然后查看 protocol_tests/mcp_harness.py。针对 RC 的比较逻辑就在已发布的 artifact 中,而 -32022 在里面一次都没有出现。
这一点值得停下来认真想一想。
我有 unit tests 覆盖了这个函数本身。它们会断言:遇到版本拒绝时,不会触发 fallback。这些测试一直都能通过——最终规范发布前能通过,发布后能通过,就连包含这个缺陷的版本发布时也照样通过。
它们之所以能通过,是因为所有 fixture 都是在 RC 阶段编写的,并且都将错误码固定为 -32004。测试和代码朝着同一个方向犯了错,因此二者完美地达成了一致。
如果一个测试只覆盖 RC 阶段的临时值,它就无法发现正式发布后的值没有得到处理。它的 branch coverage 可能足够,但它的 oracle 并不独立:实现和 fixture 只是同一个临时假设的两份副本。它们可以完全一致,却依然是错的。
我并不是通过测试发现这个问题的。我是在检查其他事情时,把规范 changelog 和自己的代码逐行对照重读,才发现了它。
比最终规范提前六天确实是一项实实在在的优势,我以后仍然会这么做。但 release candidate 代表的是一组临时承诺;跟进 RC,就意味着在人们尚未真正使用并检验这些承诺之前,你已经接受了它们。其中有些决定必然会改变。这些变化往往很小、不够吸引眼球,却恰恰是最容易被 fixture 永久冻结下来的那种变化。
这种领先不是免费的。它是一笔以尚未停止变化的规范为抵押借来的贷款。
如果你维护的任何东西涉及 MCP,并且曾在 RC 阶段采取行动,那么以下四件事值得你花一个小时检查:
搜索硬编码的 JSON-RPC 错误码。凡是在 RC 阶段至 2026-07-28 之间写下的、位于 -32000..-32099 范围内的 literal,都值得怀疑。把它们移到具名常量中——有了具名常量,重新编号就会成为一次明确的修改,而不是悄无声息地发生。但测试 oracle 必须保持独立:直接断言正式发布的 wire value,或者从权威的 conformance vector 中生成 fixture——不要使用正在接受测试的生产代码常量。测试如果导入自己正在检查的常量,本质上就是同义反复,只不过把同一种失败整理得更加整洁。
不要只检查测试断言了什么,还要检查 fixture 固定了什么。如果针对某种行为的所有 fixture 都是在同一周编写的,它们编码的就是那一周的假设,而且会永远彼此一致。加入一个来自当前标准的 fixture,看看会有什么东西出错。
审计所有负责判断“现代还是旧版”的逻辑。Version negotiation 分支在设计上就会安静地失败——它们本来就是为了优雅降级而编写的,这意味着错误的判断不会产生报错,而是会得到一次看起来合情合理的运行。
最终规范发布后重新阅读 changelog,而不只是在 RC 阶段读一次。把 changelog 与自己的代码进行 diff,而不是拿它和自己对 RC 的记忆比较。让我踩坑的重新编号,只是“Minor changes”下的第 12 项。从它所在的位置,你完全看不出它会破坏 client。
无状态重写重置了 MCP security testing 中相当重要的一部分。对于以 2026-07-28 版本为目标的实现,协议层的 session 假设已经消失,按请求声明 capabilities 成了新的攻击面,授权要求也更加严格。针对早期 MCP 修订版的测试可能依然有效,但它们并不能证明实现符合新规范。围绕旧 handshake 编写的测试套件并不只是略微过时——它们断言的是一套在 2026-07-28 protocol profile 中已经不存在的机制。
对于愿意根据当前规范文本重新推导自身假设的人来说,这是一个机会。对于提前采取行动、却没有回头检查的人来说,这也是一个陷阱。
我提前行动了,也回头检查了。代价是:我发布了一个假设,而它在三天后就变成了兼容性缺陷;为了找到它,我又花了整整一个下午。相比只谈论自己领先六天的那个版本,我更愿意把这个版本的故事公开出来。
修复方案、negative controls 以及完整的推理过程都已公开:PR #313。文中观点仅代表我个人。
如需采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。