协议自己变了形 — 三样地基件是怎么塌的
这一章讲三件事: 最新规范到底拆掉了哪三样;为什么拆它们的理由只有一条; 以及这条理由一级一级往下压塌了什么。
它在全书链条里的位置: 这是全书落点的上半截。 前十章教你把使用方那一侧写通;这一章让你认出哪几块地基已经被换掉了。 不读这一章,你会照着一套已经换代的写法去动手。 同一条理由还压塌了断线续传和订阅——那两级和全书的结账单在第 12 章。
1. 先说结论:这本书教的写法,现在叫「旧的那一代」
这一节先把结论摆出来,再用后面五节推给你看。
2026 年 7 月 28 日那版规范,把三样地基件拆掉了1:
| 被拆掉的 | 这本书在哪一章教过它 |
|---|---|
| 连上之后先打一次招呼 | 第 04 章 §5(那三条报文) |
| 服务器记着这次连接的状态 | 第 04 章 §5、第 08 章 §5 |
| 服务器主动朝客户端发问 | 第 07 章 §2(借模型)、§4(要目录)、§5(问用户) |
但先别急着把书扔掉。旧写法没有立刻失效,规范给它起了个名字叫「旧的那一代」, 并且专门规定了两代之间怎么互相识别、怎么共存2:
| 客户端是哪一代 | 服务器是哪一代 | 结果 |
|---|---|---|
| 新 | 新 | 通 |
| 新 | 旧 | 不通——所以新客户端在本机线路上应当先探一句,好干脆地失败 |
| 双代(两种都会) | 新 | 通,保持新的 |
| 双代 | 旧 | 通,自动退回旧的 |
| 旧 | 新 | 不通,而且旧客户端没有任何办法往前兼容 |
| 旧 | 双代 | 通,按旧的走 |
看第五行:旧客户端遇到新服务器,是彻底不通的,而且救不回来。 这就是为什么你必须知道这一章的内容——不是为了立刻改写,是为了在它发生时认得出来。
2. 起因不在协议,在部署:一台服务器要同时开好几份
这一节回答:为什么要改?
不是为了设计得更优雅。压力来自部署。
一台远程 MCP 服务器可能同时服务成千上万个客户端。 一台机器扛不住,标准做法是多开几份一模一样的副本,前面放一个分发器, 把进来的请求轮流派给它们——这叫多副本部署。
┌─→ 副本 A
一堆客户端 → 分发器 ─┼─→ 副本 B
└─→ 副本 C
图说:你这一条请求落到 A、B 还是 C 上,是随机的;
下一条请求可能落到另一份上。**三份之间不共享内存。**
规范提案文档把当时的困境写得很直白3:
- 最要命的是没法做负载均衡:普通的分发器会把同一个客户端的请求派到不同副本上, 而那几个副本谁都没有你的状态;运维只能上「粘性会话」这种复杂又脆弱的办法, 把一个客户端钉死在一份副本上——结果是负载不均、横向扩容变得很难;
- 一份副本挂了,它上面所有客户端的状态全丢,客户端得重连、重新打一次招呼;
- 写服务器的人要自己创建、维护、回收每个客户端的状态, 文档说这是「bug 和内存泄漏的一个常见来源」。
记住这三条,后面四级、以及第 12 章那两级,全部由它们推出来。
3. 第一级:几份之间不共享内存 ⟹「记着你」这件事做 不到
这一节把上一节的困境翻译成协议层的一个决定。
你可能以为「连接状态」只是个编号,存哪儿都行。它不是。
「服务器记得你」的完整含义是: 它记着你上次报的协议版本、 你声明过哪些能耐、你订阅了哪些东西、它给你发过哪些消息、发到第几条。 这些全都是内存里的东西。
而多副本部署下,这件事根本没法保证——你的下一条请求可能落到一份 从没见过你的副本上。
所以规范做了一个彻底的决定:取消协议层的连接状态,让 MCP 变成无状态—— 每一条请求都自带处理它所需要的全部信息,服务器不必依赖任何上一条请求留下的东西1。
注意「协议层」这三个字。 服务器业务上确实需要跨请求记东西(比如一个购物车), 但那不再由协议来管:规范的做法是让服务器自己发一个「凭据」, 当成普通的工具参数传来传去1——协议只管传,不管记。
下面三节,以及第 12 章的前两节,全部是这个决定的连锁后果。
4. 第二级:不记着你了 ⟹ 打招呼的结果没地方放
这一节是本章的主走查。
回看第 04 章:打招呼产生了什么?协议版本、双方的能耐、双方的身份。 这三样本来就存在那份连接状态里。
状态没了,它们只能跟着每一条请求重新报一遍。
走查的输入:第 04、05 章那条你已经认识的请求——
multiply_two_numbers(a=12, b=30)。 两边的报文都按各自那一代的规范写,值取自官方示例。
左边:书里那一套(旧的那一代),一共五条报文
① → { "id":1, "method":"initialize",
"params": { "protocolVersion":"2025-06-18",
"capabilities":{},
"clientInfo":{ "name":"calculator_server_connection", … } } }
② ← { "id":1, "result": { "protocolVersion":"2025-06-18",
"capabilities":{"tools":{}},
"serverInfo":{ … } } }
③ → { "method":"notifications/initialized" } ← 没有编号
④ → { "id":2, "method":"tools/call",
"params": { "name":"multiply_two_numbers",
"arguments":{"a":12,"b":30} } }
⑤ ← { "id":2, "result": { "content":[{"type":"text","text":"360"}] } }
右边:2026-07-28 那一套,一共两条报文
① → { "id":1, "method":"tools/call",
"params": { "name":"multiply_two_numbers",
"arguments":{"a":12,"b":30},
"_meta": {
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{ … },
"io.modelcontextprotocol/clientCapabilities":{} } } }
② ← { "id":1, "result": { "resultType":"complete",
"content":[{"type":"text","text":"360"}],
"_meta":{"io.modelcontextprotocol/serverInfo":{ … }} } }
逐字段对照:
| 字段 | 旧的那一代 | 2026-07-28 | 搬到哪儿去了 |
|---|---|---|---|
| 协议版本 | 报文 ① 里报一次 | 每条请求的 _meta 里都带 | 从「一次」变成「每次」 |
| 客户端 能耐 | 报文 ① 里报一次 | 同上 | 同上 |
| 客户端身份 | 报文 ① 里报一次 | 同上 | 同上 |
| 服务器能耐 | 报文 ② 里回一次 | 挪到一个专门的方法里(下一节) | — |
| 服务器身份 | 报文 ② 里回一次 | 每条回复的 _meta 里都带 | 从「一次」变成「每次」 |
| 「我好了」 | 报文 ③ | 没有了 | 删掉 |
resultType | 没有 | 每条结果都必须有 | 新增(第 6 节讲它干什么) |
那个跟着每条请求走的 _meta,装的就是元数据——
请求正文之外、专门用来放协议自己那些信息的一小块地方。
账要这么算: 报文从 5 条降到 2 条,少了 3 条; 但每条请求多带 3 个字段。打招呼那 3 条报文一次连接只发一次, 而这 3 个字段每一条请求都要带。
这是一笔用「每次多带一点」换「服务器什么都不必记」的交易。 在一次连接只调一个工具的场景下,新写法明显省; 在一次连接调几百次工具的场景下,发出去的总量其实是新写法更多—— 但省下的是运维那一侧的麻烦,而那才是当初的痛点。