1. 先搞清楚 MCP 的两种传输方式到底在解决什么问题
MCP 这个协议最近在工具集成圈子里讨论度很高,但很多人第一次接触时会被 stdio 和 Streamable HTTP 这两个选项卡住。我刚开始搭 MCP 服务端的时候也纠结过:明明本地跑得好好的,为什么还要折腾 HTTP 那一套?后来踩了几次坑才明白,这两个选项根本不是"哪个更好"的问题,而是"你的使用场景决定了你只能用哪一个"。
先把概念理清楚。MCP 全称 Model Context Protocol,核心作用是让模型能够调用外部工具、读取外部资源。它定义了一套标准的通信格式,但传输层可以有不同的实现。stdio 就是标准输入输出,进程之间通过管道通信;Streamable HTTP 则是基于 HTTP 协议的传输方式,支持流式响应。这两个选项对应的是完全不同的部署形态和交互模式。
我见过不少开发者一开始用 stdio 跑通了本地 demo,然后想部署到服务器上给团队用,结果发现完全行不通。也有人一上来就选了 HTTP 方案,结果发现本地调试变得异常麻烦。所以这篇文章我会把两种方式的适用场景、底层原理、实操配置、常见坑点全部拆开讲清楚,尤其是流式消息到底传到了哪里这个问题,很多人其实一直没搞明白。
这篇文章适合谁看?如果你正在搭建 MCP 服务端或者客户端,纠结选哪种传输方式,或者你已经选了但遇到了连接、超时、消息丢失等问题,那接下来的内容应该能帮你省不少时间。我会尽量用实际配置和代码来说明,而不是只讲概念。
2. stdio 与 Streamable HTTP 的核心差异拆解
2.1 通信模型的本质区别
stdio 的通信模型非常简单:客户端启动服务端进程,然后通过标准输入写入请求,通过标准输出读取响应。整个过程就像在终端里跟一个程序对话。服务端不需要监听任何端口,不需要处理网络连接,甚至不需要知道"网络"这个概念的存在。
这种模型的好处是极其简单、零配置、没有网络开销。你在本地写一个脚本,用node server.js或者python server.py启动,客户端直接 spawn 这个进程就能通信。但它的限制也很明显:服务端和客户端必须在同一台机器上,而且通常是一对一的关系。一个服务端进程只服务一个客户端连接。
Streamable HTTP 则完全不同。服务端是一个 HTTP 服务器,监听某个端口,客户端通过 HTTP 请求发送消息。服务端可以同时处理多个客户端的请求,可以部署在远程服务器上,可以通过负载均衡横向扩展。但代价是引入了网络层的复杂性:连接管理、超时处理、认证授权、流式传输的边界处理等等。
我个人的经验是,如果你只是本地开发调试,或者服务端和客户端天然就在同一台机器上,stdio 是更省心的选择。但一旦涉及远程调用、多客户端共享、或者需要集成到 Web 应用里,就必须上 HTTP。
2.2 流式消息在两种模式下的传输路径
这是很多人最困惑的地方。MCP 支持流式响应,也就是说服务端可以分多次返回数据,而不是等所有结果都准备好了一次性返回。在 stdio 模式下,流式消息就是服务端往标准输出里连续写入多行 JSON,客户端逐行读取解析。每条消息通常以换行符分隔,客户端读到一行就处理一行。
在 Streamable HTTP 模式下,流式消息的传输就复杂一些。HTTP 本身是无状态的请求-响应模型,要实现流式传输,通常有几种方案:一种是使用 Server-Sent Events,服务端保持连接打开,持续往客户端推送数据;另一种是使用分块传输编码,服务端把响应体分成多个 chunk 逐步发送;还有一种是在 MCP 的具体实现里,客户端先发一个请求,服务端返回一个流式响应,客户端持续读取直到流结束。
关键点在于:流式消息最终传到了哪里?答案是传到了客户端的消息处理循环里。无论是 stdio 还是 HTTP,客户端都需要一个持续的读取循环来接收消息。stdio 模式下这个循环读的是进程的标准输出;HTTP 模式下这个循环读的是 HTTP 响应的数据流。很多实现出问题,就是因为客户端没有正确处理这个持续读取的逻辑,导致消息堆积或者连接被过早关闭。
2.3 适用场景对照
| 维度 | stdio | Streamable HTTP |
|---|---|---|
| 部署位置 | 必须同机 | 可远程 |
| 并发连接 | 通常单客户端 | 多客户端 |
| 配置复杂度 | 极低 | 中等 |
| 网络依赖 | 无 | 有 |
| 流式支持 | 天然支持 | 需要协议层支持 |
| 调试难度 | 低 | 中等 |
| 安全认证 | 进程级隔离 | 需要额外认证机制 |
| 适合场景 | 本地工具、IDE 插件、单机集成 | Web 服务、团队共享、云端部署 |
这张表不是绝对的,但基本覆盖了主要决策因素。我一般会建议:先用 stdio 把功能跑通,确认工具逻辑没问题,再根据部署需求决定是否迁移到 HTTP。不要一上来就搞 HTTP,那样调试成本会高很多。
3. stdio 模式的实操配置与关键细节
3.1 服务端实现要点
写一个 stdio 模式的 MCP 服务端,核心就是处理标准输入和标准输出。以 Node.js 为例,基本结构是这样的:
process.stdin.setEncoding('utf8'); let buffer = ''; process.stdin.on('data', (chunk) => { buffer += chunk; let lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { if (line.trim()) { handleMessage(JSON.parse(line)); } } }); function handleMessage(msg) { // 处理请求,生成响应 const response = { id: msg.id, result: '...' }; process.stdout.write(JSON.stringify(response) + '\n'); }这里有几个关键点。第一,必须用换行符分隔消息,因为 stdio 是字节流,没有消息边界,只能靠分隔符来切分。第二,要维护一个缓冲区,因为一次 data 事件可能收到半条消息或者多条消息。第三,响应也要以换行符结尾,否则客户端会一直等。
我踩过的一个坑是:在 Windows 上,换行符可能是\r\n而不是\n。如果客户端只按\n切分,消息末尾会多一个\r,导致 JSON 解析失败。解决办法是在切分后对每行做 trim,或者同时处理两种换行符。
3.2 客户端启动与消息循环
客户端这边,以 Node.js 的 child_process 为例:
const { spawn } = require('child_process'); const server = spawn('node', ['server.js']); server.stdout.on('data', (data) => { // 同样的缓冲和切分逻辑 const lines = data.toString().split('\n'); for (const line of lines) { if (line.trim()) { const msg = JSON.parse(line); handleServerMessage(msg); } } }); // 发送请求 function sendRequest(method, params) { const msg = { jsonrpc: '2.0', id: nextId++, method, params }; server.stdin.write(JSON.stringify(msg) + '\n'); }注意这里的消息循环是持续运行的,不是发一个请求等一个响应就结束。MCP 支持服务端主动推送消息,比如通知、日志、进度更新等,所以客户端必须一直监听 stdout。
3.3 流式响应的处理
stdio 模式下实现流式响应很直接:服务端多次调用process.stdout.write,每次写一条完整的 JSON 消息。客户端每次收到一条就处理一条。比如一个长时间运行的工具,可以先返回一个"开始执行"的通知,然后定期返回进度更新,最后返回最终结果。
// 服务端流式返回 process.stdout.write(JSON.stringify({ id: 1, result: { status: 'started' } }) + '\n'); // ... 执行一些操作 process.stdout.write(JSON.stringify({ id: 1, result: { progress: 50 } }) + '\n'); // ... 继续执行 process.stdout.write(JSON.stringify({ id: 1, result: { status: 'done', data: '...' } }) + '\n');这里要注意的是,同一个请求 ID 可以对应多条响应消息。客户端需要根据消息内容判断是中间状态还是最终状态。有些实现会用partial字段来标记,有些则约定最后一条消息带complete: true。具体看你的协议设计。
注意:stdio 模式下如果服务端崩溃或者退出,客户端会收到进程的 exit 事件。一定要监听这个事件并做相应处理,否则客户端会一直等待永远不会到来的响应。
4. Streamable HTTP 模式的实操配置与流式传输细节
4.1 服务端搭建
Streamable HTTP 模式的服务端本质上就是一个 HTTP 服务器。以 Node.js 的 Express 为例:
const express = require('express'); const app = express(); app.use(express.json()); app.post('/mcp', (req, res) => { const msg = req.body; // 处理请求 const response = handleMessage(msg); if (isStreaming(msg)) { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); // 流式发送 sendStreamResponse(res, msg); } else { res.json(response); } }); app.listen(3000);关键区别在于流式响应的处理。当请求需要流式返回时,设置Content-Type: text/event-stream,然后保持连接打开,逐步写入数据。每条消息通常以data:开头,以两个换行符结尾。
4.2 流式消息的传输路径详解
在 HTTP 模式下,流式消息的传输路径是这样的:服务端调用res.write()写入数据,数据被 HTTP 层封装成 chunk,通过网络传输到客户端,客户端的 HTTP 客户端库接收到 chunk 后触发 data 事件,应用层从 data 事件中解析出消息。
这里最容易出问题的地方是缓冲。HTTP 服务器和客户端都可能有缓冲机制,导致数据没有立即发送或接收。服务端需要确保调用了res.flushHeaders()并且没有启用压缩中间件(或者配置压缩中间件不缓冲流式响应)。客户端需要确保读取的是原始数据流,而不是等整个响应结束后才处理。
// 客户端读取流式响应 const response = await fetch('http://localhost:3000/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request) }); const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const text = decoder.decode(value); // 解析 text 中的消息 parseMessages(text); }这个while循环就是流式消息最终到达的地方。每次reader.read()返回一个 chunk,可能是半条消息,也可能是多条消息,需要自己做缓冲和切分。
4.3 连接管理与超时处理
HTTP 模式下,连接管理是个大问题。如果客户端和服务端之间的连接断了,流式响应就中断了。所以需要处理几种情况:客户端主动断开、服务端超时关闭、网络中间设备(如负载均衡器)强制断开空闲连接。
我一般会在服务端设置一个心跳机制,定期发送空消息或者注释行(以:开头的行在 SSE 中被忽略),保持连接活跃。客户端也要设置合理的超时时间,不能无限等待。
// 服务端心跳 const heartbeat = setInterval(() => { res.write(': heartbeat\n\n'); }, 15000); req.on('close', () => { clearInterval(heartbeat); });提示:如果你的服务部署在反向代理后面,检查代理的超时配置。很多默认配置会在 60 秒后关闭空闲连接,导致长流式响应被中断。
5. 两种模式下的常见问题与排查技巧
5.1 stdio 模式典型问题
问题一:消息粘包或半包。这是最常见的。表现是 JSON 解析报错,或者消息内容不完整。原因是 stdio 是字节流,没有消息边界。解决办法就是前面说的缓冲加换行符切分。但要注意,如果消息本身包含换行符(比如 JSON 字符串里有\n),就会出问题。所以序列化时要确保 JSON 是单行的,或者用长度前缀而不是换行符来分隔。
问题二:服务端输出被其他日志污染。如果你在服务端代码里用了console.log打印调试信息,这些信息会混入标准输出,导致客户端解析失败。解决办法是把所有调试日志写到标准错误(console.error),标准输出只用于协议消息。
问题三:进程退出后客户端挂起。服务端崩溃或正常退出后,客户端如果还在等待响应,就会一直挂着。解决办法是监听子进程的exit和error事件,及时清理待处理的请求。
5.2 Streamable HTTP 模式典型问题
问题一:流式响应被缓冲。表现是客户端等了很久才一次性收到所有消息,而不是逐步收到。原因可能是服务端用了压缩中间件、代理层做了缓冲、或者客户端读取方式不对。排查方法是先用 curl 直接请求,看是否是逐步输出。如果是,问题在客户端;如果不是,问题在服务端或中间层。
问题二:连接被中间设备断开。表现是流式响应进行到一半突然中断。排查方法是检查反向代理、负载均衡器、防火墙的空闲超时配置。解决办法是加心跳,或者调整超时时间。
问题三:多客户端并发时的状态混乱。HTTP 模式下多个客户端可能同时请求同一个服务端,如果服务端有共享状态,就会出现竞争条件。解决办法是每个请求独立处理,不共享可变状态,或者用锁机制保护共享资源。
5.3 问题速查表
| 现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| JSON 解析失败 | 消息粘包/半包 | 检查缓冲和切分逻辑 | 加缓冲,按分隔符切分 |
| 收不到响应 | 服务端未输出或输出被污染 | 检查 stdout 内容 | 调试日志走 stderr |
| 流式变批量 | 中间层缓冲 | curl 直接测试 | 关闭压缩,配置代理 |
| 连接中断 | 超时或网络问题 | 检查代理和防火墙配置 | 加心跳,调超时 |
| 客户端挂起 | 进程退出未处理 | 监听 exit 事件 | 清理待处理请求 |
6. 选型决策与迁移建议
6.1 什么时候必须用 stdio
如果你的 MCP 服务端是作为本地工具使用的,比如 IDE 插件、本地 CLI 工具的扩展、单机桌面应用,那 stdio 是唯一合理的选择。它不需要网络配置,不需要考虑认证授权,不需要处理并发,启动即用。而且 stdio 的延迟极低,因为没有网络往返。
我自己的习惯是:任何 MCP 服务端,先写成 stdio 模式,把工具逻辑全部调通。这个阶段不碰 HTTP,专心把功能做好。等功能稳定了,再根据部署需求决定是否加 HTTP 层。
6.2 什么时候必须上 HTTP
几种情况必须用 HTTP:服务端需要部署在远程服务器上,多个客户端需要共享同一个服务端,服务端需要集成到 Web 应用或云函数中,或者需要通过标准的 HTTP 基础设施(如 API 网关、负载均衡)来管理。
但上了 HTTP 之后,很多事情就变得复杂了。你需要考虑认证(API Key、OAuth 等)、限流、监控、日志、错误处理、版本管理等等。这些在 stdio 模式下基本不存在。
6.3 从 stdio 迁移到 HTTP 的注意事项
如果你已经有一个 stdio 服务端,想迁移到 HTTP,核心改动是把输入输出的读写从 stdin/stdout 换成 HTTP 请求和响应。但有几个细节要注意:
第一,消息格式最好保持一致,这样客户端和服务端的业务逻辑不需要大改。第二,流式响应的实现方式不同,stdio 是直接写多行,HTTP 需要用 SSE 或 chunked 编码。第三,错误处理方式不同,stdio 模式下进程崩溃就是崩溃,HTTP 模式下需要返回错误状态码和错误信息。第四,并发处理需要重新设计,stdio 模式下通常一次只处理一个请求,HTTP 模式下可能同时有多个请求。
我一般会建议把业务逻辑抽成一个独立的模块,stdio 和 HTTP 只是两层薄薄的适配层。这样迁移成本最低,也方便同时支持两种模式。
7. 流式消息的调试与验证方法
7.1 stdio 模式调试
stdio 模式调试相对简单,你可以直接在终端里手动运行服务端,然后手动输入 JSON 消息,观察输出。比如:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node server.js这样能看到服务端返回的原始输出。如果输出有多行或者格式不对,一眼就能看出来。也可以用script命令或者重定向把输入输出保存到文件里分析。
7.2 HTTP 模式调试
HTTP 模式调试用 curl 最直接:
curl -N -X POST http://localhost:3000/mcp \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'-N参数关闭 curl 的缓冲,这样能实时看到流式输出。如果输出是逐步出现的,说明流式传输正常;如果等很久才一次性出现,说明中间有缓冲。
对于 SSE 格式的响应,还可以用浏览器直接访问(如果是 GET 请求),或者用 Postman 的流式模式查看。
7.3 验证流式消息是否真正流式
一个简单的验证方法:在服务端每发送一条消息后加一个延迟,比如 1 秒。然后在客户端观察消息到达的时间间隔。如果每条消息间隔 1 秒到达,说明流式传输正常;如果所有消息同时到达,说明被缓冲了。
// 服务端测试代码 for (let i = 0; i < 5; i++) { res.write(`data: ${JSON.stringify({ seq: i })}\n\n`); await new Promise(r => setTimeout(r, 1000)); }这个测试能帮你快速定位缓冲发生在哪一层。如果 curl 直接请求是流式的,但你的客户端不是,那问题在客户端;如果 curl 也不是流式的,那问题在服务端或中间层。
提示:Node.js 的
res.write()返回 false 时表示缓冲区已满,应该等待drain事件再继续写入。忽略这个信号可能导致内存暴涨。
8. 实际项目中的经验总结
8.1 我踩过的几个坑
第一个坑是 stdio 模式下用了console.log打日志,结果客户端一直报 JSON 解析错误。排查了半天才发现是日志混进了标准输出。后来养成了习惯:所有调试信息一律走console.error,标准输出只留给协议消息。
第二个坑是 HTTP 模式下没关压缩中间件,导致流式响应被 gzip 缓冲,客户端等了几十秒才收到第一条消息。后来在流式路由上单独禁用了压缩。
第三个坑是没处理客户端断开的情况。客户端关闭后,服务端还在往一个已经断开的连接里写数据,导致内存泄漏。后来加了req.on('close')监听,及时清理资源。
第四个坑是 stdio 模式下服务端崩溃后客户端一直挂起。后来加了进程退出监听,收到退出事件后立即拒绝所有待处理的请求,避免客户端无限等待。
8.2 性能方面的考量
stdio 模式的性能瓶颈通常在序列化和反序列化上。如果消息量很大,JSON 解析可能成为瓶颈。可以考虑用更高效的序列化格式,但 MCP 标准用的是 JSON,所以只能在实现上优化,比如用流式 JSON 解析器。
HTTP 模式的性能瓶颈在网络和并发处理上。如果并发请求很多,需要考虑连接池、请求队列、限流等机制。另外,HTTP 头部的开销也不小,如果消息本身很小,头部可能比消息体还大。
8.3 安全方面的注意事项
stdio 模式的安全边界是进程级的。服务端进程以什么权限运行,就只能访问什么资源。客户端和服务端之间的通信不经过网络,所以不存在网络窃听的问题。但要注意,如果服务端执行了不可信的命令,可能会影响整个系统。
HTTP 模式的安全问题就多了。首先必须加认证,否则任何人都能调用你的服务端。其次要考虑传输加密,避免消息被窃听或篡改。再次要限制请求频率和请求大小,防止滥用。最后要校验所有输入,防止注入攻击。
我一般会在 HTTP 服务端前面加一层 API 网关,统一处理认证、限流、日志、监控。这样 MCP 服务端本身只需要关注业务逻辑,不用重复实现这些基础设施。
9. 一些实用的配置模板
9.1 stdio 服务端模板(Node.js)
#!/usr/bin/env node const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false }); rl.on('line', (line) => { if (!line.trim()) return; try { const msg = JSON.parse(line); handleMessage(msg); } catch (e) { console.error('Parse error:', e.message); } }); function handleMessage(msg) { // 根据 method 分发处理 const handler = handlers[msg.method]; if (!handler) { sendError(msg.id, -32601, 'Method not found'); return; } try { const result = handler(msg.params); sendResult(msg.id, result); } catch (e) { sendError(msg.id, -32603, e.message); } } function sendResult(id, result) { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n'); } function sendError(id, code, message) { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n'); } const handlers = { 'tools/list': () => ({ tools: [] }), 'tools/call': (params) => ({ content: [{ type: 'text', text: 'ok' }] }) };这个模板用了 readline 模块来处理行读取,比手动缓冲更可靠。注意terminal: false这个配置,它确保 readline 不会对输入做任何终端相关的处理。
9.2 Streamable HTTP 服务端模板(Node.js)
const express = require('express'); const app = express(); app.use(express.json({ limit: '1mb' })); app.post('/mcp', async (req, res) => { const msg = req.body; if (msg.method === 'tools/call' && msg.params?.stream) { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000); try { for await (const chunk of executeToolStream(msg.params)) { res.write(`data: ${JSON.stringify(chunk)}\n\n`); } res.write(`data: ${JSON.stringify({ done: true })}\n\n`); } catch (e) { res.write(`data: ${JSON.stringify({ error: e.message })}\n\n`); } finally { clearInterval(heartbeat); res.end(); } } else { const result = handleMessage(msg); res.json(result); } }); app.listen(3000, () => { console.error('MCP server listening on port 3000'); });这个模板处理了流式和非流式两种情况,加了心跳和错误处理。注意res.flushHeaders()确保头部立即发送,否则客户端可能一直等不到响应开始。
9.3 客户端统一适配层
如果你需要同时支持两种模式,可以写一个适配层:
class McpClient { constructor(transport) { this.transport = transport; this.pending = new Map(); this.nextId = 1; } async request(method, params) { const id = this.nextId++; const msg = { jsonrpc: '2.0', id, method, params }; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.transport.send(msg); }); } onMessage(msg) { if (msg.id && this.pending.has(msg.id)) { const { resolve, reject } = this.pending.get(msg.id); if (msg.error) { reject(new Error(msg.error.message)); } else { resolve(msg.result); } this.pending.delete(msg.id); } } }stdio 和 HTTP 的 transport 分别实现send方法和消息接收逻辑,上层业务代码不需要关心底层用的是哪种传输方式。这样迁移和切换的成本最低。
10. 关于流式消息传到哪里这个问题的最终回答
回到标题里的问题:流式消息传到哪里?答案取决于你用的传输方式,但本质上都是传到了客户端的消息处理循环里。
stdio 模式下,流式消息通过管道传到客户端进程的标准输出缓冲区,客户端从缓冲区读取并解析。HTTP 模式下,流式消息通过 TCP 连接传到客户端的 HTTP 响应数据流,客户端从数据流中读取并解析。两者的区别在于中间经过的层数不同:stdio 几乎没有中间层,HTTP 要经过 HTTP 协议栈、可能的代理、负载均衡等。
理解这一点很重要,因为很多问题都出在中间层上。比如 HTTP 模式下消息被缓冲,就是因为中间层没有正确透传流式数据。stdio 模式下消息解析失败,往往是因为没有正确处理字节流的边界。
我个人的建议是:先用 stdio 把逻辑跑通,确认消息格式和流式处理逻辑没问题。如果确实需要 HTTP,再把传输层替换掉,业务逻辑不动。这样出问题时也容易定位是传输层的问题还是业务逻辑的问题。
另外,不管用哪种模式,都要做好错误处理和资源清理。stdio 模式下要监听进程退出,HTTP 模式下要监听连接关闭。这些细节看起来不起眼,但在实际运行中往往是稳定性的关键。
最后分享一个小技巧:在开发阶段,可以在客户端和服务端之间加一个日志层,把所有收发的消息都记录下来。这样出问题时可以直接看日志,不用猜。日志格式建议包含时间戳、方向、消息内容,方便排查时序相关的问题。这个日志层在 stdio 模式下可以包在读写逻辑里,在 HTTP 模式下可以做成一个中间件。等系统稳定了再关掉或者降低日志级别。