ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从双连接到单端点:MCP Streamable HTTP 迁移实践与避坑指南

从双连接到单端点:MCP Streamable HTTP 迁移实践与避坑指南

前一段时间我把团队里三个内部 MCP Server 从老式 HTTP+SSE 传输迁移到了 Streamable HTTP,起因非常具体:部署在内网的一台服务总是隔一段时间就断连,客户端日志反复出现 SSE 重连,最后定位到是网关对长连接做了闲置回收,而老传输恰恰要求客户端先开一条长连接再发消息——两个端点、两套路径,中间任何一层代理都可能变成断点。换成 Streamable HTTP 之后,这个问题基本消失了。这篇文章不打算把规范再翻译一遍,就讲清楚它跟旧方式到底差在哪、握手怎么走、流式如何按需触发,以及我在客户端接入和网关部署时踩过的坑。如果你正准备写自己的 MCP Server,或者被"streamable http connect failed"这类报错折磨过,这篇应该能让你少绕几步。

1. 从"双连接"到"单端点":Streamable HTTP 到底改了什么

1.1 先说清楚 MCP 里的"传输"是什么

MCP 是应用层的软件协议,作用是把 AI 客户端(Claude Desktop、Cursor、各类 IDE 插件)和外部工具、数据源连接起来,让模型能调用工具、读写资源、走完多步任务。你可以把它理解成协议世界里的"USB-C"——大家都在推动统一接口,但接口的具体形态和接线方式,由传输层决定。MCP 最常见的传输有两种:Stdio(本地子进程通信)和 HTTP(远程服务)。Streamable HTTP 就是这个 HTTP 传输的新范式,它在 2025 年 3 月被正式纳入 MCP 规范,6 月的 2025-06-18 版本里做了进一步收敛。

不要被"HTTP 传输"这个名字迷惑。它解决的远不止"怎么把消息发出去",还包括:服务端能不能主动推消息、请求要不要保持会话、端点该暴露哪些路径。这些问题直接决定了你往生产环境部署的时候舒不舒服。

1.2 老传输的两个深坑

老版本叫 HTTP+SSE,工作模式是:客户端先 GET /sse 打开一条 Server-Sent Events 长连接,再从另一个路径 /messages 用 POST 把 JSON-RPC 消息发出去。也就是说,一个服务至少要暴露两个端点,客户端和服务端各自要维护一套连接状态。

这套设计在 demo 里跑得很顺,生产环境就原形毕露了。第一,很多企业网关、云负载均衡器默认会对 SSE 连接做缓冲或空闲回收,导致客户端莫名其妙断流。第二,服务端想让客户端重连回来,只能靠 SSE 里的 retry 机制,但一旦链路里有代理截断,重连也救不回来。第三,两条路径意味着鉴权、日志、限流都要配两遍,出问题排查时得同时看两个连接的状态。

1.3 一个端点、按需流式

Streamable HTTP 的核心就两句话:所有 JSON-RPC 消息都发到同一个 URL;响应可以是一条完整的 JSON,也可以是一条按需开启的 SSE 流。客户端不需要预先维持任何长连接,服务端也不再承诺"永远不断流"。断连的影响面从"整个通信不可用"缩小到"当前这一次请求失败",重试成本大幅降低。

那为什么还要保留流式,而不是全部改成普通请求-响应?因为 MCP 里确实有需要持续输出的事件,比如工具调用过程中的进度日志、大文件分块读取、服务端向客户端发起的采样请求。这些场景如果用普通 HTTP 响应硬压会很别扭:要么客户端一直等,要么服务端把数据全部攒完一次性返回。按需流式的意思是,服务端自己判断哪些响应适合一次性返回、哪些适合流式,客户端要对两种格式都有处理能力。

这是整个设计最聪明的地方——流式变成了一种能力,而不是枷锁。不需要流的时候,服务端正常返回 application/json;需要流的时候,它返回 text/event-stream。单一端点负责入口统一,按需流式负责灵活度。

2. 单一端点的请求语义:POST 才是主角

2.1 一条路径走完所有 JSON-RPC 方法

