MCP 传输层:stdio 与 Streamable HTTP
搞清楚 MCP 的两种消息传输方式——本地用 stdio、远程用 Streamable HTTP,以及怎么选。
MCP 的关键不是“连上就能用”,而是先走严格生命周期:版本协商、能力交换、initialized 通知,然后才能进入正常通信。
内容摘要
很多人第一次看 MCP,会把它想成“客户端和服务端一连上就开始互调方法”。但官方规范在 Lifecycle 章节里定义得非常严格:**MCP 连接不是随便开聊,而是先走一段有明确顺序的生命周期。**
很多人第一次看 MCP,会把它想成“客户端和服务端一连上就开始互调方法”。但官方规范在 Lifecycle 章节里定义得非常严格:MCP 连接不是随便开聊,而是先走一段有明确顺序的生命周期。
官方规范把整个过程拆成三段:
这里最关键的不是名字,而是一个硬约束:初始化阶段必须先完成,后面的正常通信才成立。
| 要素 | 作用 |
|---|---|
initialize 请求 | 客户端先发起版本与能力协商 |
| 能力交换 | 双方声明自己支持哪些可选能力,比如 tools / resources / prompts / roots |
notifications/initialized | 初始化成功后,客户端必须再发一个已初始化通知,表示可以进入正常通信 |
| 正常操作阶段 | 这时才开始 tools/list、resources/read、prompts/get 等日常交互 |
| 关闭阶段 | 会话结束,连接终止,回到未初始化状态 |
initialized很多协议只做一次握手,MCP 为什么还要补一个通知?原因很实在:光拿到 initialize 的响应,不代表客户端已经准备好进入正常工作流。
规范要求 initialized 明确告诉 Server:
这相当于把“握手完成”和“开始正式工作”分成了两个明确时刻。
initialize规范最新页明确说:
initializeprotocolVersioncapabilitiesclientInfo最小结构大致像这样:
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {
"roots": {
"listChanged": true
}
},
"clientInfo": {
"name": "ExampleClient",
"version": "1.0.0"
}
}
}
服务端响应里至少会返回:
serverInfo规范页里的典型结构是:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": {
"logging": {},
"prompts": {
"listChanged": true
},
"resources": {
"subscribe": true,
"listChanged": true
},
"tools": {
"listChanged": true
}
},
"serverInfo": {
"name": "ExampleServer",
"version": "1.0.0"
}
}
}
这一步的核心不是“告诉你服务端叫什么”,而是:双方在这里正式确认这一条连接上到底有哪些能力可用。
notifications/initialized规范在 latest lifecycle 页里写得非常硬:
After successful initialization, the client MUST send an
initializednotification to indicate it is ready to begin normal operations.
也就是:
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
只有这条通知之后,才算真正进入正常通信。
这时候才轮到:
tools/listtools/callresources/listresources/readprompts/listprompts/getroots/list这些日常交互。
listChanged 到底有什么用MCP 生命周期里一个很实用的设计,是很多能力都可以在 initialize 阶段顺带声明:
listChanged比如在 Roots 里,官方文档写得很明确:
capabilities.roots 声明listChanged 表示后续 roots 变化时,客户端会不会发 notifications/roots/list_changed这说明生命周期不只是“开场寒暄”,而是在一开始就把后续会话行为约定清楚。
Lifecycle 页面还明确要求:
The
initializerequest MUST NOT be part of a JSON-RPC batch.
也就是初始化请求不能混进一批别的请求里。原因也很直观:初始化完成前,其他请求本来就还不该被处理。
如果把 MCP 生命周期和普通“直接调接口”习惯放一起看,差异会很明显:
| 维度 | MCP 生命周期 | 直接 REST 调接口式心智 |
|---|---|---|
| 第一步 | 先能力与版本协商 | 通常直接请求业务接口 |
| 会话状态 | 明确有初始化前 / 初始化后之分 | 很多 REST 场景没有显式会话状态 |
| 能力边界 | 双方一开始就声明支持哪些能力 | 常常靠文档或约定记忆 |
| 动态变化通知 | 可通过 listChanged 等能力约定 | 通常需要额外机制 |
| 适合复杂协议吗 | 很适合 | 不一定 |
核心区别:
| 误区 | 准确理解 |
|---|---|
以为连上 transport 后就能直接 tools/call | 不行。必须先走 initialize 和 notifications/initialized |
以为 initialize 响应回来后就自动算完成握手 | 还不完整。客户端还必须发 notifications/initialized |
| 以为 capabilities 只是展示信息 | 不只是。它决定后续会话里哪些功能和通知模式是合法的 |
以为 initialize 可以和别的请求一起 batch 发 | 规范明确说不行 |
以为 listChanged 是可有可无的小细节 | 对动态工具列表、资源列表、roots 变化这类场景,它很关键 |
| 以为 roots 是正常操作阶段才“顺便看看” | 实际上 roots 能力本身就在 initialize 阶段声明 |
| 优势 | 劣势 |
|---|---|
| 边界清楚:初始化前后状态明确,不容易“半连不连” | 实现更严格:不能图省事跳过步骤 |
| 能力协商完整:一开始就知道双方支持什么 | 初学者更容易被握手流程绕晕:比“直接调接口”多一层协议心智 |
为动态会话打基础:listChanged、roots、sampling 等能力都能先约定 | 调试时需要看更多消息:出错时不能只看业务请求,要看 initialize 阶段 |
| 版本兼容性更稳:先谈协议版本,再决定如何通信 | 客户端实现差异会暴露:有的客户端对某些 capability 支持不完整,问题会在初始化阶段体现出来 |
| 对复杂客户端 / 服务器关系更友好:适合多能力、多通知的长连接会话 | 心智不如“发请求拿响应”直觉:需要开发者真正理解会话状态机 |
参考答案:
因为在初始化之前,服务端和客户端连“彼此支持什么版本、有哪些可选能力”都还没谈清楚。
如果这时就直接发 tools/list,等于默认假设:
这些假设在协议层都还没有被正式确认。Lifecycle 的意义,就是先把这些前提建立起来。
参考答案:
因为 initialize 响应只说明“服务端已经给出能力和版本结果”,不代表客户端已经准备好按这个结果进入正常通信。
notifications/initialized 的作用,就是让客户端再明确说一次:
这能避免很多“握手还没完全完成,就开始收业务请求”的模糊状态。
参考答案:
因为通知行为本身就是会话契约的一部分。
如果一开始没说清楚“我后面可能会发列表变更通知”,那另一方就不知道该不该监听、该不该维护本地缓存、该不该在 UI 上做动态刷新。
所以这些能力必须在 initialize 阶段声明。它们不是“某个具体功能细节”,而是整个会话行为模式的一部分。
优先展示同分类且标签更接近的内容,方便继续串联学习。
搞清楚 MCP 的两种消息传输方式——本地用 stdio、远程用 Streamable HTTP,以及怎么选。
搞清楚 MCP 里 Host、Client、Server 各自干什么、怎么连接、消息怎么流转。
搞清楚 MCP Server 能暴露的三种能力——工具、资源、提示词模板,以及什么时候该用哪个。