跳到主要内容

协议自己变了形 — 三样地基件是怎么塌的

这一章讲三件事: 最新规范到底拆掉了哪三样;为什么拆它们的理由只有一条; 以及这条理由一级一级往下压塌了什么。

它在全书链条里的位置: 这是全书落点的上半截。 前十章教你把使用方那一侧写通;这一章让你认出哪几块地基已经被换掉了。 不读这一章,你会照着一套已经换代的写法去动手。 同一条理由还压塌了断线续传和订阅——那两级和全书的结账单在第 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 个字段每一条请求都要带。

这是一笔用「每次多带一点」换「服务器什么都不必记」的交易。 在一次连接只调一个工具的场景下,新写法明显省; 在一次连接调几百次工具的场景下,发出去的总量其实是新写法更多—— 但省下的是运维那一侧的麻烦,而那才是当初的痛点。

5. 第三级:那怎么先知道服务器支持什么 ⟹ 加一个问路方法

这一节回答:取消打招呼之后,难道只能盲试?

不是。规范新增了一个方法,服务器「必须」实现它。 我们管这个方法叫问路方法——客户端问一句「你支持哪些协议版本、 有哪些能耐、你是谁」,服务器一次答完;它在规范里正式的名字是 server/discover4

它和打招呼的关键差别只有一条,但这一条决定了一切:

打招呼(旧)问路(新)
必须先做吗必须,不做就调不了工具不必,客户端可以直接发请求
版本不对怎么办打招呼那一步就谈崩了服务器回一个「不支持这个版本」的错,并附上它支持哪几个,客户端换一个重发
结果能不能存下来复用存在连接状态里,连接一断就没了可以存下来复用,而且服务器会告诉你能存多久(第 12 章 §4)

「客户端可以不问」这半句是整件事的关键。 因为一旦「必须先问」, 服务器就又得记着「这个客户端问过了没有」——状态就又回来了。

它还有第二个用处:在本机线路上,它是判断对方是哪一代的探针2。 你发一句问路过去:回了正经答案,对面是新的;回了别的错,对面是旧的,退回去打招呼。

6. 第四级:服务器不能再主动发问 ⟹ 改成先回一句「我还缺东西」

这一节推翻的是第 07 章那一整章的形状。

第 07 章那条走查里,服务器在处理一次工具调用的过程中, 主动朝客户端发了一条请求(「借你的模型问一句」)。

这件事在新规范里被禁止了。 理由和前面一样: 服务器要主动发问,就得记着「我为哪一次调用、向谁发过什么、还在等谁的回答」—— 又是状态。

新的形状叫多轮往返请求——服务器不发问,而是把这次调用先答成 「我还缺这几样东西」,客户端补齐之后换一个新编号,把原来那条请求整条重发一遍5

客户端 → tools/call(编号 1)「订下周二最便宜的直飞」
服务器 ← 结果,但 resultType = "input_required"
inputRequests = { "pick_flight": { 一条借模型的请求 } }
requestState = "(一段服务器自己编码的、客户端看不懂的字串)"
↑ 这一次调用**到此结束了**,不是挂起

客户端 自己去把这件事办掉(该问用户问用户,该调模型调模型)

客户端 → tools/call(编号 2)同样的参数
+ inputResponses = { "pick_flight": { 模型的回答 } }
+ requestState = 原样带回来
服务器 ← 结果,resultType = "complete"

图说:两次请求之间,服务器**什么都不必记**——
它需要的上下文全被它自己塞进那段字串里,由客户端原样背回来。

第 4 节那个 resultType 字段,存在的意义就在这里: 客户端拿到一个结果, 要先看这个字段才知道「这是最终答案」还是「它还缺东西」5。 规范还规定:收到旧版服务器那种没有这个字段的结果,一律当成最终答案5

两条容易忽略的规矩:

  • 重发时的编号必须换一个新的——因为这是两次独立的请求,不是同一次的续集5;
  • 那段字串是攻击面。它经过客户端的手,服务器必须假设它被人改过: 如果它影响权限判断,服务器就必须给它加防篡改保护,并拒收验不过的5

到这里,同一条理由已经压塌了四样东西。 还剩两样也建在「服务器记得你」上面—— 断线之后从断点接上、以及服务器主动来说「清单变了」。第 12 章把它们推完。

7. 可带走的

  1. 最新规范拆掉了三样地基件:打招呼、连接状态、服务器主动发问;
  2. 旧写法没有立刻失效,它叫「旧的那一代」;但旧客户端遇到新服务器是彻底不通、且救不回来的;
  3. 起因不在设计,在部署:远程服务器要多开副本扛量,而副本之间不共享内存;
  4. 第一级:取消协议层的状态——每条请求自带全部所需信息;业务状态改由服务器发凭据、当普通参数传;
  5. 第二级:打招呼的产物跟着每条请求走——报文从 5 条降到 2 条,但每条请求多带 3 个字段;
  6. 这是一笔交易,不是纯赚:调一次工具的连接省,调几百次的连接反而多发——省下的是运维那一侧的麻烦;
  7. 第三级:新增一个服务器必须实现的问路方法,而客户端可以不问——这半句是关键;
  8. 问路的结果可以存下来复用,而打招呼的结果一断线就没了;
  9. 第四级:服务器改成先答「我还缺这几样」,客户端补齐后换新编号整条重发;
  10. resultType 是新的第一道判断:先看它是「最终答案」还是「我还缺东西」;旧版服务器没这一格,一律当最终答案;
  11. 那段随行的字串要当成攻击面——它经过客户端的手,服务器必须假设它被改过;
  12. 这条理由还没推完:断线续传和订阅也建在「服务器记得你」上面,第 12 章推完剩下两级,并给出全书的结账单

