Chapter 02
协议机制:握手、协商与线路
01 章给了一张控制权地图——谁能发起什么、朝哪个方向。这一章把这套结构放到线路上:它如何用 JSON-RPC 跑起来。从 initialize 握手、capability 协商、三阶 lifecycle,到通知 / 错误模型与两种 transport,逐层下探到比官方文档深一层的字节级细节。
本章你将建立的 schema
- MCP 用 JSON-RPC 2.0 当消息层:三种形态(request / response / notification),靠 id 关联、靠 notification 推送
initialize是强制首轮握手:版本是日期串,capability 一次性互相亮牌- lifecycle 三阶(Initialization → Operation → Shutdown),shutdown 走 transport 而非协议消息
- 两种现行 transport:stdio(本地,stdout 即线路)与 Streamable HTTP(远程,单端点 + 可选 SSE)
沿用 01 章的场景:host(Cursor 或 Claude Desktop)连一个 GitHub server,让助手能读 issue、查 PR、提交评论。01 章关心"谁能调谁",这一章把每一次调用还原成线路上真实流动的 JSON 字节——握手怎么发、能力怎么协商、一次 tools/call 在 stdin/stdout 上长什么样。
2.1为什么是 JSON-RPC 2.0
MCP 选 JSON-RPC 2.0 作消息层——它面向"调用一个具名方法",与 function calling 的心智天然对齐,而非 REST 那套资源 / CRUD 模型。
MCP 的核心动作是"调用 server 上的某个能力":tools/call、resources/read、prompts/get。这是 RPC(远程过程调用)的语义,不是 REST 的语义。REST 要把每个能力映射成 URL 路径 + HTTP 动词(资源 + CRUD),而工具调用本质是"执行这个具名函数、给这组参数"——JSON-RPC 的 method + params 直接就是这个形状,不必为每个工具设计一套 URL。
底层机制(比文档深一层):JSON-RPC 在 MCP 里扛着三个承重属性,缺一个这套协议就立不住。
- request id 做关联:一条长连接上请求和响应是乱序穿插的。每个 request 带一个
id,对应的 response 回填同一个id——这样在同一条长生命周期双向通道上,发起方能把回来的结果对上是哪一次请求。没有 id,单通道上的多路并发请求就无法解多路复用。 - notification 做推送:一类消息没有 id、也不期待回复,专门承载
*/list_changed(列表变更)、progress(进度)、cancellation(取消)。它是单向的"广播一声",不占用请求-响应的配对。 - 对称(symmetric):JSON-RPC 不区分谁是"客户端谁是服务端"——任一端都能发 request。这正是 03 章 server→client 调用(sampling / elicitation)能成立的协议基础:在消息层,server 给 client 发 request 与 client 给 server 发 request 完全同构。
还有一条关键性质:transport-agnostic(与传输无关)。同一个 JSON 消息形状,无论跑在 stdio 还是 HTTP 上都不变——2.8 讲的两种 transport 只换"管子",不换"信件格式"。
对比 gRPC:gRPC 性能更高,但要求 protobuf 代码生成 + 强制 HTTP/2,调试时是二进制流。MCP 选 JSON-RPC,换来的是人类可读、可手抓包调试、零 codegen——一个 server 作者用任何语言拼 JSON 字符串就能对接。代价也实在:放弃了 HTTP 缓存、放弃了原生流式(后来靠 SSE 外挂补上)、放弃了默认强类型(改为每个 tool 自带一份 JSON Schema 描述参数)。
JSON-RPC 2.0 规范里有 batching(把多条消息装进一个 JSON 数组一次发送)这一强制特性。MCP 在 2025-06-18 版本经 PR #416 移除了对 batching 的支持,理由是缺乏实际用例。batching 只在 2025-03 至 2025-06 之间短暂存在过——也就是说,MCP 技术上丢掉了它所基于、所命名的那个规范的一个必备部分。结论:不要按 batching 来实现,当前 MCP 不支持。
2.2三种消息形态
JSON-RPC 在线路上只有三种消息:request(带 id,要回复)、response(回填 id,带 result 或 error)、notification(无 id,不回复)。
整个 MCP 会话——握手、列工具、调工具、推变更、报进度、传错误——全部由这三种形态拼出来。认清这三种形态,等于拿到了读任何 MCP 抓包的解码表:看到 id + method 是请求,看到 id + result 是应答,看到只有 method 没 id 是通知。
底层机制(比文档深一层):三种形态用一次 tools/call(让 GitHub server 创建一个 issue)摆出来看。注意 id 的有无如何决定"要不要等回复"。
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "create_issue",
"arguments": { "title": "登录页 500", "body": "复现步骤见下" }
}
}
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [ { "type": "text", "text": "已创建 issue #1423" } ],
"isError": false
}
}
{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed"
}
三点定死:① request 必带 id(数字或字符串,同会话内唯一);② response 用同一个 id 回填,result 和 error 二选一、绝不并存;③ notification 没有 id 字段,发出即忘,对端不得回复。第三种形态正是 2.6 的进度 / 取消 / 列表变更全部走的通道。
2.3initialize 握手
每条 MCP 会话的第一笔交换必须是 initialize:三步——client 发请求、server 回响应、client 发 notifications/initialized 收尾。
两端在通话前互不了解对方的协议版本和能力。initialize 是一次强制的"对暗号":先把协议版本对齐、把双方支持的 capability 一次性亮清楚,之后才允许进入正常调用。跳过握手直接发 tools/call 是协议违规——握手前只允许 ping 和 logging 两类消息。
底层机制(比文档深一层):握手是严格的三步,方向交替,不能合并。
- client →
initializerequest:带protocolVersion(自己支持的最新版本)、capabilities(client 端能力,如 sampling / roots)、clientInfo(名称 + 版本)。 - server →
initializeresponse:回 它自己的protocolVersion、capabilities(server 端能力,如 tools / resources)、serverInfo,外加可选的instructions(给模型的使用说明)。 - client →
notifications/initialized:一条通知(无 id),告诉 server 初始化完成。这之后会话才进入 Operation 阶段。
版本是一个日期串(如 2025-06-18),不是语义化版本号。协商规则是一次"降级对齐":client 在 step 1 发自己支持的最新版本;若 server 不支持该版本,server 在 step 2 回 它自己支持的最新版本;若 client 无法接受 server 回的这个版本,client 直接断开连接。没有来回拉锯,最多一个往返就定下版本或散伙。
ping / logging 外任何调用都是协议违规。{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {
"sampling": {},
"roots": { "listChanged": true }
},
"clientInfo": { "name": "Cursor", "version": "1.4.0" }
}
}
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": true, "listChanged": true },
"prompts": {}
},
"serverInfo": { "name": "github-mcp", "version": "0.9.2" },
"instructions": "用 create_issue 建 issue 前先 search_code 确认无重复。"
}
}
client 在 initialize 里发 protocolVersion: "2025-06-18",但这个 server 只支持到 2025-03-26。会话会立刻失败吗?如果不会,之后怎么走?(先停十秒)
展开答案(先自己答)
不会立刻失败。协商是"降级对齐":server 在 initialize 响应里回它自己支持的最新版本 2025-03-26。接力交到 client 手里——若 client 能接受 2025-03-26,双方就以该版本继续;若 client 无法接受(比如它依赖只有新版才有的特性),client 主动断开连接。关键点:拍板版本的最后一棒是 client,且整个过程最多一个往返,没有反复协商。
2.4能力协商
握手时双方各自亮出一个 capabilities 对象;之后两端都只准使用已成功协商的能力,用未声明的特性即协议违规。
MCP 是个还在快速演进、各家实现进度参差的协议。若靠"版本号锁一切",一个只实现了 tools 的极简 server 就没法和一个功能齐全的 client 通话——除非两边版本号严丝合缝。capability 协商让能力变成按需点单(à la carte):双方各报各的能力清单,交集即可用,不必为一个小特性做整版升级的 lockstep。
底层机制(比文档深一层):capabilities 是分方向的两套。
- server 端能力:
tools/resources/prompts/logging——即 01 章的三大 primitive 加日志。 - client 端能力:
sampling/roots/elicitation——这正是 01 §1.6 埋下的那条反方向能力(server 反过来请求 client)。03 章把它们逐一展开。 - 子标志(sub-flags):能力对象里可带细粒度开关。
listChanged: true表示"列表变更时会主动发*/list_changed通知";resources 还有subscribe: true表示"支持订阅单个资源的更新"。
运行期硬规则:协商之后,双方只能使用握手中成功协商出来的能力。server 没在 capabilities 里声明 prompts,client 就不准发 prompts/list;client 没声明 sampling,server 就不准向它发起 sampling 请求。用一个未亮过牌的特性,是协议违规——这条规则把"能不能用"从运行期试错提前到了握手期确定。
2.5lifecycle 三阶
一条 MCP 会话有三个阶段:Initialization(握手)→ Operation(正常收发)→ Shutdown(关闭),其中 Shutdown 走 transport 层而非协议消息。
明确的生命周期把"什么时候能发什么"钉死:握手阶段只准 initialize 相关消息(加 ping / logging);进 Operation 才放开 tools/call 等业务调用;关闭则有确定的收场方式。没有这套阶段约束,连接的状态会含糊不清。
底层机制(比文档深一层):注意 Shutdown 没有一条"关闭"的 JSON-RPC 消息——它由 transport 负责,不同 transport 收场方式不同。
- stdio:client 先关闭 server 的
stdin(发 EOF);server 若不在合理时间内自行退出,client 再发SIGTERM,仍不退则SIGKILL。 - Streamable HTTP:直接关闭 HTTP 连接即视为关闭会话。
另一处近期收紧:2025-06-18 把 Operation 阶段一条原为 SHOULD 的约束改成了 MUST(changelog "Change SHOULD to MUST in Lifecycle Operation")——两端在 Operation 阶段必须尊重协商出的协议版本与能力,措辞从"建议"升格为"必须"。
initialized 通知触发,但离开靠 transport 关闭——Shutdown 不是一条协议消息,这是初学者最容易找错的地方。2.6通知 / 进度 / 取消
长操作、取消、动态变更都走 notification——回到 2.2:这类消息没有 id、不期待回复,是单向推送。
请求-响应是"问一句答一句"的同步配对,扛不住三种场景:一个长跑的工具要中途报进度;一个已发出的请求要被取消;server 的工具列表在运行期变了要通知 client。这三种都是"单向告知,无需对方应答"——正是 notification 的形态。
底层机制(比文档深一层):三类常见 notification,全部无 id。
notifications/cancelled:在params里带上要取消的那个请求的requestId,告知对端"别再处理它了"。因为是按 id 取消的,所以取消的目标必须是一个带 id 的 request——这也反证了 2.2 里"为什么 request 必须有 id"。- progress 通知(
notifications/progress):长操作期间周期性上报进度。前提是发起请求时在_meta里带了progressToken,对端据此把进度关联回那次请求。 notifications/tools/list_changed(及resources/prompts的同类):server 告诉 client "工具列表变了,重新tools/list拉一次"。这就是 MCP 的动态发现——工具集不是握手时定死的,可以运行期增删。前提是 server 在握手时声明了tools.listChanged: true(呼应 2.4 的子标志)。
2.7错误模型
出错时 response 用 error 取代 result,是一个 { code, message, data } 对象,沿用 JSON-RPC 标准错误码。
底层机制(比文档深一层):标准错误码区间固定。
-32700Parse error(JSON 解析失败)·-32600Invalid Request(不是合法的 request 对象)-32601Method not found(方法不存在)·-32602Invalid params(参数非法)·-32603Internal error(内部错误)-32000到-32099:保留给 server 自定义的实现错误。
MCP 把通用错误码复用到自己的语义上。一个常见例子:initialize 时若请求的协议版本不被支持,server 回 -32602(invalid params),并在 data 里附上 supported 与 requested 两个版本字段,让 client 看清差距。
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32602,
"message": "Unsupported protocol version",
"data": { "supported": ["2025-03-26"], "requested": "2025-06-18" }
}
}
2.8传输层:stdio 与 Streamable HTTP
MCP 当前定义两种 transport:stdio(本地,消息走 stdin/stdout)与 Streamable HTTP(远程,单端点 + 可选 SSE 流);JSON-RPC 消息形状在两者上完全相同。
本地 server(如文件系统)和远程 server(如托管在云上的 GitHub server)的连接方式截然不同:前者是父进程拉起子进程、用管道通信;后者要跨网络、要会话标识、要鉴权。transport 把"信件格式"(JSON-RPC)和"投递管道"解耦——同一套消息能落在两种管道上。
底层机制(比文档深一层):两种 transport 的关键差异。
- stdio(本地):host 把 server 作为子进程拉起,JSON-RPC 消息就在 stdin / stdout 上逐行流动。关键陷阱:stdout 就是线路本身——server 里任何
print()或写到 stdout 的日志,都会混进 JSON 流、破坏解析,是真实会致命的 bug。所以 server 的日志必须全部走 stderr。这条是 04 章会回头点名的坑。 - Streamable HTTP(远程):单个 HTTP 端点。client→server 用 HTTP
POST发请求;server→client 的流式(推送通知 / 进度)走可选的GET+ SSE(Server-Sent Events)。会话用Mcp-Session-Id响应头标识;自 2025-06-18 起,后续 HTTP 请求必须带MCP-Protocol-Version头(PR #548)声明协商出的版本。
Streamable HTTP 之前是 HTTP+SSE 双端点 transport(一个端点收 POST、另一个独立端点开 SSE 长连接)。它在 2025-03-26 被废弃,由 Streamable HTTP 取代。读旧教程 / 旧 SDK 时若看到"两个 endpoint"的 HTTP 写法,那是过时方案——当前远程 transport 只有 Streamable HTTP 这一个端点。
stderr 是日志唯一的出口——一行误入 stdout 的 print 就会污染 JSON 流,让会话整条崩掉。用 Python 写了个 stdio server,本地手动跑没问题,但接进 Claude Desktop 后 client 报 JSON 解析错、连不上。代码里有几句 print("server started") 和 print(f"got request: {req}") 做调试。问题出在哪?(先停十秒)
展开答案(先自己答)
那几句 print 是元凶。stdio transport 下 stdout 就是 JSON-RPC 线路本身,print() 默认写到 stdout,于是 server started 这种纯文本被混进了本应只有 JSON 的流里,client 一解析就报错。修法:把所有调试输出改写到 stderr(Python 里 print(..., file=sys.stderr) 或用 logging 配到 stderr)。手动跑之所以"没问题",是因为没有 client 在解析 stdout——一接真实 client 立刻暴露。这正是 2.8 强调、04 章还会回访的致命坑。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- JSON-RPC 的三种消息形态各靠什么字段区分?哪一种没有
id、不期待回复? initialize握手的三步分别由谁发、是请求还是通知?协议版本协商若 server 不支持 client 报的版本,之后怎么走?- 能力协商的"运行期硬规则"是什么?举一个"违规使用未协商能力"的具体例子。
- stdio transport 下,为什么 server 的日志绝不能写到 stdout?会造成什么后果?
答案(先做完再展开)
- request = 带
id+method(要回复);response = 回填同一id+result或error(二选一);notification = 只有method、无id、不回复。没有 id 的是 notification。 - ① client 发
initialize请求;② server 回initialize响应;③ client 发notifications/initialized通知(前两步是请求 / 响应,第三步是通知)。版本协商:server 在响应里回它自己支持的最新版本,若 client 不能接受则主动断开——最后一棒在 client。 - 规则:双方只能使用握手中成功协商出的能力,用未声明的特性即协议违规。例:server 没声明
prompts,client 却发prompts/list;或 client 没声明sampling,server 却向它发起 sampling 请求。 - 因为 stdio 下 stdout 就是 JSON-RPC 线路本身,任何写入 stdout 的文本会混进 JSON 流、破坏 client 的解析,导致会话崩溃。日志必须走 stderr。
把一次"调工具"从握手到结果全程画成线路
场景:Cursor(client)连远程 GitHub server(Streamable HTTP),用户触发一次 search_code。按顺序写出线路上依次出现的 JSON-RPC 消息——从建立会话到拿到结果——并标注:哪些是 request(带 id)、哪些是 notification(无 id)、哪一步用了哪个 HTTP 方法、哪个头是 2025-06-18 起必带的。
提示(卡住再展开)
骨架顺序:① POST initialize 请求(id=1)→ ② initialize 响应(id=1,响应头带 Mcp-Session-Id)→ ③ POST notifications/initialized 通知(无 id)→ 进入 Operation → ④ POST tools/list 请求(id=2)拿到工具清单 → ⑤ tools/list 响应(id=2)→ ⑥ POST tools/call 请求(id=3,name: "search_code")→ ⑦ tools/call 响应(id=3,结果在 result.content)。其中 ③ 是唯一的 notification;自 step ③ 起每个 POST 都必须带 MCP-Protocol-Version 头。若 search_code 是长操作,④–⑦ 之间还可能穿插无 id 的 notifications/progress(走 SSE 下行)。这道题把 2.2–2.8 串成一条完整线路,06 章会以变体重现。