客户端与版本协商
本章讲客户端侧:
Client类提供什么,以及它最有意思的部分——连接期版本协商:一个客户端怎么探测出对面服务器说的是 2025 还是 2026,以及探测失败时怎么保守回退。
1. 它要解决的小问题
客户端连上一个服务器,还不知道对方是哪个纪元。MCP 的两个纪元用不同的握手:legacy 用 initialize,modern 用 server/discover。客户端得有办法「先问一句、再决定说哪种话」,而且要在「探测失败」时优雅降级,不能把一个还活着的 legacy 服务器当成死了。
2. Client 是什么
Client(packages/client/src/client/client.ts)继承自 Protocol(第 2 章),把通用骨架补成「客户端角色」:
- 高层调用:
callTool、listTools、readResource、getPrompt等(对应各 spec 方法)。 - 客户端能力:elicitation(向用户征集输入)、sampling(请客户端代为调 LLM)、roots(暴露文件系统根)。
- OAuth 助手(
auth.ts)、响应缓存(responseCache.ts)。 - 一旦协商到 modern 纪元,自动给每个出站请求附上
_meta信封(协议版本、客户端信息等)。
3. 主线:三种协商模式
协商是opt-in的,通过 ClientOptions.versionNegotiation.mode 选(versionNegotiation.ts:78):
| 模式 | 行为 | 失败时 |
|---|---|---|
'legacy'(默认) | 纯 2025 连接,字节等同没有这套机制的客户端 | —— |
'auto' | 连接时先用 server/discover 探测;只有拿到确凿 modern 证据才走 modern,否则保守回退到 initialize | 网络故障→抛类型化连接错误 |
{ pin: '<version>' } | 强制 modern 且必须是 pin 的那个版本 | 对面没提供→抛类型化错误,不回退 |
默认是 'legacy'(versionNegotiation.ts:100),改默 认就是改这一行——说明 v2 仍把 2025 当稳定默认。
4. auto 模式的精妙:传输感知的超时回退
'auto' 模式最值得学的是它怎么处理「探测没回音」。同样是超时,在不同传输上含义相反:
发出 server/discover 探测
│
┌────┴────┐
有响应 超时无响应
│ │
▼ ┌────┴────┐
按响应 stdio HTTP
判定纪元 │ │
▼ ▼
回退到 抛类型化
initialize 超时错误
(本地管道静默 (部署的服务器
= legacy 服务器) 该回话,静默=故障)
注释把理由讲得很白(versionNegotiation.ts:40-49、:70-73):
- stdio 上静默 = legacy 服务器。 本地子进程管道,一个老服务器根本不认
server/discover、不回话很正常——所以回退到initialize在同一条流上重试。 - HTTP 上静默 = 故障。 一个部署好的 HTTP 服务器应该回话;没回话意味着网络/服务器出问题——所以抛标准的类型化超时错误,而不是误判成 legacy。
探测窗口本身有几条不变量(versionNegotiation.ts:8-13):探测用字符串 id、不消耗 Protocol 的 messageId,所以一次 legacy 回退发出的 initialize 和一次纯 legacy 连接字节等价;探测窗口开着时,任何不是探测响应的入站消息都被丢弃、零字节回写。
5. 协商后:实例纪元被钉死
协商完成后,Client 把结果版本写进 _negotiatedProtocolVersion,从此这个实例的纪元就固定了(第 3 章讲过,这是 「实例状态、无副表」)。在 client.ts 里能看到 connect 路径分两支(client.ts:929 起):
- legacy 路径: 跑经典
initialize握手,握手完成时设负商版本(client.ts:1026附近)。 - negotiated 路径: 跑
negotiateEra探测,成功后设版本(client.ts:1095附近)。
会话恢复(reconnect)时会先恢复之前协商好的版本,把它塞回 transport(transport.setProtocolVersion),这样重连不必重新协商一遍(client.ts:1049-1055)。
6. 巧妙之处
-
-32022纠偏续连不计入重试预算:maxRetries只管「超时重发探测」的次数;规范要求的「服务器回 −32022 让你用双方都支持的版本重连」是另一个协商步骤,绝不占用maxRetries(versionNegotiation.ts:51-60)。两种「重试」语义不同,刻意分开。 -
modern-only 客户端没有回退: 如果你的
supportedProtocolVersions列表里一个 legacy 版本都没有,resolveVersionNegotiation(versionNegotiation.ts:120)会把这判定为「无 fallback 可用」——modern 探测失败就直接失败,不会偷偷降级。这防止「我明明只想说 modern,却被悄悄拉回 legacy」。
7. 边界与局限
- 协 商是 opt-in 的;不配
versionNegotiation就是纯 legacy,连探测都不发。 - modern 纪元下服务器不能反向给客户端发请求(采样/elicitation 的 2025 推模式没了),改用
input_required多轮结果——所以Client在 modern 连接上会丢弃入站的服务器→客户端请求(client.ts:620附近的 drop 逻辑)。 - pin 模式要求版本必须通过
isModernProtocolVersion校验(versionNegotiation.ts:130),不能 pin 一个 legacy 版本。
8. 横向对比
很多协议客户端的版本协商是「发一个版本号、对面接受或拒绝」。MCP 的做法更像浏览器的特性探测:主动 probe + 传输感知的回退策略,把「本地管道」和「远程 HTTP」当成语义不同的环境区别对待。这是 v2 三大支柱里最贴近「真实部署经验」的一块——和 03-wire-codec-eras.md 的「删除即物理删除」配合,确保协商出的纪元从此被运行时严格执行。
9. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 客户端类 | packages/client/src/client/client.ts | Client、connect |
| 协商选项与解析 | packages/client/src/client/versionNegotiation.ts | VersionNegotiationMode、resolveVersionNegotiation |
| 协商引擎 | packages/client/src/client/versionNegotiation.ts | negotiateEra |
| 探测分类器 | packages/client/src/client/probeClassifier.ts | classifyProbeOutcome |
| OAuth | packages/client/src/client/auth.ts | (见文件) |
| 响应缓存 | packages/client/src/client/responseCache.ts | ClientResponseCache |
| 纪元判定 | packages/core-internal/src/shared/protocolEras.ts | isModernProtocolVersion、modernProtocolVersions |