在 Streamable HTTP 下,一个 MCP Server 对外通常只需要暴露一个路径,比如https://your-service.com/mcp。initialize、tools/list、tools/call、resources/read、prompts/get、notifications/initialized……无论请求是"有响应"还是"纯通知",全部通过 POST 发给同一个 URL。客户端每次请求需要带几个关键 header:

  • Content-Type: application/json
  • Accept: application/json, text/event-stream
  • MCP-Protocol-Version: 2025-06-18

这里最容易犯的错误是把 Accept 写成二选一:只写text/event-stream或只写application/json。协议要求客户端必须同时声明能接受两种响应格式,这样服务端才能在该返回 JSON 时返回 JSON、该推流时推流。只声明一种,某些服务端实现会直接回 406,或者干脆按它最保守的方式处理。

为什么 POST 是主角?因为 MCP 的调用模型本质上是 JSON-RPC 2.0,所有"客户端发指令、服务端返回结果"的交互都适合 POST。GET 留给"服务端到客户端的单向流"这种可选项,DELETE 用来关闭会话。这三个方法加起来就是完整语义了,不再需要 RESTful 风格的一堆路径设计。

2.2 响应头与版本协商:不是"返回 200 就行"

服务端在处理每个 POST 请求后,成功响应必须带MCP-Protocol-Version响应头告诉客户端协议版本。客户端在请求里也会带上自己支持的版本。两边版本不一致怎么办?规范的做法是:服务端返回一个 JSON-RPC 错误响应,在错误信息里给出它支持的协议版本,客户端再决定是否用新版本重新初始化。也就是协议层面的"能力谈判"。

实际线上最常见的 400 就是"客户端没带 MCP-Protocol-Version 请求头,而服务端只接受带版本头的请求"。这个头在 SDK 内部通常由 transport 自动注入,但如果你用 curl 手调、或者自己封装了 HTTP client,就特别容易漏。漏掉的后果不是"协商失败"这种友好报错,而是赤裸裸的 400,很多人因此误判成路径写错或 token 失效。

2.3 两种响应格式:聚合 JSON 与 SSE 流

拿到 POST 请求后,服务端可以选择以下两种方式之一返回:

  • 聚合响应:Content-Type: application/json,Body 里就是完整的 JSON-RPC 响应体,适合绝大多数即时结果。
  • SSE 流:Content-Type: text/event-stream,Body 按 text/event-stream 格式逐步推多个 JSON-RPC 消息,适合需要分阶段输出的场景。

一个典型聚合响应的头长这样:HTTP/1.1 200 OK,Content-Type: application/json,MCP-Protocol-Version: 2025-06-18,服务端需要会话时还会带上MCP-Session-Id头。而 SSE 流的 Body 是下面这种格式:

HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive MCP-Protocol-Version: 2025-06-18 event: message data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"demo-server","version":"1.0.0"}}} event: message data: {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","description":"echo input"}]}} event: end data: [DONE]

每个事件以event: message开头,data行是 JSON-RPC 消息,多条事件之间用空行分隔,流结束前服务端会发一个event: end事件,客户端读到 end 才认为这次交互彻底结束。这里有个非常关键的细节:客户端不能因为"读到了完整的 JSON-RPC 结果"就提前关闭连接,必须等到 end 事件。旧版 HTTP+SSE 因为没有明确的结束标记,连接断了客户端也分不清是正常结束还是异常中断,2025-06-18 规范加入 end 事件就是专门解决这个问题的。

提示:自研客户端时,对 SSE 流的读取一定要以"end 事件"作为结束条件,不要用"响应体读完"或"拿到目标 result"来判断。否则你会在 Keep-Alive 连接上反复遇到"流没结束但消息已经处理完"的诡异状态。

2.4 DELETE 与 GET:会话与通知的可选项

DELETE 方法用于关闭会话。比如客户端要退出一个长任务、释放服务端资源,发一个DELETE /mcp,服务端清理会话上下文,返回 200 或 204。GET 则是给"服务端主动向客户端推消息"留的口子:如果服务端支持,客户端可以先发一个 GET 保持流打开,后续服务端发起的通知(比如 log、progress、sampling)都从这条流上走。如果服务端不区分,客户端完全可以不用 GET,所有消息都在 POST 的响应里带回来。

要明确一点:GET 和 DELETE 都不是必须实现的。服务端能力列表里会说明自己支持哪些,客户端 SDK 会先探测服务端的能力再决定要不要用。对初学者来说,抓住"POST 是命根子"就够了,剩下的都是能力谈判。

3. 按需流式:服务端如何决定"什么时候推,什么时候等"

3.1 判定逻辑其实很朴素

按需流式需要服务端针对每个请求回答两个问题:这次响应是立即可得的完整结果吗?如果不是,我能不能分批把进度吐给客户端?如果"是/是",那就走 SSE;否则走聚合 JSON。

举个具体例子:一个 tools/call 调用本地命令,命令执行 3 秒才完成,这 3 秒里服务端可以周期性地推送进度日志,最后把最终结果推出来——这种情况必须 SSE 流。而一个 resources/read 请求,资源就几 KB,服务端一次读完直接返回 JSON 完事——这种情况走聚合响应。判定依据不应该是"这个方法名是什么",而应该是"这次请求的实际数据特征"。

3.2 SDK 里怎么按需实现

在 MCP TypeScript SDK、Python SDK 里,服务端开发者通常不需要太关心底层如何选流式还是聚合——你只需要在路由层做判断,然后返回不同类型的响应对象。很多实践是:封装一个统一方法,数据量小于某个阈值就返回普通 JSON 结果,超过阈值或者客户端带了 stream 参数就走 ReadableStream 包装。

我自己的服务端是给 tools/call 增加了一个params.stream标志,客户端想要流式就传 true。这样既保持聚合路径简单,又给高级客户端留了流式选择。但别指望每个客户端都会传这个参数,所以服务端一定要做兜底判断——根据数据特征自动决定是否切流。只支持"显式传 stream 才流式"的服务端,遇到不理解这个参数的通用客户端,体验会差一截。

3.3 流式再好也别滥用

不是说所有响应都应该尽量流式。SSE 流是有成本的:连接要维护、代理要放行、客户端要区分消息边界。不要把"字符串够大"当成流式的唯一理由。我自己踩过的一个坑是,某个文件读取服务把每次 resources/read 都切成 1KB 一条的 SSE 流,结果日志量爆炸,客户端调试时根本看不清哪条消息对应哪个请求。后来改成大于 1MB 才走流式,小文件一律聚合 JSON,两边体验都好了。

这里给一个参考阈值:文本类工具结果通常在几十 KB 以内,走聚合 JSON 完全够;只有在你预期结果会持续产生超过几百毫秒、或者数据量达到 MB 级时,才值得切到流式。阈值本身可以做成配置,方便按业务调整。

3.4 一个流里塞多个消息:通知和进度如何共存

流式的另一个价值是可以在一个流里携带多个 JSON-RPC 消息:中期通知(log、progress)、最终结果、甚至服务端发起的 sampling 请求。这在聚合响应里是做不到的——聚合响应只能是一个响应体,想多发几个消息只能多开几个请求。

所以如果你正在开发需要进度反馈的工具型 MCP Server,比如构建工具、拖拽工具、代码调试器,Streamable HTTP 的流式基本是唯一干净的交互方式。客户端可以在同一个 SSE 流里先看到"开始执行""步骤 1 完成""步骤 2 完成",最后看到 result。这种体验和旧版靠多条通知拼接完全不同,调试链路的可观测性也好了很多。

4. 会话生命周期:无状态与 MCP-Session-Id 的取舍

4.1 无状态优先:简单但有限的交互

Streamable HTTP 支持完全无状态的运行方式:每个 POST 请求独立处理,服务端不在内存里保留客户端上下文,响应里也不给会话 ID。对大多数无状态工具服务(一个纯计算器、一个天气查询、一个静态资源读取器)来说,这是最理想的:部署简单、水平扩展容易、任何一层负载均衡都能随便路由。

但无状态的问题也很明显:服务端没法持续跟踪"这个客户端上一次做到哪一步"。MCP 里有大量交互天然是有状态的,比如文件系统工具记住当前工作目录、多步骤任务依赖前序结果、服务端要主动推送日志。这些都需要会话约束上下文。MCP 规范没有强制所有服务端必须有状态,它把决定权完全交给了服务端实现。

4.2 MCP-Session-Id 的工作方式

当服务端需要会话时,会在响应头里返回MCP-Session-Id。客户端收到后,必须在后续所有 POST、GET、DELETE 请求里带上这个头。带上 ID 之后,服务端就知道这是同一个客户端的连续请求。简单理解:相当于 Web 应用里的 Cookie,只是这里用显式响应头来交接,而不是浏览器自动管理。

需要特别注意:服务端可以只接受带有效会话 ID 的请求。如果客户端没带、或会话已经失效,服务端可以返回 404 或直接报会话无效。客户端 SDK 对此的处理通常是抛异常,然后让上层重新走 initialize。如果你在日志里看到 "Invalid session" 这种词,多半是会话过期了,不是协议错误——重新 initialize 一次就好。

4.3 会话超时与清理策略

我倾向给非关键会话设一个相对短的 TTL,比如 30 分钟,服务端定期清理。MCP 客户端一般不会频繁保持一个会话好几天,长会话反而增加内存泄漏和状态错乱的风险。另外,如果服务端支持DELETE /mcp关闭会话,客户端结束任务后最好显式调一次 DELETE,而不是依赖服务端超时回收——资源释放更及时,日志审计也干净。

还有一个经验:不要在会话 ID 里编码业务数据。会话 ID 应该是一个不透明字符串,客户端不解析,服务端也不往里塞敏感信息。它可能出现在访问日志、链路追踪、代理日志里,一旦编码了用户信息或内部路径,就等于把敏感数据写进了明文日志。

5. 客户端接入实测:握手序列、SDK 调试与常见报错

5.1 用 curl 完整走一遍握手

先发 initialize,这是所有 MCP 交互的第一步:

curl -i -X POST https://your-service.com/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "MCP-Protocol-Version: 2025-06-18" \ -d '{ "jsonrpc":"2.0", "id":1, "method":"initialize", "params":{ "protocolVersion":"2025-06-18", "capabilities":{}, "clientInfo":{"name":"debug-client","version":"1.0.0"} } }'

服务端通常返回 200,Content-Type: application/json,带MCP-Protocol-Version头。如果服务端要会话,响应头里就有MCP-Session-Id,把它存下来,后续请求带着。然后发notifications/initialized(注意这是通知,不需要 id),再之后才可以调用 tools/list、tools/call 等业务方法。

规范的顺序必须严格遵守:先 initialize,确认协议版本和服务端能力,再发 initialized 通知,最后才开始业务请求。跳步会导致服务端抛错——这是我见过最多的一类接入错误,尤其是很多人在本地调试时手动复制消息,漏掉了 notifications/initialized 这一步。

5.2 "streamable http connect failed" 排查链路

那个非常常见的报错streamable http connect failed: streamable http error: error posting to endpoint,本质是客户端 SDK 在 POST 请求这一层抛出的最外层包装。看到它别慌,按下面的链路查:

现象优先怀疑对象怎么验证
401 / 403鉴权失败确认 token 是否过期、服务端要求哪种鉴权方式
400协议版本缺失或不匹配检查请求头里有没有 MCP-Protocol-Version
404端点路径错误用 curl 原样请求,确认 URL 完整
408 / 504代理超时或服务端响应慢看服务端日志,确认有没有收到请求
curl 能通但 SDK 不行SDK 版本太旧升级 MCP SDK,或对比 SDK 打印的请求头和 curl 是否一致
代理环境行为异常代理剥离响应头抓包对比直连和走代理的响应头差异

先 curl 复现,这步能立刻分离"网络问题"和"协议问题"。如果 curl 都收不到预期响应,问题在服务端或网络;如果 curl 正常而 SDK 报错,重点查 SDK 版本和 transport 配置。

还有一个非常隐蔽的原因:代理缓存。某些企业代理会缓存 POST 响应或剥离未知响应头(比如 MCP-Session-Id),服务端明明创建了会话,客户端却没收到 ID。遇到这种问题,用 Burp 或本地抓包工具对比"直连"和"走代理"两次响应头的差异,往往一眼就能看出哪个头被吞了。

5.3 客户端 SDK 选型与版本坑

TypeScript SDK 和 Python SDK 是主流。TypeScript 的 StreamableHTTPClientTransport 日常用起来很顺手,但要注意它跟着 SDK 版本走,MCP SDK 迭代很快,几天不更新就可能不支持最新协议版本。我的习惯是:在 CI 里固定 SDK 版本,同时专门写一个"协议版本协商"测试——故意用错误版本初始化,确认服务端能返回可读的错误信息,而不是裸 400。另外,非必要不要自己封装 HTTP transport:版本头、会话头、SSE end 事件这些细节太多,自己造轮子一次能让你修三天。

如果你是在浏览器扩展或 IDE 插件里接 MCP,还要留意 CORS 预检。很多客户端请求是从浏览器发出的,服务端必须正确处理 OPTIONS 预检,并在 Access-Control-Allow-Headers 里放行 Content-Type、Authorization、MCP-Protocol-Version、MCP-Session-Id。少放一个头,浏览器就可能拦截整个请求,而报错信息又特别含糊。

6. 网关与安全实践:暴露 Streamable HTTP 前要想清楚的事

6.1 别把凭证放 URL 里

我在不少公开分享里看到过这样的端点示例:https://example.com/mcp/?token=xxx。本地联调用用可以,一旦放到公网就是定时炸弹:token 会进网关访问日志、浏览器历史、反向代理错误页、各种链路追踪系统,甚至可能被爬虫抓到。MCP 服务本身没有内置鉴权规范,所以更应该在传输层做好,推荐用Authorization: Bearer,至少也要用自定义请求头。如果服务端已经不支持改 header 了,宁可套一层自己的网关把 token 换掉,也不要让它在 URL 里裸奔。

除了凭证,还有一个容易被忽略的是暴露范围。MCP 端口相当于把你的工具调用能力开放给了网络,如果不需要公网访问,就绑内网或 localhost;必须公网暴露的话,建议加一层鉴权网关,而不是让 MCP Server 直接裸奔在公网 IP 上。

6.2 代理缓冲与超时:SSE 流在网关上的两个杀手

如果服务端要走流式,网关必须关掉响应缓冲。Nginx 里是proxy_buffering off,部分 PaaS 平台需要单独开启 streaming 选项。缓冲开着,SSE 事件会被攒到一定量才转发,客户端看到的"实时进度"变成"憋半天的批量更新",体验和调试都会出问题。

第二个是空闲超时。SSE 流在事件间隔长的时候看起来像"死连接",网关注销机制很容易把它干掉。需要把对应路由的空闲超时调到比业务最大事件间隔更长。如果网关参数改不了,可以考虑让服务端定期发ping事件保活——在不影响业务消息的前提下,把连接状态维持在"活跃"。

6.3 日志与可观测性:把协议头纳入审计

访问日志建议至少记录这些字段:客户端 IP、请求路径、响应状态码、MCP-Protocol-Version 响应头、MCP-Session-Id(脱敏或哈希后记录)、响应类型(json / sse)。回看生产问题的时候,这些字段比什么都有用。我经历过一次线上会话上下文串台,最后就是靠把 MCP-Session-Id 字段按时间排序后,发现某个会话 ID 被负载均衡路由到了两个不同的服务实例,导致状态不一致。这种问题没有协议头级别的日志,根本定位不到。

如果你正在把自己的工具接进 MCP,我的建议很简单:先按无状态把核心方法跑通,再按需把流式加进来,最后再考虑要不要上会话。Streamable HTTP 的优雅在于它把复杂度兜在协议层,让你从"维护两条连接"的泥潭里解脱出来。重点是握手顺序、协议版本头、end 事件这三件事,把它们刻在脑子里,大概率能少走我走过的弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进