1. 先搞清楚MCP的传输层:stdio 与 Streamable HTTP 各自的模型和适用边界
我在做 MCP 服务端选型的时候,第一个卡住的问题不是协议细节,而是"我到底要把这个服务做成什么样"。后来踩过几轮坑才慢慢理顺:stdio 和 Streamable HTTP 不是简单的"本地版"和"远程版"的关系,它们背后的进程模型、鉴权方式、流式通道、以及运维复杂度完全是两套逻辑。如果一开始方向选错,后面改起来会非常痛苦。
先说 stdio。它的本质是本地子进程通信:MCP 客户端(比如本地的 CLI 工具、编辑器插件)通过标准输入和标准输出跟你的 MCP server 进程交互。客户端拉起一个子进程,把 JSON-RPC 消息写到stdin,服务端处理完再把响应写到stdout,日志走stderr。这个模型有一个很关键的特性——进程生命周期由客户端托管。客户端退出,服务进程也跟着结束;客户端断开,服务端不需要维护任何会话状态,因为一切都在进程内。
Streamable HTTP 则完全换了个思路。服务端是一个常驻的 HTTP 服务(通常是 Express / Fastify / Python 的 FastAPI 之类),客户端通过 HTTP POST 发送请求,服务端通过 HTTP Response 返回结果。它支持两种模式:一种是普通的 request-response(一次性响应),另一种是Streamable HTTP——服务端用text/event-stream(SSE)把多个事件陆续推给客户端。这里的"流式"不是指 WebSocket 那种双向长连接,而是服务端单向推送的流。客户端可以随时用 POST 发起新的请求,服务端通过已经建立好的 SSE 流把结果推回来。
两者的核心区别,我用一个表格来对照说明:
| 维度 | stdio | Streamable HTTP |
|---|---|---|
| 进程模型 | 客户端拉起子进程,生命周期随客户端 | 服务端常驻,客户端通过网络访问 |
| 会话状态 | 无状态,每次请求独立 | 可选MCP-Session-Id维持会话 |
| 流式通道 | 进程内管道(stdin/stdout) | SSE(text/event-stream) |
| 鉴权方式 | 不做鉴权(本地文件系统即信任边界) | 需要 OAuth、Bearer Token、API Key 等 |
| 服务发现 | 客户端直接指定命令和参数 | 需要 HTTP Endpoint 和可能的 OAuth 流程 |
| 部署形态 | 随客户端本地运行 | 可以独立部署在服务器或容器里 |
| 适用场景 | 本地工具、编辑器插件、开发辅助 | 多客户端共享、远程服务、跨设备调用 |
看完这个表,选型的大方向其实就出来了:如果你的 MCP server 只服务本机上的一个客户端,比如给 VSCode 插件提供代码分析能力,或者给命令行工具提供本地数据查询,stdio 是最省事的方案。它不需要起 HTTP 服务,不需要处理鉴权,不用考虑网络延迟,客户端拉起进程就能用。反过来,如果你要做的是一个团队共享的服务,比如统一的数据库查询入口、内部的知识库检索服务,或者部署在云服务器上的 Agent 后端,那 Streamable HTTP 是必然选择——因为别人没法通过 stdio 来访问你的远程服务。
2. 选型判断的几个关键信号:端口、进程、鉴权和流式需求的权衡
很多人会直接问"stdlib 还是 Streamable HTTP 选哪个好",但我认为更准确的问法是:"我要做的东西有没有远程访问的需求?如果有,它需要什么样的远程访问形态?"因为 stdio 本质上可以做很多事,只是它的边界被锁在"本地进程"里;而 Streamable HTTP 能把服务边界扩展到网络,但代价是要处理一整套 HTTP 服务的运维问题。
2.1 单机工具 vs 在线服务:看的是进程生命周期而不是"好不好用"
我之前帮某个内部工具团队做改造的时候,对方一开始坚持要用 Streamable HTTP,理由是"用起来与时俱进"。但实际上他们只需要在本地编辑器里调用一个代码检索服务,没有共享和多用户需求。
当时我给出的判断依据很简单:
- 如果 MCP 服务的调用方是且仅是一个客户端进程,并且在单台机器上运行,选 stdio。
- 如果 MCP 服务的调用方可能是多个客户端进程、多台机器、多个用户,选 Streamable HTTP。
stdio 模式下的 MCP server 不需要监听端口,不需要配置跨域,更不需要考虑"这个服务是不是暴露在公网上被攻击"的问题。客户端通过配置文件里的command和args来指定启动方式,服务端只需要保证那个命令在 PATH 里能找到就行。整个过程就像是"本地调用一个命令行工具"——你写一个node index.js或者python main.py,客户端帮你把这个进程拉起来。
而 Streamable HTTP 模式你需要多考虑一层:进程是谁拉起的?是运维用 systemd 守护的,还是容器编排平台拉起的?如果是你自己在服务器上手动跑的,那客户端挂了它也不会跟着停;如果容器里跑的,需要配置健康检查和自动重启。这个差异直接决定你的 MCP 服务是不是能长期稳定对外提供服务。
2.2 无状态调用 vs 会话保持:Streamable HTTP 的MCP-Session-Id解决什么问题
很多人忽略了一个细节:MCP 的initialize和initialized两步握手在 stdio 模式下几乎是瞬间完成的,因为进程内部天然就是"一对一"的。但在 Streamable HTTP 模式下,客户端和服务端之间是多对多的:同一个 HTTP 端口后面可能有多个客户端连接,每个客户端都有自己的上下文状态。这个时候必须区分"这个请求是哪个客户端发过来的"。
MCP 协议里给出的方案是MCP-Session-Id。服务端在响应initialize请求时,可以在 header 里带一个 session id(也可以服务端主动通过Mcp-Session-ID返回),客户端拿到后要在后续请求里带上这个 header。服务端借此区分"多次请求属于同一个逻辑会话",例如需要维护 tool call 历史、资源订阅列表、或者跨请求缓存数据时,这个字段就非常关键。
在实际实现里,我见过不少团队一开始没有处理 session id,导致服务端每次请求都新建一个上下文,工具调用之间状态互相丢失。结果用户发一句"帮我查一下昨天的订单",上下文完全不知道"昨天"是什么。这就是没有理解 Streamable HTTP 的会话模型导致的线上事故。
如果你只需要简单的无状态调用(比如每次单独请求一个工具函数,不需要跨请求记忆),可以完全不理会 session id;但只要你希望客户端和服务端之间维持一段持续的对话,或者需要按用户维度做资源隔离,就必须实现 session 管理。stdio 反而简单——每个子进程就是一个天然的会话容器,不需要额外传 session id,上下文天然存在进程内存里。
2.3 本地信任边界 vs 远程鉴权模型:选 Streamable HTTP 必须面对的安全决策
stdio 模式几乎可以不考虑鉴权,因为调用方就是本地进程,文件系统权限本身就是一道信任边界。但如果你的服务跑在远程服务器上,事情就完全不同了。任何一个知道地址的人都可以往你的 HTTP 端口发请求,所以必须做鉴权。
MCP Streamable HTTP 的鉴权模型主要是 OAuth 2.1 和 Bearer Token。官方规范里提到支持无鉴权、Bearer Token、OAuth 2.1 三种形态。我的建议是:内网自用或者团队内部服务,用 Bearer Token 就够了;如果要做成对外服务或者可能被多个第三方应用调用,还是要走 OAuth 2.1 的授权流程。
这里有个实际经验:即便在内网环境,也不要裸奔。之前我们做过一个内部 AI 工具平台接入 MCP 服务,当初图省事没有加鉴权,结果局域网里任何会发 HTTP 请求的人都能调用工具接口。后来加了一层简单的 token 校验,成本很低,但安全边界立刻清晰了。Streamable HTTP 让你把服务扩展到了"网络上",那"网络"本身就是一个不信任的环境。
还有一个容易忽略的问题:CORS(跨域资源共享)。如果你的 MCP 客户端是基于 Web 的(比如在浏览器里跑的交互界面),那么 Streamable HTTP 服务端必须正确处理跨域请求,否则浏览器端的客户端会直接被 CORS 拦截。stdio 完全没有这个烦恼。
2.4 流式需求:SSE 事件流 vs 进程内管道
选型中有一个维度是标题里特别点到的——"流式消息传到哪里"。要理解这个问题,必须先分清两种传输模式下流式通道的物理形态。
stdio 模式下,流式消息是在进程内的管道里流动的。服务端往stdout写数据,客户端从stdout读数据。这个"读"是连续的,不存在 HTTP 的请求-响应边界,所以天然就是流式的。MCP 协议规定的notifications(比如notifications/progress、notifications/resources/updated)在 stdio 下实现起来非常自然,就是往 stdout 里写一段 JSON-RPC 消息。
Streamable HTTP 模式下,流式消息的物理载体是SSE 连接。服务端收到客户端的 POST 请求后,不立即返回完整结果,而是先返回一个202 Accepted(或直接返回 200 + SSE 流),然后通过一个独立的GET请求建立 SSE 长连接,把后续的事件通过这个连接推送出去。MCP 协议里规定,如果客户端需要通过 SSE 接收 server 推送的消息,服务端必须在响应 header 里提供Mcp-Session-Id,客户端拿着这个 id 去请求一个GET接口,服务端在这个 GET 响应上以text/event-stream格式不断输出事件。
所以如果你问"流式消息传到哪里",答案就是:stdio 传到进程管道,Streamable HTTP 传到客户端发起的 SSE 请求的响应流里。这个区别直接决定了服务端的架构设计:
- 如果服务端是 Node.js,用 Streamable HTTP 时你需要一个事件路由中心——类似 EventEmitter 或者消息队列来管理 session 和 SSE 连接的关系,客户端 A 的消息不能推给客户端 B。
- 如果是 Python FastAPI,则要处理
StreamingResponse与后台任务的生命周期绑定问题——POST 请求结束不代表消息发送结束,SSE 连接必须被单独管理。
我做过的几个项目里,最稳妥的 Streamable HTTP 实现模式是:
- POST 请求进来,鉴权和初始化成功后,生成或复用 session id,把请求体存进一个内存队列。
- 同时有一个后台任务/独立事件循环在跑,专门消费这个队列里的消息,并通过对应的 SSE 连接推给客户端。
- SSE 连接是"长命的",客户端断开时要及时清理连接实例,否则会内存泄漏。
3. 流式消息传到哪里:从服务端到客户端的完整链路拆解
"流式消息传到哪里"这个问题看似简单,但真正把它想透的人并不多。因为它牵扯到 MCP 协议层、HTTP 传输层的区别,以及服务端如何维护多个 SSE 连接的关键命题。我在项目落地中经历的链路是这样的,分享出来供参考。
3.1 先理解 MCP 协议里的"事件流"是什么
MCP 协议里的"流式"本质上是JSON-RPC 消息的非同步推送。正常的请求-响应模型是:客户端发一条请求,服务端回一条响应。但在实际的 AI 场景里,工具执行可能是很长耗时的,比如一个工具要查询数据库、调用外部 API、再计算结果,这时候客户端不能干等一个 HTTP 响应,否则超时问题会很头疼。
MCP 提供了notifications(通知)的机制——服务端可以主动向客户端推送事件,而客户端不需要发起请求。这些事件包括:
notifications/initialized(初始化完成)notifications/resources/updated(资源更新了)notifications/tools/list_changed(工具列表变化了)notifications/log/message(日志消息,常用于调试)
在 stdio 模式下,这些通知直接写到 stdout,客户端读管道就能收到。在 Streamable HTTP 模式下,这些通知必须通过 SSE 连接传给客户端。关键问题是:服务端怎么知道该把通知发给哪个客户端?这就是 session id 的用武之地——每个 session 对应一个 SSE 连接,服务端在往某个 session 推送时必须找到那个连接。
3.2 实际操作:服务端维护 SSE 连接和 session 的对应关系
我在做一个 Node.js 版本 MCP Streamable HTTP server 的时候,核心的数据结构就两个:
// 保存所有活跃的 SSE 连接 const sseConnections = new Map(); // key: sessionId, value: { res, shouldSend, ... } // 保存所有会话上下文 const sessions = new Map(); // key: sessionId, value: { contextData, ... }收到initialize请求时,服务端做这几件事:
app.post('/mcp', async (req, res) => { const body = req.body; if (body.method === 'initialize') { // 1. 检查有没有已有 session const sessionId = req.headers['mcp-session-id'] || randomUUID(); // 2. 先返回 initialize 的响应(带 session header) res.setHeader('Mcp-Session-Id', sessionId); res.status(200).json({ protocolVersion: '2024-11-05', capabilities: {...} }); // 3. 把这个 session 登记到 sessions Map sessions.set(sessionId, { initialized: false }); } })然后客户端会拿着 session id,去请求一个独立的 SSE 端点(比如/sse),服务端在这个端点上建立 SSE 连接:
app.get('/sse', async (req, res) => { const sessionId = req.headers['mcp-session-id']; if (!sessions.has(sessionId)) { res.status(400).json({ error: 'unknown session' }); return; } res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }); // 保存这个连接 sseConnections.set(sessionId, res); req.on('close', () => { sseConnections.delete(sessionId); }); });一旦这个 SSE 连接建立起来,服务端就可以在任何时候往这个连接里写入事件。比如工具执行到一半,要发送进度通知:
function sendEvent(sessionId, eventData) { const sseRes = sseConnections.get(sessionId); if (sseRes) { sseRes.write(`data: ${JSON.stringify(eventData)}\n\n`); } }这个模型非常清晰:POST 请求负责"提交任务",GET SSE 连接负责"接收结果"。跟传统的 WebHook 回调模式不同,它不是服务端自己去回调一个 URL,而是客户端主动保持一个长连接等着接收。
3.3 流式场景中的坑:SSE 连接被代理切断、超时和断线重连
真实环境里,"流式消息传到哪里"最大的麻烦不是 MCP 协议本身,而是网络链路中的各种中间层。我踩过的几个典型坑:
第一个坑:代理服务器拦截 SSE。如果你的 MCP 服务部署在 Nginx 后面,Nginx 默认可能会缓冲响应,导致 SSE 事件不能及时刷给客户端。SSE 的响应头里必须传X-Accel-Buffering: no,同时 Nginx 配置里要关掉proxy_buffering。另外proxy_read_timeout默认是 60s,如果一个 SSE 连接 60 秒没有数据流量,Nginx 会直接断开,客户端必须自动重连。我后来把这个参数调到了 3600s,才避免每小时的断线问题。
第二个坑:客户端自动重连导致的重复消费。SSE 断线后,客户端会自动重连(EventSource 内置了这个能力),但问题是重连后 session 状态还在不在?如果服务端在断线时把 session 清掉了,那重连后客户端拿到的还是旧 session id,服务端不认识就报 400。这时候要么让客户端强制重新initialize,要么服务端在 session 过期前允许"闲置但存活"一段时间。我给 session 加了 idle timeout(比如 5 分钟没有活动就回收),并且重连时如果 session 已过期,客户端要走完整的初始化流程。
第三个坑:多客户端共享一个 session。有些客户端实现不仔细,会把同一个 session id 用在多个并发请求上,这会导致 SSE 连接只有一条,多个请求的消息都往里面塞,客户端那边根本区分不了哪个响应对应哪个请求。正确做法是:一个 session 一个 SSE 连接,每个请求对应一个独立的事件流标识。如果 MCP 工具要支持多个并发调用,可以在 SSE 事件里带上_meta.requestId关联原始请求。
4. 选型决策和架构落地:我给你的最终建议
回到最初的问题——"MCP stdio 与 Streamable HTTP 怎么选"。我的答案不是"看场景",而是一个可以照做的决策流程。我把过去做过的十几个 MCP 相关项目归类后发现,选型的判断依据可以收敛成五个问题:
4.1 自问五个问题,答案就出来了
- 这个服务是否只在本机使用?是 → stdio 直接最优解。否 → 看下一题。
- 是否有多客户端/多用户同时访问的需求?是 → Streamable HTTP。否 → stdio。
- 是否需要在远程服务器上部署(比如云主机、容器集群)?是 → Streamable HTTP。否 → stdio。
- 是否需要维持跨请求的会话状态(比如对话历史)?是 → Streamable HTTP(配合 session id)。否 → 两者都行,选 stdio 更省事。
- 是否要嵌入现有的 Web 应用或 SaaS 平台?是 → Streamable HTTP。否 → stdio。
如果五个问题的答案摇摆不定,还有一个傻瓜式判断法:你看看你的 MCP client 是怎么连服务端的。用的是 VSCode 扩展的那种command+args配置,就是 stdio;用的是URL配置,就是 Streamable HTTP。反过来推,你的部署形式大概率跟客户端配置方式绑定。
4.2 拿到答案后,落地时需要注意的工程细节
选 stdio 的时候,真正要注意的不是协议,而是进程的退出机制。很多 stdio server 是常驻进程,客户端退出后它不会自动退出,时间久了会有僵尸进程累积。标准做法是监听stdin的end事件,或者客户端进程销毁时通过信号量让子进程退出。另外,stdio 模式的日志千万别往 stdout 打,否则会污染协议消息,要统一走 stderr 或者文件日志。
// stdio server 的正确退出姿势 process.stdin.on('end', () => { cleanup(); process.exit(0); });选 Streamable HTTP 的时候,重点做三件事:
第一,做好 session 的清理和超时机制。复用我能想到的最好方案:我会给 session 设置lastActive时间戳,每分钟跑一个 tag 扫描,超过 10 分钟的 session 直接销毁。这样做一方面避免内存泄漏,另一方面能及时释放无效的 SSE 连接。
第二,做好鉴权中间件。不管是 Bearer 还是 OAuth,建议在所有 MCP 端点上统一进行校验。注意,SSE 的 GET 请求也要鉴权,否则别人猜到/sse路径就能白嫖你的服务。
第三,做好超时与重试策略。MCP 协议里工具调用的超时时间由客户端决定,但服务端也不能一直挂着任务。合理的做法是:工具执行超过 5 分钟就主动发送notifications/progress事件,告诉客户端"还在跑,别急";超过 15 分钟就主动取消任务,并在 SSE 事件里带上错误信息。这样客户端不会一直干等,用户体验会好很多。
4.3 混合架构:stdio 和 Streamable HTTP 可以在一个服务里共存
最后分享一个我个人的进阶经验——不要把自己限制在二选一。MCP 的传输层是可配置的,你完全可以写一个服务,内部逻辑复用,外部同时提供 stdio 和 Streamable HTTP 两个入口。
具体做法是抽象一个核心处理层(比如MCPRouter),它负责处理所有 MCP 协议的请求分发,然后写两个薄薄的 adapter:
StdioAdapter:监听 stdin,收到的消息交给MCPRouter,router 的响应写回 stdout。HTTPAdapter:暴露两个端点,一个接收 POST 请求,一个提供 SSE 连接,同样把请求交给MCPRouter。
这样的好处非常明显:本地开发调试用 stdio(快速、无需起服务、没有鉴权干扰),上线部署用 Streamable HTTP(远程调用、多客户端支持、可以做鉴权)。代码逻辑完全复用,只是传数据的管道不同。
这个架构在 MCP TypeScript SDK 和 Python SDK 里都有官方支持,你只需要实现Transport接口就行。我前段时间搭了一个内部服务,通过一套核心代码同时服务编辑器插件(stdio)和远程 API(Streamable HTTP),日常维护成本几乎是单份的。这个思路大家可以直接抄作业。
5. 实操侧重点:流式、会话、鉴权,三座山头怎么翻
既然标题里专门提到了"流式消息传到哪里",我不妨把实际编码中与流式相关、会话相关、鉴权相关的最关键步骤拆出来,给大家一条可以直接走的路线。
5.1 流式消息在 Streamable HTTP 里的最小实现清单
如果你用的是官方的 SDK,很多细节不用手写。但理解底层仍然是必要的。这里给一个无 SDK、用原生 HTTP 实现的最小流程:
- 客户端先 POST
/mcp,带initialize请求。服务端返回Mcp-Session-Id响应头。 - 客户端再发起 GET
/sse请求(带着Mcp-Session-Idheader)。服务端保持这个连接,返回Content-Type: text/event-stream。 - 客户端接着 POST
/mcp,带各种业务请求(tools/call、resources/read)。服务端处理完,结果通过第 2 步的 SSE 连接推给客户端。 - SSE 连接断线后,客户端自动重连 GET
/sse,服务端如果 session 还在,就直接复用;如果 session 过期,客户端需要重新初始化。
这个流程里有个顺序问题值得注意:客户端必须先建立 SSE 连接,再发业务请求。否则服务端在处理业务请求时找不到可推送消息的通道。如果你在实现中发现"调用工具后没有任何返回",大概率是 SSE 连接没建好,或者 session id 没对上。
5.2 在 SDK 里快速落地 Streamable HTTP 的推荐姿势
如果用 MCP 官方 SDK,你不需要自己写 session 管理,但需要正确配置服务端能力。
TypeScript SDK(@modelcontextprotocol/sdk)里,SSE 服务端示例:
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; const server = new Server({ name: 'example-server', version: '1.0.0', }, { capabilities: { tools: {}, }, }); let transport: SSEServerTransport | null = null; app.get('/sse', async (req, res) => { transport = new SSEServerTransport('/messages', res); res.on('close', () => { transport = null; }); await server.connect(transport); }); app.post('/messages', async (req, res) => { if (transport) { await transport.handlePostMessage(req, res, req.body); } });这里面有一个初学者常常犯错的地方:/messages端点是给客户端 POST 消息用的,不是给浏览器访问的。如果你拿 Postman 直接 POST/messages,会发现没有响应。因为真正的响应走的是/sse连接那边的通道。所以调试的时候,你要同时开着两个请求:一个/sse的长连接,另一个/messages的 POST 请求,响应会在/sse那边返回。
Python SDK 的 FastAPI 版本也类似,用StreamableHTTPServerTransport配合 SSE 路由。目前官方文档里 TypeScript 的示例比 Python 完整,但核心模型是一样的。语言不是关键,核心在于理解那个双通道模型:POST 进,SSE 出。
5.3 鉴权落地顺序:先挡未授权,再放行白名单
鉴权这块要按生产环境的标准来,不要嵌入式练手。推荐的落地顺序:
- 第一层:全局中间件校验
Authorizationheader。没带 token 或者 token 无效,直接 401。 - 第二层:区分是否需要 OAuth 授权流程。如果是纯内部服务,Bearer Token 就够了;如果是对外平台,建议实现
/.well-known/oauth-authorization-server端点来自动发现鉴权配置。 - 第三层:按 session 粒度控制资源访问。也就是说,同一个 token 下可能有多个 session,不同 session 能访问的工具集可能不同。
我见过的最常见错误是:鉴权只做了第一层,但 session 之间没有隔离。结果用户 A 创建的 session 可以调用用户 B 的工具。这在实际业务里可能就是数据越权。解决方式很简单——session 创建时绑定用户身份:
function createSession(userId) { const sessionId = randomUUID(); sessions.set(sessionId, { userId, createdAt: Date.now() }); return sessionId; } function isAuthorized(sessionId, toolName) { const session = sessions.get(sessionId); return session && userHasTool(session.userId, toolName); }6. 踩坑实录:我从 stdio 迁到 Streamable HTTP 时遇到的三个隐形问题
选了 Streamable HTTP 之后,并不是就万事大吉了。我从 stdio 迁移到 HTTP 模式的过程中,遇到过几个在文档里看不到的隐形问题,逐一记录下来,省得大家再趟一遍。
6.1 隐形问题一:客户端握手成功,但工具调用全部超时
现象:SSE 连接建立正常,initialize 也完成了,但一旦调用 tools/call,客户端就一直收不到响应,直到超时。
排查过程:我起初以为是自己工具函数写错了,花了几个小时调试工具内部逻辑,结果发现工具函数执行得很快,问题出在响应的去向。因为服务端把工具的响应写到了 SSE 连接里,而客户端那边的实现并没有把"工具调用响应"关联到对应的 POST 请求上——它可能还在傻等 POST 请求的 HTTP 响应。
后来我查了 MCP 协议规范才发现:Streamable HTTP 模式下,POST 请求本身可以返回结果,也可以只返回202 Accepted然后通过 SSE 返回结果。绝大多数服务端 SDK 默认走的是"SSE 返回结果"这条路径,所以如果你的客户端 SDK 版本比较旧,或者实现不标准,它就会一直等 POST 的响应体。最终我把服务端改成"POST 直接返回结果(如果是同步工具),SSE 只推送通知(比如进度通知、日志)",问题立刻解决。
这个案例给我们的教训是:在选型之前一定要确认你用的客户端 SDK 对 Streamable HTTP 的支持程度。有些客户端虽然配置了 URL,但它其实用的是旧版 SSE 模型(不是标准 Streamable HTTP),这会让你选型的根基直接动摇。最简单的方式是先跑通官方参考客户端,再换你自己的客户端。
6.2 隐形问题二:多个客户端共用同一个进程,状态混乱
stdio 模式下,一个客户端对应一个进程,天然隔离。但 Streamable HTTP 模式下,所有客户端共享一个进程。如果服务端内部用全局变量存状态,就会出现"用户 A 改了配置,用户 B 的请求也受影响"的问题。
我遇到的一个具体案例是:服务端用了一个全局变量存当前工作目录,结果用户 A 切了工作目录后,用户 B 的请求全跑到新目录去了。当时的解决方式是引入 session 粒度上下文,每次请求都从 session 里取配置,而不是从全局取。
解决技巧:在服务端入口处就做上下文绑定,用AsyncLocalStorage(Node.js)或者ContextVar(Python)来保证每个 session 的上下文隔离。这样你在代码深处想拿用户信息、配置参数,直接getStore()就能拿到,不需要层层传参。
6.3 隐形问题三:断线重连后 session 还在,但 SSE 连接需要重建
这个我前面已经提到,但值得再强调一次。MCP 规范里,session 的生命周期和 SSE 连接的生命周期是解耦的:session 可以比 SSE 连接更长寿。当 SSE 连接断开后,客户端重连时,服务端会看到同样的Mcp-Session-Id,但sseConnectionsMap 里已经没有对应的连接对象了。
这本身不是 bug,但如果你的服务端在断线时直接销毁 session,就会导致重连失败。正确做法是:
- 收到 SSE 断开事件时,从
sseConnections删除连接,但 session 暂时保留(比如保持 5 分钟)。 - 收到重连请求时,检查 session 是否还在,如果在,直接重新绑定 SSE 连接。
- 如果 session 已过期,返回 400 或要求重新初始化。
这个设计需要你在服务端显式地分清楚两个生命周期。不少初学 MCP 的朋友把 session 和 SSE 连接当成一个东西管理,结果一断线就得重新初始化,在弱网环境下体验灾难。
7. 一些补充判断:什么情况下我会坚定地选 stdio
前面说了很多 Streamable HTTP 的好处,但我也遇到过很多场景,最后坚定地退回了 stdio。这些场景可能跟你的直觉相反,但在实际业务里确实存在。
7.1 离线环境优先,不想引入 HTTP 服务运维成本
如果你的 MCP 服务跑在离线环境(比如用户的电脑上没有外网),或者你不想维护一个常驻服务进程,stdio 是最好的选择。它可以做到完全的"零配置"——只要把程序放到目标机器上,在 MCP 客户端配置里加一行命令,就能直接用。而 Streamable HTTP 需要考虑到端口占用、防火墙、进程守护、日志轮转等一系列运维问题,哪怕只是一个小服务,也得按生产标准来。
7.2 调试和维护的简易度:stdio 真的是"无需额外手段"
stdio 模式最大的好处是调试极其方便。你可以直接在命令行里手动启动 server,然后输入 JSON-RPC 消息,观察输出。都不用起 HTTP 服务,不用处理网络问题,不用为鉴权发愁。而且因为它是单进程模型,所有日志、状态、异常都集中在终端里,出问题一眼就能看出来。
Streamable HTTP 的调试成本要高得多。你需要 curl 模拟初始化,手动管 session header,处理 SSE 长连接,还要面对跨域、代理、超时等一系列网络问题。我实际经验是:即使是同一个 MCP server,换成 Streamable HTTP 之后,光是为了绕过 Nginx 的缓冲配置就花了我半天时间。
7.3 回归到"做工具"的本质:stdio 是 MCP 最初的形态
需要意识到,MCP 最开始的设计就是为本地工具服务的。stdio 模式是最接近原生体验的形态——你的 MCP server 就是一个可以交互的命令行程序。所有基于"本地操作"的 AI Agent 流程,比如读取本地文件、执行本地脚本、调用本地数据库,用 stdio 是在用最顺手的工具。Streamable HTTP 引入的复杂度,只有在"服务需要被远程访问"时才划算。
我在微信群和社区里看到过不少团队纠结选型,最后绕了一圈又回到 stdio 的情况。原因是他们做的东西本质上就是一个本地开发辅助工具,远程访问的需求并不存在。先把视角拉回到"我需要它跑到哪里",选型的问题就能自然化解。不要为了炫技术去选择更复杂的形态,这个原则在 MCP 上面尤其适用。