
第一次接触 MCP 的人十有八九会在 streamable HTTP 和 SSE 这两个概念上卡住。我最初做 MCP Server 的时候也踩过不少坑官方文档里一会儿提到 SSE一会儿又推荐 streamable HTTP再去看各种 SDK 的 transport 参数整个人是蒙的。这篇文章就把它们的区别从底层原理、通信模型、实操和排坑四个层面讲清楚。如果你正在选型 MCP 的 HTTP 传输层方案或者想把旧版 SSE Server 迁到 Streamable HTTP这篇应该能帮你省不少时间。我需要先说明一个背景MCP 全称是 Model Context Protocol它把 AI 应用客户端和外部工具、数据源服务器用 JSON-RPC 2.0 连接起来。本地可以用 stdio远程基本走 HTTP。而 HTTP 传输层在 MCP 规范里有两条路线一个是早期的 SSE transport一个是现在推荐的 Streamable HTTP transport。很多人都以为它们是完全不同的协议其实它们不是。Streamable HTTP 并没有抛弃 SSE而是用一种更合理的姿势把 SSE 收纳进来改成由服务器按需返回事件流。理解这个前提后面对比就顺了。1. MCP 的两种 HTTP 传输先搞清楚它们解决什么问题1.1 为什么 MCP 需要传输层还搞出两种方案MCP 本质上是一套基于 JSON-RPC 的消息协议。客户端要发initialize、tools/call、resources/list这些方法服务器要返回结果或者在某些场景主动推送通知。消息怎么从 A 点到 B 点就是传输层要干的事情。本地进程之间可以直接用 stdin/stdout但一旦 MCP Server 部署在另一台机器、另一个容器或者公网服务上就必须有一个远程传输方案。远程传输方案不可能一拍脑袋就完美。最早的 HTTP 实现直接选用了 SSE原因是 SSE 实现简单浏览器原生支持服务端推流非常自然。但实际用起来之后大家发现它有几个硬伤比如客户端到服务器和服务器到客户端各走各的连接消息关联要靠 JSON-RPC 的 id 手工配对连接闲置久了还容易被中间网关掐掉。于是 MCP 规范后来推出了 Streamable HTTP transport目标很明确保留 SSE 的流式能力但把“请求-响应”和“服务器主动推送”整合得更顺滑同时解决连接治理和会话管理的问题。这个演进过程很像我们平时做接口设计一开始发现长连接推流挺好用就把所有消息都塞进推流通道后来发现很多请求其实是简单的同步 RPC没必要每个都等一个异步推送于是把同步响应和异步流拆开让调用方按需选择。MCP 的 Streamable HTTP 就是这种“按需流式”的设计思路。1.2 一句话记住两者的核心差异一句话版本旧版 SSE transport 是“POST 发请求 SSE 收响应”双连接Streamable HTTP 是“POST 请求响应里可以直接给你 JSON也可以给你 SSE 流要收服务器主动消息再另开一个 SSE 流”。所以 Streamable HTTP 不是去掉了 SSE而是把 SSE 变成了响应的一种可选形式。举个例子你调用一个tools/call让服务器去查天气。旧 SSE transport 下客户端先要去打开一个专用 SSE 通道然后往另一个端点 POST 天气查询请求服务器收到后通过 SSE 通道把结果推送回来。Streamable HTTP 下客户端直接往同一个端点 POST 请求如果逻辑简单服务器直接返回一个application/json的{ jsonrpc: 2.0, result: {...} }就结束了如果结果需要分块返回比如大模型生成 token 一个接一个出来服务器就返回text/event-stream客户端在同一个 HTTP 响应里读流。这样一来SSE 从“必须存在的传输通道”变成了“可选返回格式”灵活度一下子高了。2. SSE 协议原理解读单向推送通道的魅力与局限2.1 SSE 不是 WebSocket理解它的事件流格式SSE 是 Server-Sent Events规范在 HTML5 里它看起来就是普通 HTTP 响应只不过 Content-Type 是text/event-stream。服务器把数据一行行写给客户端连接不关闭。数据格式很简单比如data: {jsonrpc:2.0,id:1,result:{weather:sunny}}每个事件由若干字段行组成常见的是data:、event:、id:事件结束必须跟一个空行。客户端可以解析这些行也可以直接依赖浏览器里的EventSourceAPI。注意EventSource自动重连、断线恢复逻辑都给你做好了但它只支持 GET 请求而且没法自定义 HTTP Header。这个限制对 MCP 这种经常要带Authorization的 JSON-RPC 服务很致命所以 MCP SDK 里的 SSE 客户端基本都不是裸用 EventSource而是自己封装了流式解析。SSE 和 WebSocket 最本质的区别SSE 是“服务器单向推给客户端”。客户端想给服务器发数据必须另走一条请求通道。WebSocket 是全双工一条连接双向自由发送。SSE 为什么在 MCP 早期被选中因为 MCP 的多数消息流本来就是“客户端发请求服务器回结果”服务器推结果这件事用 SSE 很简单不需要升级握手不需要处理二进制帧也不需要维护复杂的协议状态机。但它的“单向性”直接影响了旧版传输的架构下一节讲。2.2 MCP 旧版 SSE Transport 的双连接尴尬旧版 SSE Transport 在 MCP 文档里经常被写成Legacy HTTPSSE Transport它的交互模型非常典型客户端先向服务器的/sse端点发起一个 GET 请求建立 SSE 连接。服务器在这个 SSE 连接上推送一个endpoint事件告诉客户端“你后面要 POST 消息请发到/messages这个地址。”客户端收到endpoint后往/messagesPOST JSON-RPC 消息。服务器把处理结果通过之前建立的 SSE 连接推回给客户端。如果 POST 消息有 JSON-RPC id客户端需要把推回来的结果按 id 和之前发出去的请求做匹配。这个设计有好几个尴尬的地方。第一两个连接服务器必须记住“某个客户端 POST 到的 /messages 对应哪个 SSE 连接”如果没有合适的会话机制就得靠客户端标识或者 Cookie非常容易搞混。第二POST 请求本身通常只是被服务器接受返回202 Accepted之类真正的业务响应全部得异步走 SSE这让很多第一次接触的人摸不着头脑。第三如果 SSE 连接因为网络问题断掉服务器还在处理那个 POST 请求结果就没人接收了消息直接丢。第四长连接没有心跳中间经过 Nginx、云网关时经常被闲置超时踢掉这也是搜索热词里为什么会有人报stream disconnected before completion: idle timeout waiting for sse这类错误。我自己做私有化部署的时候被这种双连接模型坑次数最多的问题就是消息丢失POST 正常发出去了服务器也执行了但 SSE 连接早已被网关断开结果客户端迟迟等不到响应超时报错。去查服务器日志任务明明跑完了气都没处撒。所以后来我把能迁的 Server 都迁到 Streamable HTTP 上不是因为它多高级而是它让“等结果”这件事变得直接。3. Streamable HTTP一套流式方案把请求和推送揉在一起3.1 Streamable HTTP 实际是如何工作的Streamable HTTP 的核心设计是“复用同一个 HTTP endpoint用同样的 JSON-RPC 消息但响应状态更符合正常 HTTP 直觉”。它支持两种请求方式POST 请求客户端把 JSON-RPC 消息放进请求体发给 MCP Server。服务器可以选择直接返回application/json格式的响应也可以返回text/event-stream格式的 SSE 响应。如果是 SSE 响应服务器可以在同一个流里逐条发送多个消息。GET 请求客户端可选地建立一个 SSE 长连接用来接收服务器主动发起的消息比如通知、资源变更推送。这个连接是可选的不需要它客户端依然能完成绝大多数请求-响应调用。换句话说Streamable HTTP 允许三种会话模式纯请求-响应模式客户端只 POST服务器返回普通 JSON。适合那些一次调用就出结果的场景比如查数据库、算个哈希、读文件。POST 返回 SSE 模式客户端 POST 时故意带上Accept: application/json, text/event-stream服务器觉得有流式进度要报就返回 SSE 流。适合工具执行时间较长、想主动给用户反馈进度的场景也适合 LLM token 流式输出。POST GET 双流模式客户端既有 POST 调用同时也开了 GET SSE 流用来收服务器主动推送。适合协作类、订阅类场景比如文件监视、多客户端协同。这里的三种模式复用同一个端点不需要像旧 SSE 版本那样区分/sse和/messages大大简化了服务端路由和网关配置文件。实现上Streamable HTTP 的 SSE 流和普通 SSE 没有任何本质区别它仍然是text/event-stream发送的还是 JSON-RPC 消息帧格式依然是data: {...}加空行。区别只在“流由谁发起、存活多久、消息怎么定位”。3.2 streamable HTTP 与 SSE transport 的详细能力对比为了方便后续选型我整理了一张对比表对比项Legacy SSE TransportStreamable HTTP Transport默认端点通常两个/sse和/messages通常一个比如/mcp请求响应模式POST 被接受结果异步通过 SSE 返回POST 可以直接返回 JSON也可以返回 SSE 流服务器主动推送必须维护一个 SSE 连接通过可选的 GET SSE 连接推送同步响应体验弱业务响应依赖另一条连接强普通的 RPC 可以像 REST 一样即时返回消息关联靠 JSON-RPC id 手工匹配靠 JSON-RPC id但响应所在通道更明确会话管理一般靠连接状态隐式识别支持显式Mcp-Session-Id头部认证支持SSE 用 EventSource 时难以加 Headerfetch 实现可以自由设置 Authorization 头客户端必须开 GET 流必须否则收不到响应可选不需要时可以直接接力对流式输出的支持支持但只能通过 SSE 推送通道支持POST 响应即可为 SSE 流代理和防火墙友好度较差两个连接、长空闲易被掐断较好普通 POST 可以不依赖长连接规范状态旧版逐渐被放弃新版推荐使用这张表里最值得关注的一条我认为是“POST 响应可以直接 JSON”。这意味着 Streamable HTTP 在不需要流式输出的场景下可以直接当成普通 HTTP JSON-RPC 接口来理解dubug 起来特别轻松。你可以直接用curl发一个 POST看返回是不是一个干净的 JSON-RPC response根本不用管 SSE 那一堆数据帧解析。再说会话管理。旧版 SSE Transport 最大的痛点是服务器无法可靠辨别“当前这个 POST 来自哪一条 SSE 连接”。Streamable HTTP 引入了Mcp-Session-Id服务器可以在首次响应时下发一个 session 标识比如放在响应头的Mcp-Session-Id里客户端后续请求带上这个头服务器就能把多次请求关联到同一个逻辑会话。这个机制对需要保持上下文的 MCP Server 非常重要比如用户在一轮会话里连续调用多个工具服务器如果能在内存里缓存临时状态就依赖这个 session ID。4. 实操构建客户端与服务器的核心环节4.1 客户端侧抛掉旧 SSE 的别扭直接读流我先说一下客户端怎么处理 Streamable HTTP。假设我们要请求一个 MCP Server 的tools/call普通模式很简单构造一个 JSON-RPC 请求发过去就行const res await fetch(https://your-mcp-server.example.com/mcp, { method: POST, headers: { Content-Type: application/json, Accept: application/json, text/event-stream, Authorization: Bearer your-token, }, body: JSON.stringify({ jsonrpc: 2.0, id: 1, method: tools/call, params: { name: get_weather, arguments: { city: Shanghai } } }) });注意Accept头写的是两种类型application/json和text/event-stream。这样服务器可以自由选择如果它觉得适合直接返回普通 JSON就回 JSON如果要流式输出就回 SSE。客户端拿到响应后先看Content-Typeconst contentType res.headers.get(content-type); if (contentType.includes(application/json)) { const payload await res.json(); console.log(payload); } else if (contentType.includes(text/event-stream)) { const reader res.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // 按空行切分 SSE 事件 const events buffer.split(\n\n); buffer events.pop(); for (const event of events) { for (const line of event.split(\n)) { if (line.startsWith(data:)) { const data line.slice(5).trim(); if (data) { const msg JSON.parse(data); console.log(msg); } } } } } }这里面有几个实操细节值得说。第一SSE 流的data:行可能很长也可能被网络层拆成多个 chunk所以必须做 buffer不能一拿到 chunk 就往 JSON.parse 塞否则大概率报 “Unexpected end of JSON input”。第二服务器如果发了retry:行说明它想让你多久之后重连这个可以留着但如果你的客户端用的是 fetch 自研读取重连逻辑就得自己写。第三不要把响应体全部读进内存再做解析大模型 token 流一长内存容易爆最好边读边按事件切分。如果你在用官方 TypeScript SDK其实不需要手动解析 SSE。官方 SDK 的StreamableHTTPClientTransport已经帮你做了大部分工作你只要把 transport 传给Client对象即可。但理解上面的底层解析逻辑还是很有用的因为当你抓包排错、写测试脚本时直接读原始响应是最高效的排查方式。4.2 服务端侧如何把响应改造成流式服务端要支持 Streamable HTTP首先得确定你用的是哪套 MCP SDK。以 TypeScript SDK 为例官方提供了StreamableHTTPServerTransport你可以在 HTTP 框架里挂一个 POST/GET 路由。很多时候我推荐在 Fastify 或者 Express 里挂一个/mcp收到请求后把 Node 的 req/res 对象传给这个 transport。如果你自己实现服务端响应流核心就两件事设置Content-Type: text/event-stream然后持续向响应体写符合 SSE 格式的文本。简单示意res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no }); res.write(data: {jsonrpc:2.0,id:1,result:{partial:hello}}\n\n); // 继续写更多事件 res.end(data: {jsonrpc:2.0,id:1,result:{partial:world}}\n\n);这里X-Accel-Buffering: no是我个人非常喜欢加的一个头它专门用来告诉 Nginx 我这个响应是流式的不要缓冲到底再推给客户端。如果不加Nginx 默认proxy_buffering可能是开启的会把已写出的数据缓存住直到后端关闭响应才一次性推出去那样实时性就没了。服务端还有一个容易犯的错SSE 事件必须以data:开头并且字段行结束之后必须有一个空行。很多人写成了data: {...}\n少一个换行客户端就永远等不到下一个事件然后触发 idle timeout。我建议在代码里封装一个sendSSE(data)方法内部强制拼完整的\n\n不要手写字符串拼接。用上官方 SDK 后服务端响应流你基本不用自己裸写。SDK 会在收到客户端请求时把返回结果封装成交互模型你在工具函数里return { content: [...] }即可。但了解底层协议仍然能帮你解释很多奇怪的现象比如为什么客户端收到两个 JSON-RPC 响应为什么流里有一个注释行。那个注释行规范允许服务器发送: heartbeat这种纯注释行专门用来保持连接活跃客户端解析时只要忽略注释行即可。4.3 会话管理与认证的坑Streamable HTTP 的会话管理方向比旧 SSE 靠谱但依然有坑。官方推荐的做法首次请求时服务器生成 session ID放到响应头Mcp-Session-Id返回客户端保存并在后续请求头上带上。服务器如果发现 session 已经失效可以回 404 或干脆返回一个错误码让客户端重新初始化。实际项目里我见过一个典型翻车案例客户端用同一个 session ID 并发请求多个工具但服务端只允许一个信息上下文导致前一个请求把当前上下文覆盖了。MCP 本身是给每个 JSON-RPC 请求独立处理结果的但很多应用层会为了“对话上下文”把多个工具调用揉在一起这时候 session ID 设计就要格外小心该加锁加锁该隔离隔离。认证方面Streamable HTTP 最大的优势是可以用 fetch 自定义 Header这意味着你可以在请求里带Authorization: Bearer ...。这比旧 SSE 的 EventSource 舒服太多因为浏览器原生 EventSource 只能通过 URL query 传 tokenquery 参数容易进日志还会被代理缓存。用 Streamable HTTP 时POST 和 GET 请求都可以带上 Authorization 头你的网关也能正常做鉴权。注意如果服务器返回401并带WWW-Authenticate就说明它期望你补认证信息MCP 里可行的认证方式包括 Bearer Token、OAuth2 等具体要和你自己的身份服务对接。5. 常见问题与排查实录5.1 idle timeout长连接被静默掐断“stream disconnected before completion: idle timeout waiting for sse” 这个错误本质就是你在等一个 SSE 流但等了好长时间服务器没有任何数据发过来中间的 HTTP 层Nginx、云负载均衡、网关觉得这个连接超时了主动断开。很多 MCP Server 在处理一个耗时工具调用时中间会有几秒甚至几十秒没有输出这时候如果没有任何心跳包连接很容易被掐断。解决办法有几个层级。第一服务端要定期发送 SSE 注释行比如每 30 秒写一个: keep-alive\n\n客户端解码时忽略注释行即可。第二在 Nginx 里调大proxy_read_timeout比如设成300s或更长同时记得开proxy_buffering off。第三客户端轮询如果用了 fetch 的reader.read()长时间没有数据也会一直挂住必要时可以在协议层做超时控制。我遇到过更隐蔽的情况本地测试 SSE 一切正常部署到云上就闲置断开。后来发现是云网关默认 60 秒没有双向流量就断开连接。虽然服务器发心跳注释能起到“有数据”的作用但有些网关只把上行流量算作流量注释行也许不够最终我还是调成“每 15 秒发一个注释行”并把客户端读取超时放宽才算稳下来。5.2 网络代理把 SSE 流缓冲了SSE 流本该是一行行实时到达的但如果你把 MCP Server 放在 Nginx 后面客户端经常发现数据不是实时到的而是一波一波地涌来。原因不外乎 Nginx 默认开了proxy_buffering它会把后端响应读进缓冲区等满了或响应结束才发给客户端。排除方法有三步一是确认后端响应头里有没有X-Accel-Buffering: no没有就加上二是 Nginx 里对应 location 配置proxy_buffering off、proxy_cache off三是检查 CDN 或安全网关层有没有“缓冲响应体”的配置。如果你不方便改落网管也可以在 SSE 流里定期输出足够大的 padding 数据比如定时发几百字节的注释行把缓冲区的“水位”顶上去但这不是长久之计最好还是关缓冲。还有一个容易忽略的点检查是不是自己代码里套了一层 HTTP 客户端它默认会把响应体全部读完再做回调。比如某些 axios 适配器对响应流的支持比较弱会等res.end()才触发。如果你用 Node 原生 fetch 的res.body.getReader()一般不会踩这个问题但如果你转到 Python 的requests它默认也是把响应体缓存到最后。写流式客户端时务必确认你的 HTTP 库支持增量回调。5.3 用调试工具观察 MCP 协议帧排查 MCP 传输问题时我感受最深的一点是“光看逻辑很容易被骗直接看帧最有效”。你可以用一个简单的本地代理或者在服务器接入层打日志把经过的请求头、响应头、响应体片段全部打出来。比如看一个 POST 请求如果响应头Content-Type是text/event-stream它一定是流式返回如果是application/json它就是普通 JSON-RPC success 响应。如果你用curl观察一个 MCP endpoint可以直接这样curl -N -X POST https://your-mcp-server.example.com/mcp \ -H Content-Type: application/json \ -H Accept: application/json, text/event-stream \ -H Authorization: Bearer your-token \ -d {jsonrpc:2.0,id:1,method:tools/list}加-N参数是关闭 curl 的缓冲区强制逐个字节输出。看它返回的是普通 JSON 还是一堆data: {...}帧一眼就能判断当前服务器走得是哪条路线。如果是旧 SSE Transport你还需要先 GET/sse连接拿 endpoint这一步用 curl 就会显得比较别扭。所以如果某个 MCP Server 宣称支持 Streamable HTTP你直接用这种 POST 测试是最快的方式。观察帧的时候还要注意 JSON-RPC 消息里的id必须匹配。有些服务器在一个 SSE 流里既发 notification不带 id也发 response带 id客户端解析的时候如果只按事件顺序“按最后一个结果为准”很容易配对错误。正确做法是始终用 id 字典维护 pending 请求收到消息后根据 id 取出对应的 Promise再 resolve。这个建议用官方 SDK 的时候不用自己操心但如果你自研客户端一定要记住。6. 选型建议你的 MCP Server 该用什么6.1 现有 SSE Server 如何迁移如果你手上已经有一个基于旧 SSE Transport 的 MCP Server迁移到 Streamable HTTP 并不需要改动应用层的 tool 注册和 JSON-RPC 分发逻辑主要改的是传输层入口。以 TypeScript SDK 为例把原来的SSEServerTransport换成StreamableHTTPServerTransport并保证路由同时支持 GET 和 POST以 Python SDK 为例旧版sse_app可以换成streamable_http_app或者挂到 ASGI 的 session manager 上。迁移过程中的重点不是代码而是验证几个行为是否还符合预期客户端是否还依赖旧的双连接模式如果是旧客户端需要同步升级。会话管理是否从隐式连接状态改成显式Mcp-Session-Id如果之前是内存里按 SSE 连接存状态现在要改成按 session ID 存。认证链条是否要调整旧的 SSE 连接如果是靠 query token 鉴权迁移后建议改成 Header Bearer如果客户端兼容性不允许也可以保留 query 参数作为兜底。很多服务端框架里同一个/mcp端点同时支持 GET 和 POST 后你需要保证 GET 请求响应的 SSE 连接不是每来一个请求就新建一个 session而应该通过请求头里的 session ID 复用。否则客户端反复重连服务器内存里塞一堆无用的会话迟早出事。6.2 什么情况下保留 SSE 也合理虽然官方现在主推 Streamable HTTP但并不是说旧 SSE Transport 必须立刻全部干掉。如果你的 MCP Server 只运行在内网客户端也是老版本而且你的场景基本都是“客户端发请求服务器推结果”那旧 SSE 完全能跑。特别是如果你需要非常轻量的服务器主动推送旧 SSE 的单一连接反而直观服务器想推就推客户端连上就一直挂着。另外有些浏览器端场景对 EventSource 的自动重连很感冒因为它们不能方便地自定义 Header如果你所有认证都能走 Cookie 或 query 参数旧 SSE 也能凑合。但从长期维护角度看我个人的建议是新旧两条路线同时开放。入口处做协议切换旧 SSE Transport 保留兼容新客户端默认走 Streamable HTTP。这样既不背叛老用户又能逐渐积累新方案的经验。我自己在实际操作中还有一个体会协议选型不要只看功能也要看你所处的网关环境。如果你们公司的 Nginx 和运维体系对流式非缓存支持得很好而且你已经习惯了处理 SSE 的各种超时问题那旧 SSE 和 Streamable HTTP 差别没有那么大。但如果你在云服务上部署有一堆 API 网关、负载均衡、WAF 层层把关Streamable HTTP 的“不用长连接也能跑通普通 RPC”这个特性真的能让你的排障压力小很多。最后再分享一个小技巧判断一个 MCP Server 到底用的是旧 SSE Transport 还是 Streamable HTTP直接看它对外暴露的端点。如果必须有两个 URL一个用来接收 SSE 流、一个用来 POST 消息那基本是旧版如果只有一个 URL且 GET 和 POST 都能跑那就是新版。用浏览器访问这个 URL看看响应头里有没有Content-Type: text/event-stream没有的话说明它更可能在 POST 时才返回流。掌握这个判断方法后你以后接别人的 MCP Server光看文档就能猜到它内部用的是什么传输模型很多奇奇怪怪的连接问题也就不难定位了。