Streamable HTTP 仍然是 MCP 的传输——但 2026-07-28 版本把会话 header、独立的 GET 流以及可恢复性又从里面拿走了。
Streamable HTTP 熬过了 2026-07-28 版本;但一份 2025-11-25 的教程关于它所讲的内容大半没熬过去。Mcp-Session-Id 没了,MCP 端点上的 GET 没了,SSE 流也不再可恢复——取而代之的,是每次 POST 必带的三个 header、在 params._meta 里逐请求完成的协商,以及一条需要客户端显式索取的 subscriptions/listen 流来承载服务器想推的一切。那份让旧传输显得有意思的分布式系统账单并没有被还清,它是被删掉了。被取代的那套形状本文以过去时保留,因为还有大量在跑的服务器只会讲它。
弃用时间线:HTTP+SSE 让位给 Streamable HTTP,然后 2026-07-28 把它掏空。
MCP 的传输章节两年里被改写了三次,一份教程到底在讲哪一版所引起的困惑,占了到达官方 SDK 的"我的客户端为什么连不上我的服务器"工单的大头。2024-11-05 版本定义的 HTTP+SSE 传输带两个端点——客户端把 JSON-RPC 请求 POST 到 /messages/,并保持一条到 /sse 的 SSE 长连接以接收响应与服务器主动通知。2025-11-25 版本弃用了那种形状,定义了 Streamable HTTP:一个 MCP 端点同时接受 POST 与 GET,服务器可以返回普通 JSON 响应、也可以升级为 text/event-stream,会话与可恢复性由 header 承载而不再靠 URL 结构。2026-07-28 版本留下了端点,删掉了其余大部分——没有 initialize 握手、没有会话 header、没有 GET、没有 Last-Event-ID。2026-07-28 版本那一篇给出完整的删除清单与迁移路径;本文讲的是这套传输如今在线上到底长什么样,以及被取代的那套形状长什么样——这样你在接手别人的代码库时认得出来。MCP 架构那一篇勾勒了参与者模型,以及这套传输所承载的 JSON-RPC 层。
本页自己就是这个论点的一件物证。它 2026 年 7 月上线时,开篇警告说:任何描述 POST-再-SSE 双端点接力的教程都早于当前规范——而几周之内当前规范又动了一次,于是这句警告对承载它的这一页同样成立。教训不是 MCP 不稳定,而是传输类内容半衰期很短,读的时候手里必须拿着版本日期。你从任何地方抄东西之前先看日期,包括这里。
第一次迁移很快:Bloomberry 2026 年对 1,412 台生产服务器的调查显示,2025-11-25 发布六个月内已有 93% 迁到 Streamable HTTP。第二次要慢,因为它是破坏性的而非增量的。Python 的 fastmcp、TypeScript 的 @modelcontextprotocol/sdk、官方 Go SDK 以及 Rust 的 rmcp 都已实现当前版本;而 mcp-go——多数 Go MCP 服务器实际上建在其上的那个库——仍然只实现 2025-11-25,这正是下文把被取代的形状保留下来记录、而不是删掉的现实理由。阅读时的经验法则:一篇描述 initialize 握手与会话 header 的文章早于当前规范;一篇描述双端点 POST-再-SSE 接力的文章比那还要早一代。
线上格式从来不是有趣的部分。真正值得展开的,是 Streamable HTTP 把传输从"HTTP 加服务器推送"这种可以在脑子里一次装下的东西,拽成了带粘性路由、会话存储与回放契约的系统——然后 2026-07-28 又把其中大部分拿了回去。本文余下几节就沿着这条弧线走:当年的运维形态是什么、其中还剩下什么、以及哪些部分现在得你自己来做,因为传输不再替你做了。
单端点:POST 用于发送、SSE 用于流式、GET 换来 405。
一台 Streamable HTTP MCP 服务器只暴露一个路径——规范里叫"MCP 端点",具体 URL 由服务器决定,惯例上是 /mcp。客户端把 JSON-RPC 请求 POST 到这个端点,然后看响应的 Content-Type。若头是 application/json,body 就是 JSON-RPC 响应,交换到此结束。若头是 text/event-stream,服务器选择了流式——同一份响应作为一条或多条 SSE data: 帧递交,其间夹带这次请求的进度或日志通知,服务器无话可说时流就关闭。客户端事先无法预知服务器会走哪一路,规范明确说两路都合法;行为得体的客户端两路都会处理。这一部分没有变。
变的是:POST 现在得自带自我介绍。没有握手替一条连接一次性确立协议版本、能力与客户端身份了,于是每一次请求都要重述一遍——中间件需要看见的放 header,其余的放 params._meta。
POST /mcp HTTP/1.1
Host: mcp.example.com
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: resources/read
Mcp-Name: file:///repo/README.md
{"jsonrpc":"2.0","id":1,"method":"resources/read",
"params":{"uri":"file:///repo/README.md",
"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}},
"io.modelcontextprotocol/clientInfo":{"name":"acme-client","version":"2.0.0"}}}}
HTTP/1.1 200 OK
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"result":
{"contents":[{"uri":"file:///repo/README.md","mimeType":"text/markdown","text":"# acme"}],
"resultType":"complete","ttlMs":60000,"cacheScope":"private"}}
这次 POST 里有三个 header 是承重件,而且没有一个像当年的 MCP-Protocol-Version 那样事实上可省。MCP-Protocol-Version(全大写,规范就是这么拼的)必须出现,且必须与 params._meta 里 io.modelcontextprotocol/protocolVersion 的值一致;两份副本对不上就是 400 加 HeaderMismatch(-32020)。Mcp-Method 必须出现在每一次请求上,值与 JSON-RPC 的 method 字段相同。Mcp-Name 必须出现在 tools/call、resources/read 与 prompts/get 上,取自 params.name 或 params.uri;非 ASCII 的值必须使用规范的 Base64 哨兵格式,不能直接塞进 header。
后两个为什么被做成 header,值得明说,因为这正是为它们的啰嗦买单的那个设计决定:代理、网关或 WAF 无需解析 JSON body 就能路由、计量与授权一次请求。逐工具限流、按方法级别拦截、按名字分片,都从应用代码变成了入口层配置——而这一层本来就是多数组织已经在管策略的地方。代价是:少了其中任一 header 的请求就不是格式良好的请求,于是一个只设了 Content-Type 的手写客户端,在合规服务器上会以"看起来像路由 bug"的方式失败。
在 body 里,params._meta 承载着握手当年一次性确立的那些东西。io.modelcontextprotocol/protocolVersion 与 io.modelcontextprotocol/clientCapabilities 是 REQUIRED,io.modelcontextprotocol/clientInfo 是 SHOULD,io.modelcontextprotocol/logLevel 可选——它是 logging/setLevel 仅存的遗产,后者当年为整个会话设一次日志级别,现已删除。少一个必填字段,服务器答 -32602 并配 HTTP 400。用了这次请求没有在 clientCapabilities 里声明的特性,服务器答 MissingRequiredClientCapabilityError(-32021),同样是 400。每次请求多花几百字节,换来的性质是:任何一份副本都能在零先验上下文的情况下服务任何一次请求。
端点的 GET 那一半没了。对一台只讲新版本的服务器发 GET——或者当年用来终止会话的 DELETE——应当(SHOULD)得到 405 Method Not Allowed。服务器主动推送并没有消失,但客户端现在必须点名索取:subscriptions/listen 就是一次普通 POST,只不过它的响应流会在客户端想收通知的期间一直挂着,并由 params.notifications 过滤器精确说明想收哪些。过滤器有四个字段——布尔的 toolsListChanged、promptsListChanged、resourcesListChanged,以及 URI 的 string[] 数组 resourceSubscriptions。那个数组就是 resources/subscribe 与 resources/unsubscribe 的去处:订阅集合不再是你用两个 RPC 去改的服务器端状态,而是开流那次调用的一个参数。按实操构建 MCP 服务器那篇文章的意义把服务器写得地道,大多意味着让 SDK 替你管这条流;手写很少必要,且很容易在细节上错。
POST /mcp HTTP/1.1
Host: mcp.example.com
Content-Type: application/json
Accept: text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: subscriptions/listen
{"jsonrpc":"2.0","id":42,"method":"subscriptions/listen",
"params":{"notifications":{"toolsListChanged":true,
"promptsListChanged":false,
"resourcesListChanged":false,
"resourceSubscriptions":["file:///repo/README.md"]},
"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}
HTTP/1.1 200 OK
Content-Type: text/event-stream
data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"_meta":{"io.modelcontextprotocol/subscriptionId":"sub-7c1e"}}}
data: {"jsonrpc":"2.0","method":"notifications/tools/list_changed","params":{"_meta":{"io.modelcontextprotocol/subscriptionId":"sub-7c1e"}}}
这条流上有三条规则容易做错。服务器必须(MUST)把 notifications/subscriptions/acknowledged 作为流上的第一条消息发出,并且在它之前不得(MUST NOT)发送任何通知——所以一看到第一帧就开始分发的客户端,是在读一条自己尚未确认已经就绪的流,而抢着推送的服务器则是不合规的。流上的每一条通知都带 _meta 键 io.modelcontextprotocol/subscriptionId,这是持有不止一条 listen 流的客户端用来归属收到内容的依据。而请求范围的通知根本不走这条路:notifications/progress 与 notifications/message 留在触发它们的那次请求的响应流上,因为只有那条流知道那次请求是否还在跑。另外注意那些帧里缺了什么——没有 id: 行,因为 SSE 事件 ID 随可恢复性一起没了。
会话:会话 header,以及 2026-07-28 注销掉的那张粘性路由账单。
在 2025-11-25 下,会话 header 就是传输层"分布式系统账单"第一次被端上桌的地方。服务器在第一次 initialize POST 时生成 id,作为响应 header 返回——那一版把它拼作 MCP-Session-Id——并期待此后该客户端每次请求都带上它。id 到底寻址什么取决于服务器:任何跨请求存在的协议级状态——notifications/tools/list_changed 背后的订阅表、等待用户回复的征询回调、每会话采样预算、为可恢复性保留的 SSE 事件缓冲——都以会话 id 查找。无状态服务器可以选择不签发,但放弃会话就意味着失去可恢复性与服务器主动发起的那些流程,因为它们本质上跨请求。2025-11-25 规范明确要求:会话 id 必须密码学安全、对客户端不透明、不编码任何用户数据。
2026-07-28 把这个 header 连同这个概念一起删了。一台只讲新版本的服务器应当(SHOULD)忽略收到的会话 header 而不是因此报错,并且不得(MUST NOT)自行签发或回带任何会话 id。(给要去 grep 的人补一句:2026-07-28 的向后兼容小节把它拼作 Mcp-Session-Id,而 2025-11-25 拼作 MCP-Session-Id。HTTP header 名大小写不敏感,所以这是文档层面的差异而非线上差异——但引用哪一版就照哪一版的写法拼。)现在没有每连接状态,也没有任何替代品。规范的措辞很直白:跨请求的状态"必须由客户端在每次请求上传递的一个显式标识来引用",且"一条打开的连接——比如一个 STDIO 进程——不是一次对话或一个会话。"如果你的服务器在调用之间留着一个游标、一份工作区选择、或者一个建到一半的事务,那东西现在需要一个名字、一段生命周期,以及一个随请求体旅行的 id。
本节余下的部分属于维护读物——它适用于还在生产里跑的 2025-11-25 服务器,不适用于你今天要写的任何东西。这样一台服务器一旦不止一份副本,会话 id 立刻变成路由问题:副本 A 签发的会话所指向的状态挂在 A 上,副本 B 就没法响应针对这份会话的请求——除非状态被共享——也就是要么在负载均衡上把会话钉到某份副本,要么在共享存储(Redis、DynamoDB 之类)里放着供每份副本读取。用客户端 IP 做哈希的负载均衡是很多团队的第一反应,也是最可预测地会失败的那个:移动客户端会换 IP、公司 NAT 把很多用户折叠到少数几个地址上、云端客户端会把出口在一批地址间轮换。直接用会话 header 做哈希——只要是像样点的 ingress 控制器都支持——才是"想要钉住时"该用的原语。而如果你在写一台新服务器,正确的哈希键是:没有键。
可恢复性:Last-Event-ID、SSE 回放契约,以及它们的移除。
可恢复性是当年让 2025-11-25 的 Streamable HTTP 与朴素的"HTTP 加事件"传输最明显拉开距离的特性。当服务器选择流式响应时,每条 SSE 帧都带一个 id: 字段——一个由服务器分配、对客户端不透明的标识,客户端应当记住它。若连接在流结束前断了,客户端的重连办法是:在 MCP 端点上打开一次新的 GET,把标准 SSE 的 Last-Event-ID header 设成最后一条成功收到的帧的 id;服务器就有义务把此后签发过的每一条事件按顺序重放一遍,再接着推新事件。客户端的体验,是即便碰到 TCP 重置、负载均衡超时、浏览器标签暂停,也是一条无缝的流。
# Superseded: the 2025-11-25 resume. Kept here to be recognisable, not copied.
GET /mcp HTTP/1.1
Host: mcp.example.com
Accept: text/event-stream
MCP-Protocol-Version: 2025-11-25
MCP-Session-Id: 3f9a2c81-0b6d-4e2a-9b71-8d5e2c0a4f11
Last-Event-ID: evt-00047
HTTP/1.1 200 OK
Content-Type: text/event-stream
id: evt-00048
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"pt-9","progress":0.7}}
# The same request against a 2026-07-28 server:
HTTP/1.1 405 Method Not Allowed
Allow: POST
2026-07-28 把 SSE 事件 ID 与 Last-Event-ID 一并移除了,意思就是:流不可恢复。只讲新版本的服务器会忽略传入的 Last-Event-ID;没有留存缓冲可供寻址,也没有任何东西可供回放。流断了的时候,客户端必须(MUST)把这份活儿作为一次带新请求 ID 的新请求重新发起——不是重连,是新请求——而对 subscriptions/listen 流,则必须(MUST)连同过滤器重新发一次 subscriptions/listen。后果落在工具作者而不是传输作者头上:重新发起的请求就是一次货真价实的新请求,所以任何带副作用的东西要么本身幂等,要么在参数里带上由客户端提供的去重键。这一点从来就成立,只是当年有回放兜着、跳过它很容易。现在跳不过去了。
回放预算——服务器要把某个 id 之后的事件留多久,重连才能成功——是 2025-11-25 契约需要却始终没给的那一个数字,而它的消失正是这次移除最明确的收益。再没有人需要给保留窗口定尺寸;再没有服务器会因为一个客户端几乎不读、却挂了一整天的会话而耗光内存;那个经典 bug——事件 id 取自一次发版就归零的进程内计数器,于是可恢复性悄悄失效,客户端拿着新进程不认识的 id 重连,缺失的事件就这么丢了——也不可能再发生。你换到的是更便宜的服务器,以及一个必须对"自己愿意重做什么"给出明确说法的客户端。这笔交易里亏得最多的,是那些流式跑很长工具调用的团队,而这正是 io.modelcontextprotocol/tasks 扩展存在的目的:需要持久的活儿,现在拿到的是一个你去轮询的标识,而不是一条你祈祷别断的流。
横向扩容:会话有状态时坏掉了什么。
可恢复性一旦真的做起来,横向扩容就不再是"加几份副本"的事,而变成一个设计问题。负载均衡必须把同一会话的流量路由到同一份副本(粘性路由),或者每份副本都要能响应任何会话(共享状态);带 Last-Event-ID 的 SSE 重连必须落到那份要么手里就有对应事件、要么能从事件持久化的地方读出来的进程;会话 TTL 必须在副本间对齐,免得一份副本把另一份副本还在服务的会话回收了。这些里的每一件在朴素扩容模式下都会坏,而失败症状——重连拿到空流、征询回复永不结算、采样请求挂住——在客户端作者看来像协议 bug,实际是部署 bug。
现场里主要有两种形状。粘性路由形状把会话状态放在拥有该会话的那份副本的内存里,用会话 header 作为负载均衡的哈希键,并接受"一份副本挂了就意味着挂在它上面的每个会话都得重新 initialize";它简单、尾延迟低、可以近乎线性地扩容——直到某个热门会话变成副本级热点。共享状态形状把会话数据放到 Redis 或等价的东西里,任何一份副本都能响应任意请求,每次请求付一个 round-trip,换来把流量自由挪动的能力;副本变得可互换,滚动发版不再丢会话。团队一般先用粘性,直到某次事故把他们推向共享——这个迁移是 Streamable HTTP 服务器生命周期里一个众所周知的里程碑。2026-07-28 把这个里程碑删掉了。请求自带自我描述,于是普通轮询负载均衡就够用,发版就是一次寻常的滚动重启,没有会话存储需要维持可用性,网关也能完全不拆 body 就按 Mcp-Method 或 Mcp-Name 分片。
剩下的更少,但不是没有;而"远程 MCP 服务器"这个词做的活,也依然比多数教程承认的要多。一条 subscriptions/listen 流在它的生命周期里仍然把一条长连接钉在一份副本上,所以连接数、空闲超时、以及一次重启的爆炸半径仍是容量规划的内容;差别在于重启之后会发生什么——基本没什么,因为没有状态要迁移,也没有东西要回放,客户端重新发一次 subscriptions/listen 就完好如初。任何真正有状态的东西,现在要么活在一个你自己设计的显式标识背后,要么活在 io.modelcontextprotocol/tasks 扩展里——它的持久性成了一个你看得见的地方里你自己的问题,而不是一条 TCP 连接的隐含属性。一台本地 stdio MCP 服务器是宿主拉起的一个子进程;一台 2025-11-25 下的远程 Streamable HTTP 服务器是一个带会话、粘性路由与回放缓冲的分布式系统;而在 2026-07-28 下,它更接近一个普通 HTTP API,剩下的难点是授权、限流、多租户,以及那些 listen 流。这是一次货真价实的化简,也是一个重读自家部署的理由:相当多的 MCP 基础设施,是为了解决协议如今已经不再有的问题而建的。
本地 HTTP 服务器:Origin、DNS rebinding 与 localhost 陷阱。
Streamable HTTP 不只是远程服务器的事,而且这一节是六节里新版本几乎没碰的一节——Origin 检查与套接字绑定都是传输层防线,两版规范都没有改动它们。本地 MCP 服务器常见的一种模式是给 HTTP listener 绑到 127.0.0.1 而不是走 stdio,因为这样在浏览器客户端里更容易复用、或者用 curl 调试更方便。这个模式有一个具体攻击——DNS rebinding——MCP 的安全最佳实践附录自 2025-11-25 版本起就点名了它,因为它已经在真实世界里被拿来打过本地跑着的开发工具。机制很简单:攻击者让用户浏览器加载一个自己控制域名的页面,那个域名的 DNS 先解析到公网 IP 只为把页面发下去,然后改绑到 127.0.0.1。在浏览器看来源没变,同源检查通过,攻击者页面里的 JavaScript 就可以对着用户的本地 MCP 服务器发同源请求,触发它暴露的任意工具。
两道防线要叠加使用,规范也期待两个都做。第一道,是对每一个请求都检查 Origin header,把不在显式白名单上的(一般就是本地跑 UI 的那台 host)通通拒掉。第二道,是把 listener 显式绑到 127.0.0.1 或 ::1,而不是绑 0.0.0.0 或者空字符串,这样即便 Origin 检查配错了,socket 从环回以外的任何接口都碰不到。历史上任何一道都省掉的服务器,往往在上线一周之内被利用;这不是纸上谈兵的模式。
// Express-shaped Origin allowlist middleware for a local Streamable HTTP server.
const ALLOWED_ORIGINS = new Set([
'http://localhost:5173',
'http://127.0.0.1:5173',
'https://claude.ai',
]);
app.use('/mcp', (req, res, next) => {
const origin = req.headers.origin;
if (!origin || !ALLOWED_ORIGINS.has(origin)) {
return res.status(403).json({ error: 'origin_not_allowed' });
}
next();
});
app.listen(3945, '127.0.0.1'); // bind loopback explicitly, never 0.0.0.0
这段片段有两条脚注。第一,把空的或者缺失的 Origin header 当作安全默认放过去不对;浏览器在跨源请求上会带这个 header,缺席要么是非浏览器客户端(那应当靠鉴权而不是靠 Origin 处理),要么是一个服务器本就不该服务的浏览器请求。第二,白名单不是鉴权的替代——一台绑环回并做了 Origin 检查的本地服务器,只要它暴露的工具有任何后果,就依然需要跟远程服务器一样的 OAuth 2.1 故事。Origin 检查能挡住 DNS rebinding;它挡不住被入侵的本地进程、恶意的宿主配置、或者安全最佳实践附录另外点名的令牌透传反模式。传输层的防线是一层;鉴权层的防线是另一层;一台上线的本地服务器两层都要有。
把六节合起来看,Streamable HTTP 传输依然是两份文档合一,而 2026-07-28 把第一份变短了、把第二份变短了很多。在线上,它是一份紧凑的 HTTP 配置——一个端点、一种动词、三个必带 header、一个 _meta 块,以及一次可选的 SSE 升级——任何 HTTP 服务器都能在一下午写出来。在部署上,它不再是一台 MCP 服务器变成分布式系统的那一刻:会话、粘性路由、回放缓冲与保留策略统统没了,剩下的就是任何团队本来就在跑的那套寻常 HTTP 运维,外加几条长连的 listen 流和授权那摊事。仍要服务 2025-11-25 客户端的团队会同时付两份账单,这就是双版本窗口的真实代价。而写新服务器的团队最该注意的是:2025 年到 2026 年初写下的关于 MCP 运维的建议——包括本页的第一版——有多大比例,如今是在给一套已经不存在的传输提建议。