过去小半年我一直在折腾 MCP(Model Context Protocol)相关的集成,从浏览器自动化到安全测试工具桥接,几乎把生态里常见的 Server 都接了一遍。中间踩得最深的坑,就是 MCP 传输层从老的 HTTP+SSE 切到 Streamable HTTP 之后,一堆老教程直接失效,网上搜到的中文资料又大多是搬运官方文档,压根没讲清楚"单一端点"和"按需流式"到底是怎么回事。
这篇文章不打算复述协议原文,我就站在实际接入方的角度,把 Streamable HTTP 这套通信范式的设计逻辑、代码落地和排错过程完整捋一遍。如果你正在写 MCP Client、封装 MCP Server,或者只是被"streamable http connect failed"这类报错折磨过,这篇应该能帮你省不少时间。
1. 为什么好好的 HTTP+SSE 要换成 Streamable HTTP
先交代一下背景。MCP 早期主推的传输方式是 HTTP+SSE,也就是服务端开两个端点:一个 SSE 端点专门推流,一个消息端点用来收客户端发来的 JSON-RPC 请求。客户端要同时维护两个连接,一个是长连接听事件,一个是短连接发消息,逻辑上绕,部署上也烦。
1.1 老方案最难受的三个地方
第一,网关和反向代理配置特别别扭。我这边 Nginx 要同时放行两条路径,还要单独给 SSE 端点配置关闭缓冲、调长超时,否则流会断。第二,连接状态割裂。SSE 连接挂在一条通道上,POST 消息走另一条通道,中间一旦某个环节代理重连,两边状态就对不上了,排查起来非常头疼。第三,扩展性差。所有事件都从同一个 SSE 连接推下来,高频场景下头部阻塞明显,做负载均衡也不方便。
1.2 Streamable HTTP 的改进思路
Streamable HTTP 的设计目标说白了就一句话:用最少的端点种类,把"请求-响应"和"服务端主动推送"统一到一个通道里。它把原来两个端点的活合并到一个 URL 上,客户端只需要知道一个地址,就能完成握手、发消息、订阅事件全部操作,这就是热词里反复出现的"单一端点"。
这套设计在 2025-03-26 版本的 MCP 规范里已经标记为推荐传输方式,老的 HTTP+SSE 被标记为 deprecated。我个人的感受是:它不是简单地把两个端点合并,而是把"要不要流式"这个决定权从协议层下放到了实现层,客户端可以按需决定这次交互要不要走 SSE 长连接,这就是"按需流式"的由来。
2. 单一端点的设计逻辑:一个 URL 打天下是怎么做到的
单一端点听起来很简单,就是把 URL 从两个变成一个。但真正去读协议细节,你会发现"一个 URL"背后的 JSON-RPC 消息路由逻辑比想象中精巧得多。
2.1 GET 与 POST 的分工
同一个 URL 上,协议用 HTTP 方法做了最基本的语义区分:
- GET:客户端用来向服务端发起 SSE 流订阅。服务端收到 GET 请求后,如果同意,就返回
text/event-stream响应,后续服务端事件(包括工具调用结果、资源更新通知)都通过这条流推给客户端。 - POST:客户端用来发送 JSON-RPC 消息,比如
initialize、tools/call、resources/read。服务端处理完请求后,可以返回普通 JSON 响应,也可以返回 SSE 流,具体取决于服务端实现。
这里有个容易混淆的点:GET 建立的流是单向的,只能服务端推给客户端;POST 是双向的,客户端发请求,服务端回响应。两者是配合关系,不是替代关系。
2.2 参数推断模式:单一端点如何判断你是什么角色
单一端点要能工作,服务端必须能区分"谁是客户端、谁是服务端"。协议里有个概念叫参数推断模式(parameter inference mode),就是通过Mcp-Session-Id、Content-Type、Accept这些请求头来推断当前请求属于哪次会话、哪个角色。
我实际调试时最常用的判断套路是这样的:
- 如果请求带
Mcp-Session-Id,说明是既有会话的后续消息,直接复用会话状态。 - 如果 POST 请求的
Content-Type是application/json,且 body 是合法的 JSON-RPC 消息,就按客户端消息处理。 - 如果 GET 请求的
Accept包含text/event-stream,就按流订阅处理。
这套逻辑让反向代理层不需要感知 MCP 内部状态,纯粹按 HTTP 语义转发就行。我在 Nginx 里只需要一条location规则指向这个 URL,比之前省了一半配置量。
2.3 去掉 tools/list 预处理的好处
老方案里客户端为了知道服务端有哪些工具,往往要先用tools/list拉一次清单,再决定后续调用。Streamable HTTP 下,这个动作仍然是 JSON-RPC POST 的一部分,但整个交互模型变成了"先初始化,后按需交互"。
实际使用中,我发现这个改动对 Server 端实现者特别友好:不用再维护两套状态(SSE 连接状态 + 消息通道状态),所有会话状态都挂在同一个 session 上。我之前写的一个内部工具服务,从老方案迁移过来后,代码里关于连接管理的逻辑直接少了一半。这看起来是个细节,但对长期维护来说是质变。
3. 按需流式:不是全程开河,而是水来了才开闸
"按需流式"这个说法第一次看到的时候,我以为是"客户端可以随时开关流",后来读完规范才发现更准确的理解是:流是否建立、何时建立,由客户端和服务端在每一次交互中动态协商,而不是一上来就强制挂一条长连接。
3.1 服务端什么时候该开流
Streamable HTTP 的服务端响应有两种形式:普通 JSON 和 SSE 流。什么时候用哪种,协议没有硬性规定,但实际工程里是有规律可循的。
我自己封装服务端时的决策规则是这样:
- 请求是
initialize这类握手消息:返回普通 JSON,一次性把协议版本、能力列表给全。此时没必要开流,因为客户端还没建立会话。 - 请求是
tools/call且工具执行耗时较长(比如调外部 API、跑脚本):返回text/event-stream,把中间进度事件(比如progress通知)和最终结果拆成多个事件推给客户端。 - 请求是
resources/subscribe这类订阅型请求:返回流,保持连接,持续推送资源变更事件。
提示:判断是否开流时,可以看响应体能不能一次性组装完成。如果一个响应要分阶段产生,就适合走 SSE;如果一次就能给全,就别硬上流,JSON 响应更稳,调试也更简单。
3.2 客户端如何控制流式会话的建立与断开
客户端侧的控制点主要在Accept请求头和超时设置上。
如果你希望服务端优先返回流式响应,就在请求头里带Accept: application/json, text/event-stream。如果明确不想要流,只接受一次性 JSON,就把Accept设成application/json。
这里有个我在真实项目里踩过的细节:很多服务端实现是"你 Accept 里包含 text/event-stream 我就开流,包含与否没开流就用 JSON"。所以如果你不想处理流事件,务必把 Accept 写干净,不要图省事写*/*,否则服务端大概率会按流式返回,你的 HTTP 客户端解析 JSON 时就会报错。
会话断开方面,Streamable HTTP 没有强制要求客户端发 goodbye 消息。我实测下来,直接关闭 TCP 连接即可,服务端会通过连接断开感知会话失效。只有需要明确取消某次操作时,才需要发notifications/cancelled通知。
3.3 断线重连与会话恢复的实测心得
这个点是我在实际部署中花时间最多的地方。Streamable HTTP 里,客户端如果断线了,默认情况下会话状态可能已经丢失,也可能还在,完全看服务端实现。
有的服务端把 session 状态存在内存里,断开就没了,客户端重连时必须重新initialize;有的服务端支持会话恢复,客户端拿到Mcp-Session-Id后,断开重连时带上这个 ID,服务端就能恢复上下文。
我在接入一个第三方 MCP Server 时,就遇到客户端断线重连后工具状态全丢的问题。后来确认是该服务端把 session 存在内存且 60 秒无活动就清理了。解决办法是在客户端做了两层兜底:第一层是心跳,每隔 30 秒发一次空通知保活;第二层是重连时先尝试带原 session id 初始化,失败了就重新走完整握手流程。
提示:生产环境接入 MCP Server 前,先问清楚对方的 session 生命周期策略。这个信息在文档里很可能不写,直接看代码或者问维护者最靠谱。
4. 跑一个最小实现:用 Python 把 Streamable HTTP 从协议文本落到代码
理论讲再多,不如直接跑通一遍。我下面用 Python 写一个最小可用的 Streamable HTTP Server 和 Client,代码刻意保持精简,重点展示协议交互骨架。
4.1 Server 端最小骨架
我用 FastAPI 实现,因为异步支持和 SSE 响应都现成。先装依赖:
pip install fastapi uvicorn sse-starlette mcp服务端代码核心只有几个路由:
from fastapi import FastAPI, Request, Response from fastapi.responses import JSONResponse from sse_starlette.sse import EventSourceResponse import json import uuid app = FastAPI() sessions = {} @app.get("/mcp") async def handle_get(request: Request): session_id = request.headers.get("Mcp-Session-Id") if not session_id: session_id = str(uuid.uuid4()) accept = request.headers.get("Accept", "") if "text/event-stream" not in accept: return JSONResponse({"error": "SSE required for GET"}) sessions[session_id] = {"connected": True} async def event_stream(): yield { "event": "message", "data": json.dumps({ "jsonrpc": "2.0", "method": "notifications/initialized", "params": {} }) } response = EventSourceResponse(event_stream()) response.headers["Mcp-Session-Id"] = session_id return response @app.post("/mcp") async def handle_post(request: Request): session_id = request.headers.get("Mcp-Session-Id", "") body = await request.json() method = body.get("method", "") if method == "initialize": result = { "protocolVersion": "2025-03-26", "capabilities": {"tools": {}}, "serverInfo": {"name": "demo-server", "version": "0.1.0"} } if not session_id: session_id = str(uuid.uuid4()) sessions[session_id] = {"initialized": True} resp = JSONResponse({ "jsonrpc": "2.0", "id": body.get("id"), "result": result }) resp.headers["Mcp-Session-Id"] = session_id resp.headers["Mcp-Protocol-Version"] = "2025-03-26" return resp if method == "tools/list": return JSONResponse({ "jsonrpc": "2.0", "id": body.get("id"), "result": { "tools": [{ "name": "echo", "description": "echo input", "inputSchema": { "type": "object", "properties": {"text": {"type": "string"}} } }] } }) if method == "tools/call": # 这里故意用流式返回,方便演示按需流式 async def tool_stream(): yield { "event": "message", "data": json.dumps({ "jsonrpc": "2.0", "id": body.get("id"), "result": { "content": [{ "type": "text", "text": "echo: " + body["params"]["arguments"]["text"] }] } }) } return EventSourceResponse(tool_stream()) return JSONResponse({ "jsonrpc": "2.0", "id": body.get("id"), "error": {"code": -32601, "message": "Method not found"} })这段代码把协议里最核心的几个交互都覆盖了:initialize走 JSON、GET 建立流、tools/call走流式响应。如果你想跑起来,用uvicorn demo:app --port 8000启动就行。
4.2 Client 端最小骨架
客户端我用httpx写,不引额外的 MCP SDK,把流程透明地展示出来:
import httpx import json BASE_URL = "http://127.0.0.1:8000/mcp" client = httpx.Client(timeout=30) # 1. initialize resp = client.post(BASE_URL, json={ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "demo-client", "version": "0.1.0"} } }, headers={"Accept": "application/json"}) session_id = resp.headers.get("Mcp-Session-Id") print("session:", session_id) print("result:", resp.json()) # 2. tools/list resp2 = client.post(BASE_URL, json={ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }, headers={"Accept": "application/json", "Mcp-Session-Id": session_id}) print("tools:", resp2.json()) # 3. tools/call 流式调用 with client.stream("POST", BASE_URL, json={ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "echo", "arguments": {"text": "hello mcp"}} }, headers={"Accept": "text/event-stream", "Mcp-Session-Id": session_id}) as resp3: for line in resp3.iter_lines(): if line.startswith("data:"): print("stream event:", json.loads(line[5:]))这段代码跑通后,你就能看到完整的"JSON 握手 + 流式调用"交互链路。注意第三段用的是client.stream,这就是客户端参与"按需流式"的具体方式——请求头里声明接受text/event-stream,然后用流式读取。
4.3 跑通过程中的几个关键观察点
第一,Session-Id 的传递。初始化响应头里的Mcp-Session-Id必须存下来,后续每个请求都带上,否则很多服务端直接返回 400 或强制重新初始化。
第二,流式响应的结束标志。SSE 流以data: [DONE]结尾是 OpenAI 那边的习惯,MCP 的 SSE 流没有强制[DONE]标志,而是以连接关闭作为结束。用 SDK 的话会自动处理,手写解析时要注意不要卡在读流上。
第三,HTTP 状态码与 JSON-RPC 错误的关系。服务端处理 JSON-RPC 请求但业务出错时,HTTP 层面仍返回 200,错误只在 JSON-RPC 的error字段里。这点和 REST 习惯不同,排查问题时别只看 HTTP 状态码。
5. 实操中绕不开的坑:从 streamable http connect failed 说起
热词里反复出现streamable http connect failed: streamable http error: error posting to endpo,这个报错我见过太多次了,基本可以断定是连接建立阶段就失败了,根本走不到协议交互。下面梳理几个最常见的根因。
5.1 最常见的连接失败根因
我遇到的第一类问题是URL 拼错。MCP Server 的端点地址可能不是根路径,而是带前缀的,比如https://host/mcp或https://host/api/v1/mcp。很多 SDK 默认把base_url和路径拼接,如果你在配置里填的是https://host/mcp/,末尾多了一个斜杠,部分服务端会直接返回 404 或重定向,客户端就会报连接失败。
第二类是TLS 握手和代理问题。我这边公司网络强制走代理,而 httpx 的代理设置没生效,导致连接直接超时。排查方法很简单:先用curl -v手动请求一下那个端点,看能不能正常握手。curl 能通,SDK 不行,那问题多半在客户端配置上。
第三类是鉴权头缺失。很多 Streamable HTTP 服务端虽然没说强制鉴权,但接入了网关层 token 校验。我之前接一个小智 mcp 平台(类似wss://api.xiaozhi.me/mcp/?token=...这种带 token 的地址),token 放在 query 参数上,SDK 配置里如果没把整个 URL 原样传过去,token 就被丢了,连接自然失败。
5.2 响应 Content-Type 不合法
这类问题更隐蔽。Streamable HTTP 规范要求服务端响应Content-Type必须是application/json或text/event-stream。如果服务端返回的是application/json; charset=utf-8(带了 charset),大部分客户端没问题;但如果返回的是text/plain,很多严格校验的客户端直接不认。
我记得有个服务端为了让 SSE 兼容 IE 时代浏览器,把响应设成了text/event-stream;charset=utf-8,这个倒还好。真正坑的是有些网关会把响应类型强制改写成application/octet-stream,一旦遇到,你前面怎么调都是失败。排查路径是:抓包看响应头,确认 Content-Type 对不对。
5.3 代理、超时与保活的配置教训
接 MCP Server 的客户端如果跑在容器里,一定要给 HTTP 客户端设置合理的超时值。Streamable HTTP 的 GET 请求建立 SSE 流之后是长期挂着的,如果你用的是通用 HTTP 客户端,read 超时千万别设置太短,否则流式事件稍微间隔长一点,客户端就主动掐断了。
我的经验值是:握手阶段(initialize)超时 10 秒,流式读取阶段不做总超时限制,单次事件间隔超过 120 秒才判定为超时。这个配置在连接稳定的内网环境很够用,在公网环境可能需要再放宽。
提示:遇到无法理解的连接失败,先抓包。用 tcpdump 或 Wireshark 看 TLS 握手和 HTTP 响应头和 body,是空的还是被代理截断了,一眼就能分辨。
6. MCP 生态里的典型落点:从浏览器自动化到安全测试
说了半天协议层的东西,最后聊聊 Streamable HTTP 在实际生态里是怎么被用起来的。热词里 Playwright MCP、Chrome DevTools MCP、Burp Suite MCP 这些项目,正好代表了 MCP 在不同领域的落地场景,而它们的传输选择也在逐渐向 Streamable HTTP 靠拢。
6.1 浏览器自动化场景的传输选择
Playwright MCP 和 Chrome DevTools MCP 这类工具,本质是让 AI 助手能直接操控浏览器。它们通常以 MCP Server 的形式跑在本地,AI 客户端通过 MCP 协议调用browser_navigate、browser_click这类工具。
这类工具的通信链路比较特殊:客户端(比如 Claude Desktop、Cursor)和 MCP Server 往往在同一台机器上,所以很多实现直接走stdio 传输,不走 HTTP。但一旦你想把浏览器控制能力暴露给远程 AI,或者做成一个可以被多个客户端复用的服务,Streamable HTTP 就派上用场了。
我试过用 Docker 部署一个 Playwright MCP Server,再通过 Streamable HTTP 暴露给局域网内其他机器上的 AI 客户端调用。配置流程其实就是设置--transport http参数,然后客户端填同一个端点地址。这里有个细节:因为浏览器控制是高频交互,每个工具调用都会触发页面截图、DOM 提取,数据量大,如果用老式的全量 SSE 长连接,多个客户端同时操作会很卡;Streamable HTTP 的按需流式反而更合适,因为截图结果是一次性 JSON 就能传完的,根本不需要流。
6.2 安全测试场景:工具桥接的通信考量
Burp Suite MCP 和 Yakit MCP 这类安全工具集成的思路,是把 Burp Suite 的代理、扫描器能力封装成 MCP 工具,让 AI 能直接发起请求、查看报告、调整配置。
这类集成有个典型需求:AI 要能发起一个请求,然后持续观察目标响应变化。这种长时观察场景就很适合 Streamable HTTP 的流式响应——服务端可以在一条 SSE 流里推多个阶段的进度事件,客户端不用轮询。我在接这样的服务时,印象最深的是"目标回显"和"扫描进度"这两种事件,用老方案要自己单独做长轮询兼容,切到 Streamable HTTP 后逻辑清爽很多。
6.3 接入 Streamable HTTP 时我建议的路子
如果你正准备把一个现成的 MCP Server 改成 Streamable HTTP 传输,我建议按这个顺序做:
先确认你用的 MCP SDK 版本是否支持2025-03-26协议版本。很多老 SDK 默认还是 HTTP+SSE 时代的行为,改动没升级 SDK 等于白搭。
然后把服务端从"两个端点"改成"一个端点",把原来处理 SSE 连接和消息接收的两段代码合并。这个过程里最容易漏的是Mcp-Session-Id的透传,一定要检查响应头有没有正确返回、后续请求有没有正确读取。
最后写一个最小客户端实测握手和工具调用。建议先跑通initialize,再测tools/list,最后测一个慢工具走流式,这样每一步都好定位问题。整个过程不要急,协议本身不复杂,复杂的是你的服务端框架和网络环境。我这边迁移了四个服务,平均每个服务改动只花了小半天,但踩的坑基本都是代理和 Content-Type 这种环境问题,不是协议理解问题。
回到最初的报错那句streamable http connect failed,如果你按文章里第 5 节的链路排查完还解决不了,大概率就是服务端实现不规范。这时候唯一的办法是抓包对照规范文档逐条核对请求头、响应头、会话 ID 传递逻辑。MCP 规范更新频率不算低,遇到问题直接去看规范原文的 transport 章节,比我这个二手经验更靠谱。