MCP 这套东西,现在聊的人很多,但大多数人的学习路径是“配一个 MCP server,塞进客户端设置里,能跑就完事”。真到排查问题的时候,对着日志里那些 JSON 消息一脸懵,不知道协议层到底发生了什么。我自己前阵子也是被几个诡异报错折腾到半夜,后来耐下心把 MCP 底层的 JSON-RPC 机制和生命周期整个过了一遍,才算是把“能用”变成“会用”。
这篇文章不聊那些花里胡哨的场景,就老老实实把 MCP 协议底层最核心的两件事讲透:一是 JSON-RPC 这套消息机制到底怎么运作,二是 MCP 从建立会话到关闭的整个生命周期怎么流转。适合正在写 MCP server、做客户端接入、或者在 Cursor、Claude Desktop 这类工具里调试 MCP 服务的人。看完之后,再遇到报错,你至少知道该看消息里的哪个字段,该去查哪个阶段的状态。
1. MCP 为什么选 JSON-RPC:协议栈的设计逻辑
1.1 MCP 协议栈长什么样
MCP(Model Context Protocol)本身不是一套从头发明的通信协议,它更像是在现有传输层之上,定义了一套“语义化”的交互规则。按照官方文档的分层,MCP 从上到下大致是:
- 应用层语义:工具(Tools)、资源(Resources)、提示词(Prompts)、采样(Sampling)这些高层抽象。
- 协议层:基于 JSON-RPC 2.0 的消息规范,包括请求、响应、通知,以及错误码约定。
- 传输层:目前最常见的是 stdio(标准输入输出)和 Streamable HTTP,再加上早期的 SSE(Server-Sent Events)。
很多人第一次接触 MCP,是在配置文件里写一个command和一个args数组,让客户端去拉起一个本地进程,然后用 MCP SDK 把两边接上。这种模式下,传输层其实就是 stdin/stdout,客户端往子进程的标准输入写 JSON 消息,子进程把处理结果写到标准输出。而 MCP 所有高层能力,最后都落在 JSON-RPC 消息上。
理解这一点特别重要,因为网上不少教程把 MCP 说得很玄,实际上你拆开任何一条 MCP 交互,底层就是“一问一答”的 JSON 文本。只要抓住这个主线,后面什么生命周期、能力协商,都是在这个框架上加规则。
1.2 JSON-RPC 相比 REST、gRPC 的取舍
既然 MCP 要定义一个客户端和服务端交互的协议,为什么选 JSON-RPC 而不是 REST 或者 gRPC?我自己一开始也疑惑过,后来在写 server 的时候才体会过来。
REST 的问题是“语义成本”太高。一个 REST API 需要约定 URL 设计、HTTP 方法、状态码、鉴权方式,还需要考虑幂等、缓存这些 HTTP 层的东西。MCP 的场景里,客户端和服务端是进程内通信或者点对点通信,根本没有必要为每个操作设计一套资源路径。比如调用工具,在 REST 里你可能要设计POST /tools/{name}/invoke,还需要考虑返回码规范;而在 JSON-RPC 里就是一条带method字段的消息,方法名本身就携带了语义。
gRPC 很高效,也有 schema 约束,但它的缺点是重。需要定义.proto文件,需要代码生成,需要处理 HTTP/2 的复杂度,对轻量级客户端和本地进程通信来说太笨重了。MCP 的目标是让各种语言、各种运行环境都能快速接入,JSON-RPC 的“纯文本 + 简单规范”几乎是零门槛。你甚至不需要任何 SDK,随手用 Python 的json模块拼一个请求字典,就能完成一次协议交互。
还有一个隐藏原因:可调试性。JSON-RPC 的消息就是纯文本 JSON,出了问题可以直接打印出来看,不需要借助复杂的抓包工具。这对于一个面向开发者生态的协议来说是巨大的优势。我调试 MCP server 时,最常用的手段就是在 stdio 层打印原始消息,一眼就能看出是参数传错还是阶段不对。
2. JSON-RPC 消息机制逐层拆解
2.1 请求、响应、通知三类消息的格式
JSON-RPC 2.0 规范本身不复杂,核心就是三种消息:请求、响应、通知。MCP 完整沿用了这套分类。
请求消息必备三个字段:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_weather", "arguments": { "city": "Shanghai" } } }id是客户端生成的递增标识,服务端响应时会把同一个id带回来,客户端靠它配对请求和响应。method是操作名,MCP 里统一用“资源类型/动作”的命名风格,比如tools/list、resources/read、prompts/get。
响应消息有两种形态,成功时返回result,失败时返回error:
{ "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "上海,多云,24℃" } ] } }{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32602, "message": "Invalid params", "data": { "detail": "city is required" } } }通知(notification)是第三种消息,它没有id,服务端也不需要回任何东西。MCP 里最典型的通知就是notifications/initialized,客户端在完成握手之后发一条通知告诉服务端“我已经准备好了”,服务端收到后不需要应答。
为什么 MCP 要原封不动保留 JSON-RPC 的通知机制?因为有些流程是单向的、不需要确认的,比如客户端初始化完成这件事,本质上是一次信号广播。如果把它设计成请求,服务端还得回一条无意义的响应,反而增加复杂度。
2.2 Method 命名空间与参数组织
MCP 的 method 命名不是随意取的,它基本遵循“域/动作”的两段式结构。tools/*管工具,resources/*管资源,prompts/*管提示词,logging/*管日志,sampling/*管采样。这样设计的直接好处是:不同域的 method 不会互相冲突,客户端在收到消息时也可以根据前缀快速路由。
参数组织这方面,JSON-RPC 本身允许用数组或对象传参,但 MCP 规范强制使用“按名参数”(named parameters),也就是params必须是一个对象。这一点我在写 server 时深有体会。如果按位置传参,一旦协议版本升级增加新参数,所有老客户端都要跟着改;用对象传参,服务端可以只读取自己关心的字段,忽略其他字段,兼容性好很多。
举个例子,tools/call的参数里有name和arguments两个字段。name是工具名,arguments是工具运行时实际接收的参数对象。工具本身的参数结构完全由 server 定义,协议层不关心。这就形成了一种“两层参数”的设计:协议层关心的是name和arguments的包装,工具层关心的是arguments内部的内容。理解这个分层,你在写 MCP server 把工具函数暴露出去的时候,就知道哪里该做参数校验、哪里该做协议兼容。
2.3 错误码设计与批量请求的实现边界
JSON-RPC 2.0 标准保留了一批错误码:-32700解析错误、-32600无效请求、-32601方法不存在、-32602参数无效、-32603内部错误,还有-32000到-32099这段留给服务端自定义的服务器错误。MCP 在这个基础上又补充了一些错误码,比如-32001表示资源不存在、-32002表示工具执行失败等,具体可以查协议文档。
关于批量请求(batch),JSON-RPC 规范是支持的,允许把多个请求放在一个 JSON 数组里一次发送。但 MCP 自己的规范明确说:当前版本的 MCP 不支持批量请求。这一点很容易被忽略,因为很多人读到 JSON-RPC 的规范就默认 MCP 也支持。
为什么要禁用?我推测和状态管理有关。MCP 生命周期对消息顺序有强约束,比如初始化握手完成之前,工具调用是不允许的。如果允许批量请求,可能出现一个数组里既有 initialize 又有 tools/call 的混乱情况,协议的状态机就很难维护。而且 stdio 传输层天然是“逐行读取”的,一个 JSON 数组消息虽然也能解析,但对实现者来说会多出很多边角问题。现阶段不考虑批量,反而让两边实现都干净。
3. MCP 生命周期:从握手到关闭的状态机
3.1 生命周期各阶段与允许的操作
MCP 的生命周期可以粗暴地分成四个大阶段:未初始化(Pre-initialized)、初始化中(Initializing)、已初始化(Initialized)、关闭中(Shutdown)。
- 未初始化:连接刚建立,客户端唯一能做的事就是发
initialize请求。除此之外,任何其他请求发过去,服务端都应该返回错误。 - 初始化中:客户端发了
initialize请求,正在等待服务端返回协议版本和能力信息。这个阶段同样是“只允许 initialize”的状态。 - 已初始化:客户端收到
initialize响应后,发送notifications/initialized通知。这一步完成之后,双方才进入正常操作阶段,可以开始tools/list、tools/call、resources/read这些常规调用。 - 关闭:客户端或服务端断开连接,整个会话结束。
这里有个特别容易踩的坑,就是很多人写客户端的时候,发了initialize拿到响应就立刻去调工具,跳过了notifications/initialized通知。在严格实现的服务端上,这会被直接拒绝,返回类似“Server not initialized”的错误。
为什么必须要有“先发通知再调工具”这个设计?我后来理解了,这是为了让服务端有“最后确认”的时机。响应initialize只是表明服务端接收了握手信息,但服务端可能还需要完成一些内部初始化工作,比如加载配置、建立数据库连接。客户端发notifications/initialized的时机,恰到好处地给了服务端一个缓冲:收到这条通知时,服务端知道自己可以开始处理业务请求了。
3.2 Initialize 握手的实现细节
initialize请求的参数长这样:
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": { "tools": { "listChanged": true }, "resources": { "subscribe": true } }, "clientInfo": { "name": "my-client", "version": "1.0.0" } } }protocolVersion是客户端声明的协议版本,服务端在响应中会返回它支持的版本。如果两边版本不一致,有的实现会选择返回自己支持的版本并继续,有的会直接报错。我自己遇到过服务端返回protocolVersion与自己发送的不一致,导致客户端 SDK 抛异常的情况。现在的做法是:作为 client 端,声明一个自己能兼容的版本列表,优先使用服务端支持的版本。
capabilities字段是能力协商的关键。客户端在initialize里告诉服务端“我支持哪些特性”,比如是否支持tools、resources、prompts、sampling。服务端在响应里也会返回自己的能力声明:
{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2024-11-05", "capabilities": { "tools": { "listChanged": true } }, "serverInfo": { "name": "my-server", "version": "2.3.0" }, "instructions": "这是一个示例 MCP 服务端" } }这个能力协商机制非常像 HTTP 的内容协商。两端不是简单地对齐版本,而是互相通告自己有什么、要什么,后续所有消息都在这个协商结果下进行。比如客户端声明了sampling能力,服务端在生成过程中才敢发起采样请求给客户端;如果没声明,服务端就不应该用这个功能。
3.3 Capabilities 能力协商如何影响后续调用
我在写 MCP server 时,有一段时间对 capabilities 不太在意,觉得反正自己控制的客户端,用不用都行。后来把 server 接到第三方客户端上,才发现客户端在一开始就通过 capabilities 告诉我对哪些功能感兴趣。
举个例子,如果我在 server 端实现了tools/listChanged能力,客户端初始化时也会声明自己是否想监听工具列表变化的通知。如果客户端声明了,我才能在工具列表变化时主动发notifications/tools/list_changed通知;如果客户端没声明,发了反而可能被忽略。这个机制在高版本协议里越来越严格,很多 MCP SDK 甚至会在客户端未声明的情况下直接对不合规的通知报错。
所以,写 server 时不要想当然地认为“我实现了某个功能,就可以随时推送”。所有推送类行为都必须回到初始化阶段的能力协商结果里去审视。这一点和做 WebSocket 推送服务很像,客户端没有订阅,服务端硬推,最后就是一堆无谓的解析错误。
4. 典型调用流程:工具调用在整个生命周期中的位置
4.1 从 notifications/initialized 到 tools/list 的消息轨迹
我们还原一次完整的工具发现流程。客户端连上服务端后:
第一条消息是客户端发initialize请求,服务端返回协议版本和能力。第二条消息是客户端发notifications/initialized通知。完成这两步之后,客户端就可以发tools/list请求了。
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }服务端返回一组工具描述,每个工具包含name、description、inputSchema字段。其中inputSchema是 JSON Schema 格式的参数结构描述,客户端靠它来决定如何向模型展示工具参数、如何校验用户的输入。
为什么要先tools/list再调用?很简单,因为客户端必须知道有哪些工具、工具需要什么参数,才能让大模型选择工具并生成对应参数。MCP 协议在这里做了一个很明智的决定:工具发现是显式的,而不是靠服务端文档约定。这相当于在运行时建立了一份“API 目录”,客户端动态读取,天然支持工具热更新。
4.2 tools/call 的消息轨迹还原
工具发现完成之后,用户让模型“查一下上海的天气”,模型决定调用get_weather工具,客户端就发出tools/call请求。服务端执行工具,把结果包装成 MCP 的内容块(content block)返回。
内容块的结构是 MCP 里非常核心的设计。一个标准响应长这样:
{ "jsonrpc": "2.0", "id": 3, "result": { "content": [ { "type": "text", "text": "上海,多云,24℃" } ], "isError": false } }content是一个数组,支持多种类型,比如文本text、图片image、资源链接resource等。isError字段用来区分“调用成功但结果是错误信息”和“调用本身失败”。前者是业务错误,比如工具执行后返回{"error": "参数越界"},isError置为true,但 RPC 层仍然是成功响应;后者是协议错误,直接走error字段。
这个区分非常关键。我见过不少人在工具里抛异常,导致整个 RPC 响应变成error,客户端直接中断流程。正确的做法是:工具内部的业务错误要通过isError返回,而不是通过error抛出。协议层错误只留给“消息格式有问题”“方法不存在”“参数不符合协议规范”这类的系统级故障。
4.3 传输层对生命周期的影响
stdin/stdout 传输和 HTTP 传输在生命周期上有一些细微差别。stdio 模式下,连接的生命周期与子进程的生命周期绑定:客户端启动子进程,进程退出连接就断。整个生命周期管理非常直观。
HTTP 传输模式下,MCP 引入了会话(session)概念。客户端通过请求头或 cookie 标识自己的会话,服务端需要维护会话状态。生命周期不再只是“进程活着就能通信”,还要考虑会话超时、重连、会话与 sesssion 之间的状态隔离。我自己在做远程 MCP 服务时,遇到最多的就是会话过期后客户端没有感知,继续发送业务请求,服务端返回 401 或会话错误,双方状态就对不上了。
这类问题在本地 stdio 模式下几乎不会出现,但只要你把 MCP server 部署成远程服务,就必须在客户端加上会话续期和重连接的逻辑。目前 Streamable HTTP 传输还在持续完善,相关 SDK 的接口也经常调整,接入前最好确认你用的 SDK 版本对会话管理支持到了什么程度。
5. 生命周期错误的常见来源与排查实录
5.1 自己遇到的几种生命周期错误
先列几个我在实际使用中真实踩过的错误,每一个都让我排查了不短时间。
第一种是“Server not initialized”或者变体的报错。原因在前面提过,客户端跳过notifications/initialized通知,直接调用工具。很多 MCP SDK 在封装时隐藏了初始化细节,但如果你用原生 JSON-RPC 实现,很容易漏掉这一步。我自己用 Python 写过一个最小的客户端,当时就是觉得“initialize 已经有响应了,为什么还不行”,最后对照协议文档才发现漏了通知。
第二种是协议版本不匹配。服务端返回的protocolVersion字段和我客户端声明的不一致,SDK 直接拒绝继续握手。这个问题尤其容易出现在服务端更新版本但客户端没更新的场景。排查方法是看 initialize 响应的protocolVersion字段,确认两边都支持同一版本。
第三种是 JSON-RPC 的id没对上。有些 SDK 在发送新请求时不递增 id,或者异步场景里 id 复用,导致响应配对错误。虽然这不是 MCP 特有的问题,但在生命周期早期更容易爆发,因为初始化阶段的请求比较密集。
5.2 怎么抓包与复现问题
调试 MCP 问题时,最好的方式是在传输层直接看原始消息。如果是 stdio 模式,你可以在启动 MCP server 的配置里加一个包装脚本,把 stdin 和 stdout 的消息各打印一份。也可以直接用 Python 之类写一个中间代理进程,透传的同时打印 JSON。
如果用的是官方 SDK,很多 SDK 也提供了日志开关。比如 Python SDK 里可以配置logging相关消息,服务端会通过logging/message通知把日志发给客户端。但从底层的角度讲,最可靠的是从传输层直接看,因为 SDK 日志可能已经过滤掉了一些你没有预期的消息。
我排查生命周期问题时,习惯在三个关键点打日志:客户端发送initialize之前、发送notifications/initialized之后、第一次业务调用之前。把这三个时间点的消息拼接起来,就能判断生命周期走到哪一步出了问题。
还有一种复现问题的好办法:写一个最小复现脚本,用原始 JSON-RPC 消息手动走一遍流程。不需要引入任何复杂框架,就用json库拼消息,通过 subprocess 和 MCP server 通信。这样能最大程度排除 SDK 的干扰,确认问题是出在协议层还是你的业务代码层。
5.3 符合规范的生命周期检查清单
下面这个清单是我自己在写完一个 MCP server 之后会过一遍的,防止低级错误:
- 连接建立后,服务端是否只允许
initialize,其他方法是否返回明确错误? initialize请求参数里是否包含protocolVersion、capabilities、clientInfo?- 服务端是否正确返回
serverInfo和capabilities? - 客户端是否在收到
initialize响应后发送了notifications/initialized? - 服务端是否只有在收到
notifications/initialized后才对外提供业务能力? - 工具业务错误是否通过
isError返回,而不是通过 RPCerror返回? - 请求的
id是否在所有请求中保持一致递增?
这几点看起来基础,但很多实现都会在某个细节上栽跟头。花十分钟过一遍,比线上报错再排查效率高得多。
6. 工具与开发经验:从零调试 MCP Server/Client
6.1 用好 MCP Inspector 这类调试利器
官方提供了一个叫 MCP Inspector 的调试工具,可以在浏览器里连接你的 MCP server,手动发请求、看响应、查看工具列表、测试工具调用。对于协议层的学习和排查,它是目前最好用的工具之一。
使用方式通常是先启动一个 MCP server(比如通过npx运行一个.mcp配置或其他方式),然后用 Inspector 连接。Inspector 的好处是它把初始化握手、能力协商、工具发现这些过程都可视化了,你可以清楚地看到生命周期每一步发生了什么。
对于写 server 的人来说,Inspector 最实用的地方是模拟客户端行为。你可以手动修改方法名、参数、能力声明,验证 server 对各种异常输入的响应是否符合预期。想测生命周期边界,就故意跳过某个步骤,看看 server 会不会报错、报什么错。
6.2 常见问题速查表
我将自己排查过程中经常遇到的问题整理成一张表,方便快速定位:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 连接后服务端无响应 | 传输层未就绪或 stdio 缓冲问题 | 检查启动命令、环境变量、是否输出了非 JSON 内容 |
| 报错 Server not initialized | 客户端未发送notifications/initialized或顺序错误 | 检查初始化握手流程是否完整 |
| 调用工具报 method not found | 服务端未实现该工具或未正确注册 | 先调用tools/list确认工具是否存在 |
| 参数一直校验不过 | inputSchema与实际参数不匹配 | 检查 JSON Schema 定义,重点看必填字段和类型 |
| 返回结果模型无法理解 | 内容块类型不对或content结构异常 | 检查返回的content数组结构是否符合 MCP 格式 |
| 会话建立后超时断开 | 远程 MCP 会话过期或 keepalive 配置不对 | 检查服务端会话过期设置,客户端加心跳 |
| 请求 id 配对错乱 | 异步请求时 id 被复用或未递增 | 检查客户端 id 管理逻辑 |
6.3 几个实用细节和扩展建议
最后分享几个细节,都是实际开发中容易忽略的点。
第一,MCP server 的 stdout 绝对不能有额外的打印。因为 stdout 是协议通道,你一旦在里面输出调试日志,客户端的 JSON-RPC 解析器就会报“无效 JSON”错误,且大部分情况下客户端不会给出明确提示,只会说“连接失败”或者“无法读取消息”。调试信息要全部写到 stderr。
第二,能力声明的范围宁小勿大。不要只因为你的 server 顺手实现了某个功能,就把它声明到capabilities里。凡是声明出去的,客户端就可能在后续流程中调用对应协议端点。如果实现不够稳,就会把不成熟的功能暴露给所有客户端。
第三,关注 MCP 规范的版本变化。MCP 现在还在快速迭代,initialize的参数、capabilities的结构、传输层协议都可能调整。建议在自己项目的依赖锁定文件里固定 SDK 版本,新版本需要验证后再升级,不要盲目跟着最新版走。
我个人的体会是,MCP 这套协议真正难的不是某个单一知识点,而是把“JSON-RPC 消息模型”和“生命周期状态机”这两条线串在一起理解。你只有知道当前处于哪个阶段、能发哪些消息、不能发哪些消息,才能在写代码和排查问题时做到心中有数。希望这篇梳理能帮你少走一些弯路。