8. 原文地图

这一章推翻的是书里这些地方——列出来方便你回头对照。

被推翻的内容原书章原文位置
打招呼做了哪四件事Initializing the Client and Connecting to a Servertext/07-fm-initializing-the-client-and-connecting-to-a-serv.txt:186(搜「advertising the client」)
连接四步小结Initializing the Client and Connecting to a Servertext/07-fm-initializing-the-client-and-connecting-to-a-serv.txt:191(搜「when writing the connection code」)
服务器主动向客户端发问(借模型)Providing MCP Client Capabilitiestext/09-fm-providing-mcp-client-capabilities.txt:33(搜「allows MCP server to make use of」)
客户端要为每样能力挂一个函数Providing MCP Client Capabilitiestext/09-fm-providing-mcp-client-capabilities.txt:25(搜「Pass the callback function to the ClientSession constructor」)
那台计算器与 multiply_two_numbersInitializing the Client and Connecting to a Servertext/07-fm-initializing-the-client-and-connecting-to-a-serv.txt:320(搜「calculator tools」)

Footnotes

  1. 补充(不在书里):2026-07-28 版变更日志的「重大变更」一共九条。与本章直接相关的有:移除协议层会话与那个会话标识头,清单方法的结果不再随连接而变,需要跨请求状态的服务器改为自己发一个凭据、当作普通工具参数传(第 1 条);移除 initialize / notifications/initialized 握手,协议版本与客户端能耐改由每条请求的 _meta 携带(第 2 条);新增 server/discover(第 3 条);引入多轮往返请求取代服务器主动发起的请求(第 7 条);所有结果新增必填的 resultType(第 8 条)。剩下四条(订阅、探活与日志级别、任务扩展、移除流恢复)落在第 12 章。来源:MCP 规范变更日志 https://modelcontextprotocol.io/specification/2026-07-28/changelog(查阅于 2026-08-25)。 2 3

  2. 补充(不在书里):规范用 modern(现代)/ legacy(旧的那一代)/ dual-era(双代)三个词区分实现,并给了一张完整的兼容矩阵,本章第 1 节那张表就是它的中译。本机线路上判断对方是哪一代的办法是:先发一句 server/discover,收到正经答案或规范定义的版本错误就是新的,收到别的错就退回打招呼。规范还建议把判断结果缓存到该服务器进程(本机)或该来源(远程)的整个生命周期。来源:MCP 规范版本与兼容页 https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning(查阅于 2026-08-25)。 2

  3. 补充(不在书里):这三条困境出自规范提案 SEP-2575《让 MCP 无状态》的「动机」一节,原文标题依次是「对可扩展性的阻碍」「弹性与容错差」「实现复杂度上升」。文档明确说,把有状态的 MCP 服务器放在标准负载均衡器后面是困难的,因为客户端的会话和持有其状态的那一台实例绑死了;并说服务器端的会话状态管理是「bug 和内存泄漏的常见来源」。来源:https://modelcontextprotocol.io/seps/2575-stateless-mcp(查阅于 2026-08-25)。

  4. 补充(不在书里):规范规定服务器必须实现 server/discover,它返回支持的协议版本列表、能耐、身份,以及可选的一段给模型看的使用说明;客户端可以在发任何请求之前先调它,也可以完全不调,直接发请求并处理「不支持这个版本」的错误——那个错误里会附上服务器支持的版本清单,客户端从中挑一个重发。来源:MCP 规范服务器发现页 https://modelcontextprotocol.io/specification/2026-07-28/server/discover(查阅于 2026-08-25)。

  5. 补充(不在书里):多轮往返请求(MRTR)的规则包括:服务器可以对取话术、读资源、调工具这三种请求返回 resultType: "input_required";里面的 inputRequests 只能是借模型、要目录、问用户这三类;requestState 是一段只对服务器有意义的不透明字串,客户端禁止解析或修改、重发时必须原样带回;重发的编号必须和原请求不同;服务器禁止发送客户端没有声明支持的那类请求;服务器必须requestState 当成攻击者可控的输入,若它影响授权或业务逻辑就必须做完整性保护并拒收验不过的。客户端收到旧版服务器那种没有 resultType 的结果时,必须当成 "complete"。来源:MCP 规范多轮往返请求页 https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr(查阅于 2026-08-25)。 2 3 4 5