塌下来的其余部分 — 断线、订阅,以及这本书还剩什么
这一章讲三件事: 第 11 章那条理由再往下推,还压塌了哪两样; 顺手删掉和顺手加上的几件零碎各是什么; 以及全书的落点——这本书哪几部分作废了、哪几部分仍然成立。
它在全书链条里的位置: 第 11 章讲的是「地基怎么塌的」(打招呼、状态、服务器发 问)。 这一章讲塌下来砸到了什么,并给出全书的结账单。
1. 同一条理由,还剩两级没推完
这一节先接上一章的线头。
第 11 章那条理由只有一句:服务器多开了几份副本,几份之间不共享内存, 所以「服务器记得你上次说过什么」这件事没法保证。
那一章已经推出四级:状态取消 → 打招呼的产物跟着每条请求走 → 新增一个问路方法 → 服务器不能再主动发问。
还剩两样东西也建在「服务器记得你」上面,这一章把它们推完:
| 还剩哪一样 | 它靠服务器记着什么 | 本章第几节 |
|---|---|---|
| 断线之后从断点接上 | 记着「我给你发过哪些消息、发到第几条」 | §2 |
| 服务器主动来说「清单变了」 | 记着「谁登记过要听哪几类」 | §3 |
本章的主走查:第 08 章那次三分钟的调用,换到新规矩下跑一遍
走查的输入:第 08 章那件你已经认识的活儿——「把项目里的说明文档全转成 PDF」, 一共 214 个文件,跑到第 180 个的时候线断了。 214、180、以及下面出现的秒数、凭据字串和毫秒数,都是我们为演示编的,不是真实数值; 规矩本身(哪些被删、哪些必须重做)全部出自规范。
四步,分别落在下面四节上:
第 1 步 · 断了 旧:报「我最后收到 #180」→ 补发 #181
新:这次请求作废,换新编号整条重发 → §2
第 2 步 · 不想白跑 改用可选的「任务」扩展:先拿一个凭据回来 → §2 末、§5
第 3 步 · 重连后 必须把订阅登记重发一遍,拿回订阅编号 1 → §3
第 4 步 · 要不要重取清单 看上一份清单结果里那个保鲜期还剩多少 → §4
图说:同一次断线,新规矩下要多做两件事(重发登记、判断缓存),
少做一件事(不必再记「 发到第几条」)。
2. 第五级:断线不能续传 ⟹ 只能整条重发
这一节推翻第 08 章 §5,也是主走查的第 1 步。
回看第 08 章那句伏笔:断线续传要求服务器一直记着自己发过什么消息、发到第几条。
这和「不记着你」正面冲突。 于是它被删了—— 连同那个用来记「发到第几条」的编号一起1。
现在断线会发生什么?
| 书里那一套 | 2026-07-28 | |
|---|---|---|
| 线断了 | 报出最后收到的编号 #180,服务器从 #181 往后补发 | 这次请求作废 |
| 客户端要做什么 | 重连、报编号 | 换一个新编号,把整条请求重发一遍 |
| 已经跑完的那 180 个 | 不用重跑 | 白跑(除非服务器自己防了重复:同一件活儿跑第二遍,不会把已经转好的再转一遍) |
这是这次改动里代价最明显的一条。 一次跑三分钟的活儿, 在第 180 个文件上断掉,现在就是从头再来——前面那 2 分 30 秒白花。
主走查第 2 步:不想白跑的话,得换一条路
规范不是没给出路,只是把它挪出了核心。
长活儿改用一个叫「任务」的可选扩展来做:服务器不再把结果攥在这条连接上,
而是先回你一个凭据(比如 task-9f3),你拿着它去问「跑到哪儿了」「结果好了没」2。
不用扩展: tools/call ──── 挂 3 分钟 ────→ 断了 = 白跑
用了扩展: tools/call ──→ 立刻回 task-9f3
客户端每 10 秒问一次:task-9f3 跑到哪儿了?
断了也不要紧——**凭据不在这条连接上**,重连之后接着问
图说:关键差别不在「快不快」,在于**凭据是一张 能带走的票**,
而旧那套的「发到第几条」只在这条连接里有意义。
但要看清一件事:它已经不是核心协议的一部分了(见第 5 节)。 也就是说,你连的那台服务器可以完全不支持它,那你就只能整条重发。
3. 第六级:服务器不能主动推 ⟹ 订阅改成客户端登记一条长回话
这一节推翻第 08 章 §6 的后半段、以及第 04 章 §3 那条长连接;它是主走查的第 3 步。
第 08 章讲过:清单会变,服务器可以给你打个招呼。
当时的做法是:客户端向服务器登记(resources/subscribe),
服务器往那条长连接上主动推。
这两样都被删了,理由还是同一条:服务器得记着「谁登记了什么」3。
新的形状是:客户端主动发一条特殊的请求(subscriptions/listen),
在里面列明自己想收哪几类消息;这条请求的回复本身就是一条一直开着的长回话,
消息从这条回话上流下来4。
客户端 → subscriptions/listen
notifications = { "toolsListChanged": true,
"resourceSubscriptions": ["file:///project/config.json"] }
服务器 ← 第一条必须是「收到了,订阅编号 1」
← notifications/tools/list_changed (带订阅编号 1)
← notifications/resources/updated (带订阅编号 1)
…一直流下去,直到一方关掉
图说:方向反过来了——不是服务器主动推,是客户端先递一条请求,
服务器沿着这条请求的回复往下写。**状态在这条请求上,不在服务器的记忆里。**
四类可以登记的东西,正好对上前面几章:工具 清单变了、话术清单变了、 资源清单变了、以及某几份指定资源本身变了4。
还有两条实用的规矩:
- 服务器「禁止」发你没登记的类型;它回过来的那条确认里会写明它实际同意了哪几类, 你要拿它和自己请求的对一遍4;
- 本机线路上如果连接断了重连,客户端「必须」把登记重发一遍—— 服务器不跨重连保留任何订阅状态4。
最后这一条就是主走查的第 3 步: 线在第 180 个文件上断了,
你重连之后除了重发那条调用,还得把 subscriptions/listen 也重发一次,
再拿回一个订阅编号 1。漏掉这一步不会报错,你只是从此再也收不到「清单变了」。
4. 顺手删掉的,和顺手加上的
这一节收拢几件零碎改动,并说明它们为什么全都出自同一条理由;主走查的第 4 步在这儿。
删掉的:
| 删了什么 | 这本书在哪儿讲过 | 为什么删 |
|---|---|---|
探活(ping),两个方向都删 | 第 08 章脚注提过 | 服务器不能主动发请求了;而客户端那一侧,随便调一个正常方法就已经证明对方活着3 |
| 设置日志级别的那个方法 | 第 08 章 §4 | 它本身就是在改「这次连接的状态」;现在级别跟着每一条请求走1 |
| 「根目录变了」的通知 | 第 07 章 §4 | 根目录改成用完就问,不需要变更通知了3 |
| 资源的订阅 / 退订方法 | 第 08 章 §6 | 见上一节3 |
加上的:
每一份清单结果现在都必须带一个保鲜期。 这叫结果保鲜期—— 服务器告诉你「这份结果你可以放心用多少毫秒」;配套还有一个字段说明 这份结果能不能被共享的中间人存起来(取决于它里面有没有跟具体用户相关的内容)5。
主走查的第 4 步就用它: 你重连之后要不要再要一遍工具清单?
上一次拿清单时服务器回的保鲜期是 ttlMs: 300000(5 分钟),
而这次断线前后一共只过了 40 秒——所以直接用手里那份,省掉一次来回。
要是断了 6 分钟,那就得重新要一遍。
它为什么也出自同一条理由? 因为「不记着你」之后, 清单结果不再随连接变化——同一个问题任何时候问、问哪一份副本,答案都一样。 答案稳定了,才谈得上把它存起来重复用。
规范还提醒了两件很实用的事5:
- 保鲜期是提示,不是保证——服务器可能在到期之前就把数据改了;
- 收到「清单变了」的消息时,哪怕还没到期也要立刻当成过期。
顺带一个小改动但影响挺大: 规范现在建议服务器每次返回工具清单时保持顺序一致, 理由是这样客户端才好把它存下来重复用,模型那一侧的提示缓存命中率也更高1。 顺序一变,存下来的那份就全部作废——这一条第 09 章讲按需查找时也提到过同样的道理。
5. 核心之外的东西搬去了「扩展」
这一节讲一个方向性的变化,它决定了这份规矩以后会怎么长。
以前的做法是往核心里加功能。现在不是了。
规范引入了扩展机制——把非核心的东西拆成一个个可选的包,各自单独排版本; 双方在能耐里声明自己支持哪几个,靠一个带前缀的标识来指认6。
第一批被搬出去的,就是第 2 节那个「任务」——它本来是核心里的实验特性, 这一版被整个挪进了一个官方扩展1。所以主走查第 2 步那条出路, 能不能走通取决于对面那台服务器有没有声明它。
规矩只有一条:如果一方支持某个扩展、另一方不支持, 支持的那一方要么退回核心行为,要么明确报错6。
判断(我们的,不是书里的): 这个变化对读这本书的人有一个很实际的影响—— 以后「MCP 支不支持某某功能」这个问题会越来越难回答, 因为答案取决于「哪个核心版本 + 哪几个扩展」。 所以你的客户端从现在起就该把「对面支持什么」当成运行时的数据来处理, 而不是写死在代码里的假设。 如果错,会错在: 如果扩展生态没长起来、绝大多数实现只跑核心, 那么这层运行时判断就是白写的一层麻烦。判据是:一年后主流服务器声明的扩展有几个。