MCP 做了一年多的服务端接入,最近把客户端从老版的 HTTP+SSE 迁移到 Streamable HTTP 时,踩了不少坑,也把协议文档翻了个底朝天。这个传输层改版其实很多人还没意识到有多重要——它直接决定了你写 MCP Server 时是维护一个端点还是两个端点、要不要自己管 SSE 连接、以及怎么处理那些又长又慢的工具调用。这篇文章就围绕"单一端点"和"按需流式"这两个核心设计,把我实际迁移和调试过程中的理解、配置和排错记录整理出来,希望能帮你少走点弯路。
1. 为什么 MCP 要把通信范式改成 Streamable HTTP
1.1 旧版 HTTP 传输的问题在哪里
在 Streamable HTTP 出现之前,MCP 的标准 HTTP 传输方式是"HTTP+SSE":客户端先通过 POST 请求初始化会话,服务端返回一个 SSE 端点地址,客户端再建立第二个 HTTP 连接去监听服务端的消息。也就是你至少要维护两条连接、两个 URL,一个用于请求-响应,一个用于服务端推送。
这套设计在早期原型里能用,但实际接入时问题很明显:
- 两个端点的生命周期很难同步。初始化时拿到 SSE 端点地址,但一旦网络波动、重连或者负载均衡器介入,SSE 连接和 POST 连接各自的超时、重试策略是分开的,状态很容易错乱。
- SSE 连接是常驻的。你哪怕只是发一条 ping,也要维持一条 HTTP 长连接开着。在 Serverless 环境(比如云函数)里,长连接几乎是灾难,平台几秒不活跃就给你掐断。
- 协议语义割裂。POST 是 JSON-RPC,SSE 是纯文本事件流,两边都各自实现一遍错误处理、心跳、会话管理,代码量直接翻倍。
- 调试困难。两个端点在浏览器 DevTools 和抓包工具里是两套请求,排查问题时很难串起来。
1.2 Streamable HTTP 的核心设计目标
Streamable HTTP 的改版思路,本质上就是把"两条连接"压缩成"一条连接、一个端点"。它的设计目标很明确:
- 单一端点:整个服务端只需要暴露一个 URL(比如
https://api.example.com/mcp),客户端的所有操作——不管是发请求、收响应、收服务端推送——都打在这个 URL 上。 - 按需流式:客户端通过 POST 发起 JSON-RPC 请求,如果响应用不到流式传输,就用普通 HTTP JSON 响应直接返回;只有当需要推送多个消息或者长时间持续传输时,服务端才把响应升级为 SSE 流。流式连接不是常驻的,而是按需建立、按需关闭。
这样设计解决了三件事:第一,端点数量从两个变成一个,负载均衡和网关配置简单了;第二,连接的生命周期跟着请求走,不再是"建立一条永不关闭的 SSE",Serverless 和普通 Web 服务都能友好支持;第三,协议语义统一到 HTTP 之上,底层还是 JSON-RPC over POST,SSE 只是可选的传输升级。
1.3 和 WSS 的关系:一种常见的部署补强
现在不少 MCP Server 还会额外暴露一个 WSS 端点,比如以/mcp为路径、通过 Token 校验的 WebSocket 网关。热词里那些wss://...连接失败的讨论,本质上是把 Streamable HTTP 和 WebSocket 传输混在一起考虑时的典型场景。
WSS 的价值是双向全双工,客户端和服务端可以随时互推消息,不需要反复发起 HTTP 请求。但代价是连接管理更复杂、鉴权方式更重、网关层配置更麻烦。我的建议是:如果客户端有 WebSocket 基础,且需要频繁双向交互,可以走 WSS;否则优先用 Streamable HTTP,因为它在网关、日志、监控、限流上都能直接用现成的 HTTP 中间件,运维成本低一个档次。
2. 单一端点的本质:所有消息都走同一个 URL
2.1 单一端点下客户端和服务端的交互流程
先看一张我整理的交互流程(不用脑补代码,先理解流程):
- 客户端用
POST /mcp发送 JSON-RPC 请求(比如initialize)。 - 服务端处理请求,如果需要流式推送(比如工具执行中要发多条进度通知),就返回
Content-Type: text/event-stream,在同一个 HTTP 响应体里按 SSE 格式持续输出消息。 - 如果请求处理完直接有结果(比如
tools/list),服务端返回普通 JSON 响应即可,不需要升级成 SSE。 - 客户端在单个响应流结束后,如果需要继续发送后续请求,重新发起一次 POST。
- 服务端可以通过同一个连接(同一响应流)主动下发消息,但这些消息必须在一个已经建立的 SSE 响应流内完成,无法凭空另起一条连接。
流程走下来你会发现,"单一端点"不仅仅是 URL 数量上的简化,而是整个通信生命周期都集中在同一个 HTTP 语义下。客户端不需要知道"什么时候该连 SSE、什么时候该 POST",它只需要维护一个 base URL。
2.2 协议版本协商:initialize 请求怎么处理
MCP 协议里,客户端和服务端的版本协商发生在initialize请求阶段。Streamable HTTP 在这里有一个特殊设计:initialize请求的响应不能是 SSE 流式格式。原因很简单——版本还没协商好,双方还不知道对方是否支持流式传输,此时如果直接开流,兼容性无从谈起。
实际操作中,服务端在收到initialize时,应当返回一个普通的 JSON-RPC 响应,并声明自己支持的协议版本、能力(包括是否支持流式、是否支持会话恢复)。客户端收到响应后,再用新的版本号发起后续请求,此时服务端才能按 Streamable HTTP 的规则决定是否流式返回。
注意:如果你在调试时发现
initialize响应被切成 SSE 了,客户端大概率会直接报"streamable http connect failed"或解析失败。这属于协议违规,不是普通的格式问题。
2.3 会话状态与会话恢复:单一端点的隐藏机制
单一端点模式下,客户端和服务端之间可能存在会话状态(比如已初始化、授权、上下文缓存)。MCP 通过Mcp-Session-Id头来维持这个状态。每次 POST 请求,客户端都要带上服务端之前下发的 Session ID;服务端根据 Session ID 找到对应的会话上下文。
这里有几个容易踩的坑:
- 如果服务端不需要会话状态,响应里不返回
Mcp-Session-Id,客户端后续请求也不带,这是合法的。 - 如果服务端下发了 Session ID,客户端必须在后续请求中带上,否则服务端可以把请求当作新会话处理,导致工具列表、资源列表全部丢失。
- 会话恢复(Session Resumption)是可选能力,需要服务端在
initialize响应里声明sessionManagement相关字段,客户端才能决定是否复用会话。
我见过不少生产环境的问题,都是服务端网关层把Mcp-Session-Id头给吞了或者改写了,结果每次请求都像新用户一样。排查这类问题时,第一时间查反向代理的头部透传配置。
3. 按需流式的核心机制:何时升级为 SSE
3.1 普通响应和流式响应的判定逻辑
"按需流式"是整个 Streamable HTTP 最关键的语义。服务端必须根据请求内容和自身能力,决定返回普通 JSON 还是 SSE 流。判定逻辑一般有三条:
- 如果请求是
initialize、ping、tools/list、resources/list这类"一问一答"型请求,返回普通 JSON 响应。 - 如果请求是
tools/call,且工具执行过程中需要推送进度、日志、中间结果,或者执行时间很长(比如超过网关默认超时),服务端应该将响应升级为text/event-stream。 - 如果服务端协议版本较低,不支持流式,那就必须始终返回普通 JSON,不能强行升级。
这里有个细节要留意:当你把响应升级为 SSE 后,客户端会一直保持这个 HTTP 连接开启。客户端的超时设置需要和服务端协商好,通常是服务端通过Mcp-Session-Id和心跳注释(SSE 的: keep-alive注释行)来维持连接活性。
3.2 SSE 格式在 MCP 里的具体写法
MCP 的 SSE 响应体遵循标准 SSE 格式,每一帧包含事件类型和数据字段。在实际抓包中,我看到过两种格式:
- 事件名
message:数据是完整的 JSON-RPC 消息。 - 事件名
error:数据是 JSON-RPC 错误对象,表示流内部出错。
一个典型的 MCP 流式响应体长这样:
Content-Type: text/event-stream event: message data: {"jsonrpc":"2.0","id":1,"result":{"progress":10,"message":"开始执行"}} event: message data: {"jsonrpc":"2.0","id":1,"result":{"progress":50,"message":"处理中"}} event: message data: {"jsonrpc":"2.0","id":1,"result":{"progress":100,"message":"完成","content":[{"type":"text","text":"结果"}]}} : keep-alive comment注意几个细节:
data:后面有时有空格有时没有,标准要求必须有一个空格,但实际解析中大多数客户端可以容忍无空格格式。- 每一行必须以换行符结尾,空行是 SSE 帧的分隔符。
- 注释行(
:开头)不会触发事件,只用来维持连接,防止网关空闲超时掐断。
3.3 按需流式和传统 SSE 推送的区别
很多人容易把 Streamable HTTP 的 SSE 和传统 SSE 搞混。传统 SSE 是"服务端主动推送通道",一旦建立,服务端随时可以发数据,客户端不用反复请求。但 Streamable HTTP 的 SSE 是"请求-响应流"——它必须在一次 POST 请求的响应周期内存在,服务端不能在一个响应结束后,再用这条流给客户端主动推送新请求的结果。
换句话说,按需流式是"一次性"的:响应结束就是结束。如果服务端后续想推送新的通知,必须等待客户端发起下一次 POST,然后在该响应的流中带上。这个语义差异是很多服务端实现出 bug 的重灾区——有人直接套用传统 SSE 的写法,在响应结束后继续往流里写数据,客户端根本收不到,或者直接报错。
3.4 长任务执行时的流式缓冲策略
工具调用如果涉及长时间执行(比如构建镜像、跑测试、爬取网页),流式响应需要合理设置缓冲策略。我在 Go 和 Python 服务端里用的方案不太一样:
- Go net/http:默认
http.ResponseWriter会对小响应自动缓冲。为了流式输出,需要设置http.Flusher接口,每写入一帧就调用Flush(),确保数据及时到客户端,而不是攒在缓冲区里。 - Python FastAPI:用
StreamingResponse,在生成器里 yield SSE 格式字符串,不要用JSONResponse去包 SSE 数据。
缓冲策略的核心是:写一帧、flush 一帧。如果不手动 flush,网关可能一直等缓冲区满后才发送,客户端会表现为长时间无响应,超时后直接报streamable http error: error posting to endpoint。
4. 实操:我是怎么把 MCP Server 改成 Streamable HTTP 的
4.1 服务端改造:从两个端点收敛到一个端点
我这边原本的服务端代码是用 Python FastAPI 写的,暴露了两个端点:/mcp处理 POST JSON-RPC,/mcp/sse处理 SSE。改造的第一步是把两个端点合并成/mcp,根据请求头(Accept: text/event-stream)判断是否流式返回。
伪代码如下:
@app.post("/mcp") async def mcp_endpoint(request: Request): # 解析 JSON-RPC body = await request.json() method = body.get("method") # 初始化请求不流式返回 if method == "initialize": return handle_initialize(body) # 工具调用可能需要流式 if method == "tools/call": # 判断客户端是否接受 SSE accept = request.headers.get("accept", "") if "text/event-stream" in accept and tool_requires_streaming(body): return StreamingResponse(generate_tool_events(body)) # 默认返回普通 JSON return handle_jsonrpc(body)注意这里的tool_requires_streaming是我实践中的折中方案:不是所有tools/call都要流式,只有那些耗时超过 3 秒或需要推送进度的工具才升级为流式。否则每次调用都建 SSE 流,日志里全是连接建立断开的噪音。
4.2 客户端接入:以 Claude Code 和 Trae IDE 为例
客户端接入 Streamable HTTP 比较简单,通用的 MCP 客户端都能配。以 Claude Code CLI 为例,配置一个远程 MCP Server:
claude mcp add --transport http --url https://api.example.com/mcp my-toolsTrae IDE 里通常在 MCP 配置面板新增远程服务,填入 URL 和可选的 Header(比如鉴权 Token)。热词里提到的"谷歌浏览器扩展设置中启用 MCP 连接",其实也是同一套逻辑——扩展内部集成了 MCP 客户端,配置一个 HTTP URL 即可。
我在多个 IDE 客户端里配置时发现一个通用规律:只要服务端实现了标准 Streamable HTTP,客户端就只需要知道一个 URL 和一个鉴权 Headers。不同客户端的差异主要在配置界面上,不在协议上。
提示:鉴权信息通常放在
Authorization或自定义 Header(如X-API-Key)里。热词里出现的?token=...这类 URL 参数形式虽然也有服务端支持,但我不推荐——Token 会进访问日志、代理日志,泄露风险高得多。
4.3 网关配置:超时、缓冲和头部透传
Streamable HTTP 投入生产后,网关(Nginx、Kong、Cloudflare 等)的配置直接决定稳定性。我整理了一份我的 Nginx 配置片段:
location /mcp { proxy_pass http://mcp-backend; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_send_timeout 300s; }几个关键点:
proxy_buffering off必须开,否则 Nginx 会等后端响应全部积攒完才开始往客户端传,流式就失效了。proxy_read_timeout要调大,适配长任务执行。- 如果后端有 SSE 注释心跳,
proxy_set_header Connection ""可以避免 Nginx 强制关闭连接。 proxy_request_buffering off这行我建议也加上,避免 POST 大请求体时产生额外延迟。
4.4 日志和监控怎么跟进
热词里有人问"MCP server端的日志如何使用自定义日志管理",这个问题其实和普通 Web 服务一致,只要你的 MCP Server 是基于 HTTP 框架写的,直接用框架的日志中间件即可。Streamable HTTP 场景下,我关注三个维度:
- 连接生命周期:每次 POST 的开始、结束、耗时、是否升级为 SSE。
- 会话状态:Session ID、初始化时间、最后活跃时间,方便排查会话泄漏。
- 错误码分布:协议错误、鉴权失败、超时、资源不存在四类错误分开统计。
这套日志在排查"客户端报连接失败但服务端看起来正常"这种问题时特别有用——你会看到请求根本没到服务端,问题出在网关或客户端。
5. 常见错误和排查记录:照着抄就行
5.1streamable http connect failed与error posting to endpoint
这是热词里出现最多的报错。它有两层含义:一是 MCP 客户端发起初始化请求时,连接建立失败;二是请求已经发出去,但服务端返回了非预期响应。
我的排查路径是固定的:
- 先用 curl 或
httpx直接 POST 一个initialize请求到服务端,绕过客户端,看响应是否符合 MCP 规范。 - 检查服务端
initialize响应是否包含正确的协议版本字段(protocolVersion)。 - 检查鉴权 Header 是否被网关正确透传,是否被吞掉。
- 如果请求能到达服务端但响应超时,重点查流式响应的 flush 和网关缓冲配置。
最经典的一次问题是:服务端用的老版本 SDK 返回的协议版本和客户端不兼容,客户端拿着旧版本去协商,服务端不认,直接报错。解决方案简单粗暴——把服务端 SDK 升到支持最新协议版本的版本。
5.2 热词里常见的"WSS 连接失败"到底是什么情况
热词里高频出现wss://api.xiaozhi.me/mcp/?token=...这类连接失败的讨论。从现象上看,这通常是 WebSocket 传输模式下的握手失败。但结合热词里其他内容,很多用户其实是把 Streamable HTTP 的 URL 填到了 WebSocket 连接器里,或者反过来。
我建议这样判断:你的客户端如果写的是http://或https://,那就是用 Streamable HTTP,走 POST + 可选 SSE;如果写的是ws://或wss://,那就是用 WebSocket 传输,走全双工。两者不是同一个东西,URL 混用必然报连接失败。如果服务端两种传输都支持,它应该分别暴露不同路径或通过Upgrade头决定走哪种协议。
5.3 排查工具和有效技巧
我调试 MCP 通信时最常用的工具组合:
- curl 手测:最直观,能看到原始请求响应头,适合排查鉴权和内容类型问题。
- 浏览器 DevTools:如果客户端是浏览器扩展,直接在 Network 面板看 POST 请求和 SSE 事件流,能看到每个事件的时间线。
- Burp Suite / Yakit 等代理工具:拦截 MCP 请求做改包测试,验证服务端对畸形请求的处理。
- 服务端 debug 日志:每次请求记录 method、params、耗时、响应状态,必要时开启请求体打印。
经验:如果你在协议栈的各层都已经排查过但仍找不出问题,试着换个客户端测同一个服务端。如果别的客户端能连上,问题大概率在客户端配置或版本;如果别的客户端也连不上,问题就在服务端或网关。
5.4 兼容性速查表
最后放一张我在团队内部传阅的速查表,覆盖 Streamable HTTP 最关键协议的"该不该、能不能":
| 场景 | 是否正确 | 说明 |
|---|---|---|
| initialize 响应用 SSE 流式 | 错误 | 版本协商阶段绝不能开流 |
| tools/call 耗时超过 5 秒 | 建议 SSE | 避免网关超时,可推送进度 |
| tools/list 响应用 SSE | 不推荐 | 一次返回完的内容浪费流开销 |
| 服务端在响应结束后继续写流 | 错误 | 响应结束连接即关闭,写不进去 |
| 客户端每次请求都带 Session ID | 推荐 | 服务端声明了会话管理时必带 |
| 网关对 /mcp 开启 buffering | 错误 | 流式帧会被攒住,客户端超时 |
排查时对着这张表过一遍,多数问题能快速定位到具体环节。
6. 我在实际接入中的几个体会
Streamable HTTP 这个范式改得挺聪明,它没有引入新协议,只是把 HTTP 语义用得更到位了。真正写代码的时候你会发现,服务端其实不太关心"这是不是 MCP"——它就是处理 POST JSON、按需决定要不要转 SSE、管好 Session ID。这让你可以复用大量 Web 服务的最佳实践:限流、熔断、鉴权、监控,全都现成。
我踩过的坑里,最值得分享的一条是:别在服务端自作主张把所有 POST 都变 SSE。看起来"统一用流式"很优雅,但会让客户端和服务端双方都付出不必要的开销,而且很多客户端对非预期 SSE 响应的处理并不完善,反而会莫名其妙的报错。
另一个体会是:协议版本和会话管理这种"看不见"的能力协商,才是真正决定长跑稳定性的东西。很多人调通了初始连接,上线后才发现会话丢失、推送错乱,回头查都是initialize阶段的能力声明没填对。
如果你正在做 MCP Server 接入或者客户端适配,建议先把 Streamable HTTP 协议面文档里关于端点、会话、SSE 格式的三张图吃透,然后照着这篇文章的流程搭一个最小实验环境,跑通 initialize、tools/list、tools/call 三条链路。动手跑一遍,比读十遍文档都管用。