先说个有意思的现象很多人一搜“MCP 生命周期”结果出来一半是 Vue 生命周期一半是 Rust 的所有权生命周期反而把真正想问的 MCP 协议本身给淹没了。MCPModel Context Protocol确实是现在 AI 工具链里最绕不开的一个缩写Cursor、Claude Code、Trae、Cherry Studio 这些客户端都在接它但天天听到“MCP Server”“MCP 工具”不代表真的理解它。我把协议规范和实际抓到的报文对照着看了几遍之后最大的感触是MCP 这套东西从宏观上看是“给 AI 接外部工具”但一旦打开连接、把消息打出来看底层流动的全是 JSON-RPC 2.0 的消息。这篇就把 MCP 最底层的 JSON-RPC 机制、初始化协商过程、以及从建立连接到关闭的完整生命周期讲透。适合已经简单跑通过 MCP 示例、但想知道“它内部到底在干嘛”的人如果你是零基础也能跟着报文示例一步步建立起整体认知。1. 先搞清楚MCP 为什么偏要选 JSON-RPC1.1 MCP 解决的是 AI 连接外部世界的“最后一公里”大模型本身是没有手也没有脚的它能回答问题、能写代码但没法替你去查数据库、操作浏览器、提交表单。于是各家都做了自己的工具调用方案OpenAI 有 function calling早期 LangChain 有自己的一套工具协议Claude 生态又有 Anthropic 自己的 tool use 格式。结果就是每换一个模型厂商、每换一个客户端工具代码就要重写一版非常痛。MCP 做的事情就是把这个“模型 ↔ 工具”之间的通信方式做成一个公开标准。它定义了两端MCP Host宿主也就是 Claude Code、Cursor 这类客户端和 MCP Server提供工具/资源/提示词的服务端。Host 和 Server 之间不需要关心对方是什么语言写的、跑在哪台机器上只需要遵守同一个消息协议就行。你可以把 MCP 类比成 USB-C 接口——以前各种外设各用各的线现在大家统一一口插上就用。1.2 JSON-RPC 2.0轻到极致的远程调用协议JSON-RPC 2.0 这个协议本身已经有十几年历史了是一套基于 JSON 的远程调用协议。它最大的特点就是“轻”没有复杂的 XML 命名空间没有一堆必须继承的基类也不需要提前定义接口描述文件就是用 JSON 对象表示“我要调用哪个方法、传什么参数、返回什么结果”。MCP 选择它非常合理。一方面MCP Server 可能是一个 Python 写的后端服务也可能是一个 Node.js 写的本地脚本客户端还必须支持 TypeScript、Python、Java 等多个 SDK找一个“什么语言都能轻松解析”的消息格式是刚需。另一方面MCP 本身的核心交互模式就是“请求-响应”这正好和 JSON-RPC 的模型完全吻合。你在 MCP 里调一个工具本质上就是向服务端发一条“调这个方法”的请求服务端处理完回一条“这是结果”的响应仅此而已。对比一下早期的 XML-RPC 和 SOAP你就明白为什么选它了XML 报文冗长、解析慢而且强类型定义那一套东西在 AI 工具调用的场景里反而成了负担。MCP 需要的是灵活、跨语言、能快速 debug 的协议JSON 是眼下最合适的选择。提示JSON-RPC 2.0 规范本身只有不到一页纸的约束MCP 则是在这个极简协议之上补充了“方法名约定”“生命周期状态”“能力协商”等规则。所以看 MCP 报文时凡是符合 JSON-RPC 2.0 的部分都很好理解真正要花心思的是 MCP 自己加的那些层。1.3 一条 MCP 消息到底长什么样JSON-RPC 2.0 的消息结构非常固定。一条请求消息长这样{ jsonrpc: 2.0, id: 1, method: tools/call, params: { name: get_weather, arguments: { city: 北京 } } }字段含义jsonrpc固定字符串2.0表示协议版本防止和 1.x 版本混淆。id请求的唯一标识可以是数字、字符串或者 null。响应里会带上相同 id让调用方知道这是哪次请求的结果。method要调用的方法名MCP 里都是类似initialize、tools/list、resources/read这种带命名空间的字符串。params方法对应的参数可选通常是一个对象。服务端处理完请求后返回一条响应{ jsonrpc: 2.0, id: 1, result: { content: [ { type: text, text: 北京 晴 25°C } ] } }如果出错了响应里的result会换成error{ jsonrpc: 2.0, id: 1, error: { code: -32602, message: Invalid params: city is required } }这个结构是整个 MCP 通信的最小单元。后面无论多复杂的功能拆到底都是这一条一条的 JSON-RPC 消息在流动。2. 请求、响应、通知MCP 里的三种“对话姿势”2.1 一问一答请求/响应是绝对主力MCP 中绝大多数方法都走请求/响应模式。客户端发一条请求服务端必须给一条响应而且这个响应的id必须和请求里的id完全一致。这个规则看起来简单但在实际并发场景下很容易出问题如果你并发发了好几条请求id 分别是 1、2、3但服务端响应时把 2 的响应结果标成了 1那么客户端就会把两条请求的结果弄混。我用 Node.js 写过简单的调试脚本一开始为了省事直接用的随机字符串当 id后来排查问题的时候就发现日志里根本分不清哪条响应对应哪条请求。建议 id 在同一个连接内使用严格递增的整数既方便日志追踪也方便做超时控制。下面是我常用的一个伪代码流程let requestId 0; function sendRequest(method, params) { const id requestId; // 记录当前请求等待响应 pendingRequests.set(id, { method, resolve, reject }); transport.send(JSON.stringify({ jsonrpc: 2.0, id, method, params })); // 设置超时 setTimeout(() { if (pendingRequests.has(id)) { pendingRequests.delete(id); reject(new Error(Request timeout: method)); } }, 30000); }这样做的好处是任何一条响应回来只需要查id就能知道它属于哪个请求逻辑非常清晰。2.2 单向广播通知类消息不需要回复除了请求/响应JSON-RPC 2.0 还有一种特殊的消息叫“通知”Notification。通知和请求长得几乎一样唯一的区别就是没有id字段。因为没有 id服务端收到通知后不需要返回任何响应也不会知道自己这条消息到底有没有被正确处理。MCP 里最常见的一个通知是notifications/initialized它在客户端和服务端完成初始化协商之后发出作用是告诉服务端“我已经准备好进入正常工作了”。这类消息没有对应的响应你如果非要等一个回执那永远等不到。另一个常见通知是notifications/cancelled当客户端要取消一个正在执行中的长任务时会主动给服务端发一条取消通知。我刚开始调试时犯过一个经典错误在代码里手动构造了一条notifications/initialized然后又写了等待响应的逻辑结果直接超时。记住一个判断原则方法名带notifications/前缀的首字母或者干脆看 JSON 里有没有id没有 id 就是通知不要去等响应。2.3 批处理规范支持但 MCP 明确不用JSON-RPC 2.0 规范里还定义了一种“批处理”模式把多个请求放在一个 JSON 数组里一次发出例如[ {jsonrpc: 2.0, id: 1, method: tools/list}, {jsonrpc: 2.0, id: 2, method: resources/list} ]这种模式在 RPC 领域并不罕见能显著减少网络往返。但 MCP 的规范里明确做了约束MCP 不使用 JSON-RPC 批处理连接双方不能发送这种数组形式的消息。原因是 MCP 的很多方法之间有状态依赖比如必须先初始化才能调用工具再加上很多 MCP Server 还要支持流式响应和取消机制把这些逻辑叠加到批处理模型上会使复杂度成倍上升但收益并不大。所以你在用 MCP SDK 时完全不需要实现数组形式的批处理专心把单条消息处理好就行。2.4 错误码标准错误码和 MCP 的语义扩展JSON-RPC 2.0 定义了一套固定的错误码MCP 基本沿用了这套体系同时预留了服务器自定义错误范围。常用错误码整理如下错误码名称含义-32700Parse errorJSON 解析失败发给服务端的不是合法 JSON-32600Invalid Request消息结构本身不合法比如缺jsonrpc字段-32601Method not found方法名不存在服务端没实现这个能力-32602Invalid params参数不合法比如缺少必填字段、类型错误-32603Internal error服务端内部异常-32000 ~ -32099Server errorMCP Server 自定义错误范围MCP 的实现中很多错误都是在Server error这个范围里做语义扩展的。比如请求一个不存在的资源路径时服务端可能返回-32002错误信息里写 “Resource not found”。这种错误码虽然不属于 JSON-RPC 标准强制约束的范畴但实践中已经成为事实约定。调试时看到这类错误码不要慌直接去服务端日志里查对应实现。注意id为null的请求在 JSON-RPC 2.0 中表示“客户端不关心响应”这实际上就是通知的一种写法。MCP 规范更推荐直接用“没有id字段”的消息表达通知避免歧义。3. 生命周期MCP Server 从初始化到关闭的全过程3.1 一切从 initialize 开始MCP 的生命周期模型非常严格大体上分为三个阶段未初始化、已初始化、已关闭。连接刚建立的时候双方都处于未初始化状态此时客户端唯一能做的合法请求就是initialize。initialize请求携带的信息很关键{ jsonrpc: 2.0, id: 0, method: initialize, params: { protocolVersion: 2025-03-26, capabilities: { roots: { listChanged: true }, sampling: {} }, clientInfo: { name: my-ai-client, version: 1.0.0 } } }看到了吗这个请求并不是在说“帮我干某件事”而是在说“我是什么客户端、支持哪个协议版本、我具备哪些能力”。服务端拿到之后会根据这些信息决定是否兼容、如何协商。有一次我犯了个低级错误连接建立后第一件事直接发了tools/list服务端直接秒回一个错误。后来查规范才知道MCP 规定在完成initialize之前客户端不能发送任何其他请求这属于硬性约束。要让一个 MCP Server 正常工作第一件事永远是initialize。3.2 capabilities 与 protocolVersion双方怎么“谈条件”服务端收到initialize后会返回一段 JSON包括服务端支持的协议版本、服务端能力列表、服务端信息和指令。响应大概是这样的{ jsonrpc: 2.0, id: 0, result: { protocolVersion: 2025-03-26, capabilities: { tools: { listChanged: true }, resources: { subscribe: true, listChanged: true }, prompts: { listChanged: true }, logging: {} }, serverInfo: { name: my-mcp-server, version: 0.1.0 }, instructions: 这个服务提供天气查询和待办事项管理能力。 } }这里有两个关键点要理解。第一点protocolVersion的协商。如果客户端发的是2025-03-26服务端支持2024-11-05那服务端会返回自己支持的最新版本2024-11-05客户端看到之后要么升级自己的版本要么终止连接。网上很多教程在代码里硬编码一个协议版本字符串其实不太规范——正确的做法是先读服务端返回的版本再决定后续通信是否继续。第二点capabilities就是“我会什么”的清单。客户端看到服务端返回的capabilities里有tools字段才会去调用tools/list如果服务端没声明resources能力客户端就不应该尝试resources/read。这相当于一个互相同意的能力契约超出能力范围的调用都可能报错或者行为异常。3.3 initialized 通知协商完成后的“确认信”initialize请求/响应完成之后能不能直接开始调工具还不行少一步客户端必须发送notifications/initialized通知给服务端。{ jsonrpc: 2.0, method: notifications/initialized }这步操作在协议层面的意义很微妙。服务端完成initialize响应后其实已经知道了客户端的能力和版本但客户端还必须主动告知“我知道你的能力了现在正式开始”。这是一种双向确认机制避免出现“服务端以为客户端知道了、客户端其实没收到响应”的错位情况。真实世界里很多 MCP SDK 在initialize响应返回后会自动帮你发这个通知你在使用高级客户端时感知不到。但如果自己写底层实现这一步非常容易漏。漏掉之后的表现很诡异工具列表能拿、资源能枚举但某些具体的调用会随机失败甚至服务端日志里报“lifecycle error”。这其实是协议状态机在那提醒你你现在还在“已经完成初始化但尚未确认”的中间状态。3.4 运行期工具、资源、提示词和日志完成初始化后MCP 就进入最活跃的阶段。这个阶段的主要操作我总结成一张表方法方向作用tools/list客户端→服务端获取服务端提供的工具清单tools/call客户端→服务端调用指定工具resources/list客户端→服务端获取可访问的资源列表resources/read客户端→服务端读取具体资源内容prompts/list客户端→服务端获取提示词模板列表prompts/get客户端→服务端获取具体提示词模板logging/setLevel客户端→服务端设置服务端日志级别ping任意方向保活探测检查连接是否存活运行期的设计有一个值得注意的点tools/call、resources/read这类方法可能会执行很长时间。比如一个工具要调外部 API响应可能要等几十秒。MCP 对这类场景提供了进度通知机制请求参数里的meta字段可以带上progressToken{ jsonrpc: 2.0, id: 3, method: tools/call, params: { name: long_running_task, arguments: {}, _meta: { progressToken: task-001 } } }服务端在执行过程中可以发送notifications/progress通知内容是进度百分比或者具体描述。这个机制对用户体验的提升非常明显如果客户端端只是干等用户看到的一直是空白页面用上进度通知就能展示实时的任务状态。还需要注意的是运行期客户端可以随时发送ping。MCP 的传输层是长连接如果长时间没有消息往来任何一端都会怀疑连接已经死了。ping就是一个标准的保活手段收到ping的一方应该回一条空的result响应。我在写本地 stdio 通信时观察过很多 MCP Client 的实现基本每隔几十秒就会发一次ping。3.5 关闭优雅退出与异常断开MCP 协议里其实没有专门设计一个“关闭”方法。生命周期到最后的收尾动作通常就是直接关闭底层传输通道stdio 模式下退出子进程、关掉 stdin/stdoutHTTP 模式下结束会话或直接断开连接。但这里有一个实践上的坑关闭之前一定要先处理正在运行中的请求。如果客户端在某个工具还没执行完时直接把进程杀了服务端可能会产生死活锁或者资源泄漏。比较好的做法是设计一套“关闭前取消”机制先给服务端发notifications/cancelled取消还在执行的任务然后留出几秒的宽限期最后再断开连接。另外服务端如果检测到客户端的连接异常断开比如 EOF 或者超时应当主动做资源清理、终止正在执行的任务。很多 MCP Server 在实现时忽略了这个环节于是你会在服务器日志里看到大量僵尸进程挂在那里就是因为没响应对端断开事件。4. 传输层stdio 与 Streamable HTTP 的取舍4.1 stdio最朴素的进程间通信MCP 最早的传输方式之一就是 stdio客户端启动一个子进程然后把 JSON-RPC 消息写入子进程的标准输入同时从标准输出读取子进程的响应。每一条消息用换行符分隔一个 JSON 一行。这种方式的优点非常明显不需要监听端口、不需要处理 HTTP 协议、没有跨域问题也就没有暴露网络端口的风险。本地开发调试 MCP Server 时我强烈建议先用 stdio 模式把逻辑跑通因为问题最好定位——直接在终端里启动 server就能在控制台看到全部输入输出。但 stdio 的缺点同样明显子进程必须和客户端跑在同一台机器上无法远程调用而且一个 stdio 连接只能服务一个客户端没法做多路复用。所以它更适合本地工具场景比如“给 Claude Code 挂一个本地数据库查询工具”。4.2 Streamable HTTP跨机器访问 MCP 的现代方案随着 MCP 的应用场景不再局限于本地协议引入了 Streamable HTTP 传输方案。在这种模式下MCP Server 变成一个 HTTP 服务客户端通过 POST 请求发送 JSON-RPC 消息服务端以application/json返回响应也兼容text/event-stream的流式响应。Streamable HTTP 解决了一个很实际的问题MCP Server 可以部署在远程服务器上多个客户端通过网络访问同一个 Server。比如团队内部共享一个工具服务就不需要每个人都在本地启动一个进程了。但它也带来了新的复杂度需要处理会话 IDMcp-Session-Id头、HTTP 状态码、超时重试机制等等。4.3 传输层和 JSON-RPC 之间的“适配层”做了什么很多人会疑惑JSON-RPC 消息和传输层到底是什么关系简单说JSON-RPC 规定了消息的“内容格式”传输层规定了消息的“搬运方式”。MCP SDK 在中间做了一层适配发送方把 JSON 消息序列化成字符串交给传输层按特定格式包装接收方把字符串解析回 JSON 对象再分发给上层的生命周期状态机。这个适配层的重要性在普通场景下看不出来但一旦遇到“消息被拆包”“一行里塞了两个 JSON”“连接空闲被服务端断开”这类问题你就会意识到传输层除了搬运还负责维持消息边界。stdio 模式用换行符做边界所以要求每个 JSON 序列化后必须紧凑、不含裸换行符HTTP 模式则天然把每次请求/响应作为一个独立消息体边界问题由 HTTP 本身解决。我在自测时习惯写一个小调试工具角色类似于“中继”收到客户端 - 原样解码 - 打印 - 原样转发给服务端 收到服务端 - 原样解码 - 打印 - 原样转发给客户端用这种方式能直观地看到消息在 stdio 上是如何被一行一行切开、又是如何在两个进程之间“来回传递”的。对理解传输层的作用非常有帮助。5. 从零抓包一次工具调用的完整报文流5.1 初始化阶段三条消息打天下假设我们自己实现了一个极简 MCP Server提供查询订单状态的工具。客户端连接启动后第一条消息一定是initialize请求服务端回复协议版本和能力然后客户端再补一条notifications/initialized通知。整个初始化阶段其实就这三条消息{jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2025-03-26,capabilities:{},clientInfo:{name:debug-client,version:0.0.1}}} {jsonrpc:2.0,id:1,result:{protocolVersion:2025-03-26,capabilities:{tools:{listChanged:false}},serverInfo:{name:order-server,version:1.2.0}}} {jsonrpc:2.0,method:notifications/initialized}注意消息里没有多余空格这是为了在 stdio 模式下保证“一行一条”的边界清晰。很多初学者会直接用JSON.stringify(obj, null, 2)格式化后输出结果把换行符塞进了 JSON 字符串里导致服务端解析失败或消息提前断开。这是一个非常隐蔽的坑调试时如果发现服务端老是报 Parse error先检查是不是输出了带换行的格式化 JSON。5.2 工具发现客户端先看一眼“你有什么工具”初始化完成后客户端会向服务端发起tools/list以获取当前可用工具的描述信息。下面是一次完整的工具发现请求/响应{jsonrpc:2.0,id:2,method:tools/list,params:{}}{ jsonrpc: 2.0, id: 2, result: { tools: [ { name: get_order_status, description: 根据订单号查询订单当前状态, inputSchema: { type: object, properties: { order_id: { type: string, description: 订单号 } }, required: [order_id] } } ] } }这个步骤的作用很关键客户端一般用这个工具列表来决定如何把“用户的自然语言需求”映射到“调用哪个工具、填哪些参数”。现代模型大多能理解description和inputSchema的语义所以你写工具描述时信息要足够清晰参数约束必须准确——模型就是靠这些字段生成调用参数的。5.3 工具调用真正干活的时刻工具发现之后客户端构造tools/call请求传入工具名和参数{ jsonrpc: 2.0, id: 3, method: tools/call, params: { name: get_order_status, arguments: { order_id: A12345 } } }服务端执行完逻辑后返回一个结构相对标准化的结果{ jsonrpc: 2.0, id: 3, result: { content: [ { type: text, text: 订单 A12345 当前状态已发货预计明天送达 } ], isError: false } }content是结果正文可以包含多个文本块、图片块或资源链接isError用来区分“调用成功”和“业务级失败”。注意即使工具内部发生了业务错误比如订单不存在只要服务端正常处理了这次调用传输层就还是返回result而不是error只是isError会变成true。这个区别很多人没搞清楚导致他们在服务端把业务异常直接抛出来变成 JSON-RPC 的error响应结果客户端模型收到错误后体验很差因为模型无法从错误里提取文本内容。提示MCP 设计isError这个字段是有讲究的。它希望工具的业务逻辑错误也能以结构化的文本结果返回给模型让模型有机会面向用户解释这个错误而不是把内部错误码暴露出去。你自己开发 MCP Server 时推荐遵循这个原则。5.4 进度通知与取消长任务和用户的耐心如果工具执行时间很长客户端通常会在调用时带上progressToken这样服务端就可以通过notifications/progress向客户端汇报进度{jsonrpc:2.0,method:notifications/progress,params:{progressToken:task-001,progress:50,total:100,message:正在查询物流信息}}用户如果临时决定不想要这个结果了客户端可以发送notifications/cancelled{jsonrpc:2.0,method:notifications/cancelled,params:{requestId:3,reason:用户取消}}这里requestId要填的是被取消请求的 id也就是tools/call请求里的3服务端收到后如果还在执行应当主动中断并释放资源。我在实践中见过不少实现收到取消通知后只是把日志打了一下任务还在后台继续跑这会造成严重的资源浪费尤其是在执行 Python 数据脚本或 IA 推理类任务时。6. 常见问题与排查实录6.1 初始化超时服务端半天不回 initialize 响应现象客户端日志报Timeout waiting for initialize response连接建立后什么都没发生。排查思路先确认服务端是否真的收到了消息。stdio 模式下如果服务端启动时输出了非 JSON 内容例如 Python 的print调试信息、依赖库的 warning这些内容会混入标准输出通道客户端解析时直接出错。解决办法是把服务端所有的日志输出重定向到 stderr绝不能让日志污染 stdout。这是实现 stdio MCP Server 的第一条纪律。6.2 协议版本不匹配客户端和服务端谈不拢现象服务端返回的protocolVersion既不是客户端发的版本也不是客户端能接受的其他版本。常见于不同 SDK 版本实现的 Server 混用。项目里如果同时用modelcontextprotocol/sdk多个历史版本很容易出现这类问题。通用解决办法是客户端实现一个“版本回退”逻辑收到服务端返回的版本号后如果兼容就继续不兼容就给用户一个明确的错误信息而不是像某些实现那样直接静默失败。我自己遇到过一次是客户端发2025-03-26而服务端只支持2024-11-05客户端又没有回退机制最终所有工具调用全部失败排查了很久才在 SDK 日志里看到版本协商异常。6.3 请求发太早初始化还没完成就调用工具现象客户端刚连接成功就立刻发tools/list服务端返回错误码或者直接断开连接。这是生命周期状态机最常见的违规。解决方式是在代码里维护一个显式的状态位比如NOT_INITIALIZED、INITIALIZING、READY、CLOSED只有状态是READY时才允许发送普通请求。像notifications/initialized通知则只能在INITIALIZING状态下发送发完即进入READY。这种状态机逻辑虽然笨但能挡住绝大多数协议违规问题。6.4 并发请求 id 用重了响应全串线现象两个请求的响应内容对调了A 请求的结果跑到 B 请求头上。原因几乎都是客户端没有保证请求id唯一。解决方式用一个自增计数器管理id即可不要用随机数更不要用时间戳高并发下重复概率比你想象的大。响应回来时最稳妥的判断是拿 id 到pendingRequests的 Map 里查找而不是用数组遍历。6.5 Streamable HTTP 传输下的 header 问题现象通过 HTTP 方式连接 MCP Server 时能初始化成功但调用工具后收不到响应服务端日志里连请求记录都没有。这类问题通常是请求没有正确携带Mcp-Session-Id头。Streamable HTTP 使用会话 ID 来区分不同的客户端会话第一次 initialize 请求时还没有会话 ID服务端在响应头里返回一个新的Mcp-Session-Id之后的所有请求都必须带上它。很多 SDK 把初始化响应的 header 解析逻辑藏得很深如果你自己封装 HTTP 调用一定要在initialize响应之后把Mcp-Session-Id存下来后续每个请求都加到 header 里。7. 写在最后一点实操经验协议这东西光看规范永远记不牢一定要自己动手抓报文。我最推荐的方式是随便选一个 MCP SDK本地跑一个最简单的 echo server然后用官方调试工具去连它把所有收发消息打印到终端。看几条真实的initialize、tools/list、tools/call报文之后你对 MCP 的理解会立刻超过一大半只看过视频教程的人。另外一个建议是不要在项目里盲目引入一堆高级封装先试着用原生 SDK 实现一次完整的工具调用链路。你会在过程中踩到协议版本、超时、生命周期状态、stdout 污染、会话 ID 这些坑而这些坑恰恰是 MCP 协议设计里最值钱的经验。把一次调用完整跑通再回头用那些封装好的框架你会觉得它们做的事情清晰得多了。