Streamable HTTP 与 SSE:把远程 MCP server 迁到当前的传输方式
如果你的 MCP server 有一个让客户端用 GET 打开的 /sse 地址,那它用的是 2024-11-05 版本里的 HTTP+SSE 传输。规范在 2025-03-26 用 Streamable HTTP 取代了它。之后,当前版本 2026-07-28 又把 Streamable HTTP 改了一遍:没有握手,没有会话,没有 GET 流。所以「迁到当前的传输方式」可能是两次迁移,而你可能希望老客户端在两次迁移中都能继续用。
这篇文章对比这三种形态,按规范里的向后兼容规则讲怎么继续服务老客户端,最后给出验证用的 curl 命令。把 your-server.example.com 换成你的主机名就行。
远程 MCP 的三种形态
| HTTP+SSE(2024-11-05) | Streamable HTTP(2025-03-26 到 2025-11-25) | Streamable HTTP(2026-07-28) | |
|---|---|---|---|
| 端点 | 一条 GET 流,加一个由服务器在 endpoint 事件里告知的 POST 地址 |
一个 MCP 端点,支持 POST 和 GET | 一个 MCP 端点,只用 POST |
| 回复走哪里 | 作为 message 事件,走 GET 流 |
在这个 POST 的响应里:JSON 或 SSE 流 | 在这个 POST 的响应里:JSON 或只属于这个请求的 SSE 流 |
| 握手 | initialize |
initialize |
没有:每个请求都带 _meta,服务器必须实现 server/discover |
| 会话 | 没有定义;回复依赖客户端那条打开着的 GET 流 | 可选的 Mcp-Session-Id,在 initialize 时下发 |
没有 |
| GET | 打开那条流 | 打开一条可选的额外流,或者回 405 |
不属于这个传输;回 405 |
| 服务器发给客户端的请求 | 走那条流 | 走 SSE 流 | 放在结果里返回;客户端带着答复重试 |
| 断流后续传 | 没有定义 | Last-Event-ID |
不支持;客户端重新发请求 |
最大的区别在回复走哪里。在 HTTP+SSE 下,一个 POST 的回复要走另一条长连接的 GET 流,所以收到 POST 的那个进程,得能找到握着这个客户端那条流的进程。Streamable HTTP 直接把回复放在这个 POST 自己的响应里。到了 2026-07-28,连会话都没有了,任何一个实例都能回答任何一个请求。
2026-07-28 改了什么
如果你在 2025 年已经迁到了 Streamable HTTP,下面这些是和传输有关的改动:
- 没有
initialize,也没有notifications/initialized。每个请求都在_meta里带上io.modelcontextprotocol/protocolVersion和io.modelcontextprotocol/clientCapabilities,并且应该带上io.modelcontextprotocol/clientInfo。 - 没有会话。
Mcp-Session-Id头取消了。需要跨调用保存的状态,改用显式的句柄,由客户端作为工具参数传回来。 - 没有 GET 流。变更通知改为走
subscriptions/listen这个 POST 的响应流。 - 没有续传。
Last-Event-ID和 SSE 事件 ID 都取消了。流断了,这个请求就丢了,客户端用新的 ID 重新发。 - 关闭一个请求的响应流,就是取消这个请求。
- 新增必需的请求头:每个 POST 都要带
MCP-Protocol-Version和Mcp-Method,tools/call、resources/read和prompts/get还要带Mcp-Name。缺了头,或者头和请求体对不上,会得到400和错误码-32020(HeaderMismatch)。 - 不支持的版本会得到
400和-32022(UnsupportedProtocolVersion),并列出服务器支持的版本。不存在的方法会得到404和-32601。 - HTTP+SSE 从 2025-03-26 起就已弃用,现在按规范的功能生命周期政策被正式列为 Deprecated:新实现不应该再采用,已有实现应该迁移;以后的版本可能会把它移除。
让老客户端继续能用
规范的向后兼容一节(2025-03-26 起就有,2026-07-28 也保留了)对服务器只说了一件事:在新的 MCP 端点之外,继续保留老传输的 SSE 端点和 POST 端点。你也可以把老的 POST 端点和新的 MCP 端点合成一个,但规范提醒,这可能带来不必要的复杂度。分开更简单:/sse 和它的 POST 地址原样保留,另外加一个 /mcp。
了解一下同时支持两种传输的客户端是怎么判断你用哪一种的,会有帮助,因为你的服务器得给它正确的信号:
- 它先向用户给的地址发一个 POST。
- 成功了,就用 Streamable HTTP。
- 如果这个 POST 失败,状态码是
400、404或405,而且响应体不是一个它认得的新版 JSON-RPC 错误,它就对同一个地址发 GET,等一个endpoint事件。2025-03-26 和 2025-06-18 的原文写的是任意 4xx;2025-11-25 明确为这三个状态码;2026-07-28 又加上了对响应体的判断。
由此有两点:
- 保存了你老
/sse地址的用户,他们的客户端会先对它发 POST。这个 POST 必须以 4xx 失败,比如405,而不是200、跳转或者一个 HTML 页面,这样客户端才会退回去发 GET。 - 在新端点上,错误必须带一个真正的 JSON-RPC 错误响应体。在 2026-07-28 下,一个空的
400或404看起来就像老服务器,同时支持两代协议的客户端就会转头去找initialize,或者去找 SSE 流。
如果你还有 2025 年那一代的 Streamable HTTP 客户端:
- 一个端点可以同时服务两代。带着新
_meta字段的请求按无状态方式处理;一个initialize请求则选中它所协商版本的老行为,范围限于这个会话。 - 没有
MCP-Protocol-Version头的请求,来自比 2025-06-18 更老的客户端。如果你要支持这些客户端,可以把这类请求当作2025-03-26处理;如果不支持,就必须以400拒绝。 - 如果你不再支持老的几代,规范也写明了只支持 2026-07-28 的服务器该怎么对待它们的流量:GET 和 DELETE 回
405;忽略任何Mcp-Session-Id,也绝不生成或回显;忽略Last-Event-ID;老客户端发来initialize时,在错误里写明你支持的版本。老客户端没法自己往新版本走,这条消息可能是它的用户能看到的唯一线索。
迁移容易出错的地方:GET
- 在 2025-03-26 到 2025-11-25 下,对 MCP 端点的 GET 要么打开一条 SSE 流,要么回
405。回一个200加 HTML 页面不算,回404也不算。 - 在 2026-07-28 下没有东西可以 GET。回
405,把变更通知挪到subscriptions/listen。 - 别让老的流出现在新路径上。如果对
/mcp的 GET 打开了一条带endpoint事件的老式流,一个因为任何原因退回老传输的客户端,之后的所有流量都会一直走老传输。 - 长连接的流会碰上代理的空闲超时。2026-07-28 的原文建议在长连接的流上定期发 SSE 注释行(以
:开头的行),并带上X-Accel-Buffering: no头,让 nginx 这类代理不要缓冲事件。如果你保留了老的 GET 流,它经过的也是同样这些代理。
迁移容易出错的地方:会话
- 如果你给 2025 那一代的客户端发
Mcp-Session-Id,负载均衡后面的每个实例都必须能找到这个会话。会话只存在某个进程的内存里,请求一落到别的实例上就变成「找不到」。 - 你已经结束的、或者已经不认识的会话,必须回
404。这个状态码就是告诉客户端重新发一次initialize的信号。回400或500会让它卡住。 - 在 2026-07-28 下,不要给新版请求生成会话,也不要要求会话。需要跨调用保存状态的工具,应该返回一个句柄,比如购物篮 ID,由模型作为参数传回来。每次调用都要按这个句柄核对调用方的授权。
- 在 2026-07-28 下,
tools/list这类列表结果不再随连接而变。没有会话,也就没有可以随之变化的东西。 - 请求头名称不区分大小写。2025-11-25 的原文写作
MCP-Session-Id,更早的写作Mcp-Session-Id;两种都要能匹配。
用 curl 验证迁移
先把两个端点设好:
NEW=https://your-server.example.com/mcp
OLD=https://your-server.example.com/sse
1. 新版请求第一次就能成功,而且没有会话头。
curl -sS -D - "$NEW" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"curl-test","version":"0.1"},
"io.modelcontextprotocol/clientCapabilities":{}}}}' \
| grep -iE '^HTTP/|^content-type|^mcp-session-id|supportedVersions'
应该看到 200,内容类型是 application/json 或 text/event-stream,supportedVersions 里有 2026-07-28,并且没有 mcp-session-id 这一行。
2. GET 和 DELETE 得到 405(如果你只支持 2026-07-28)。
curl -sS -o /dev/null -w 'GET %{http_code}\n' -H 'Accept: text/event-stream' "$NEW"
curl -sS -o /dev/null -w 'DELETE %{http_code}\n' -X DELETE "$NEW"
3. 不存在的方法得到 404,以及一个客户端认得的响应体(-32601)。
curl -sS -i "$NEW" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: no/such_method' \
-d '{"jsonrpc":"2.0","id":2,"method":"no/such_method","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}'
**4. 老客户端的 initialize 能得到有用的回答。**同时服务两代的服务器会返回 InitializeResult,可能还带一个 Mcp-Session-Id。只支持 2026-07-28 的服务器回 400,最好在里面写明它支持的版本。
curl -sS -i "$NEW" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"curl-test","version":"0.1"}}}'
**5. 如果保留了老传输,它的行为仍然像老传输。**老地址拒绝 POST,GET 打开一条流,第一个事件是 endpoint。
curl -sS -o /dev/null -w 'POST %{http_code}\n' "$OLD" -H 'Content-Type: application/json' -d '{}'
curl -sS -N --max-time 5 -H 'Accept: text/event-stream' "$OLD" | head -n 3
POST 应该得到一个 4xx;GET 应该先看到 event: endpoint,再看到一行带着 POST 地址的 data:。--max-time 会在五秒后关掉这条一直开着的流,所以 curl 最后报一条超时信息是正常的。
上线迁移的那次部署之后把这五步都跑一遍;之后每次改了代理或负载均衡,也再跑一遍,因为 GET 的处理和会话的路由也可能在那一层。
看看 agent 看到的是什么
迁移算不算完成,要看外部客户端能不能用,而不是你自己的客户端能不能用。在 mcpcheckup.com 粘贴你的地址,看看 agent 会看到什么。它会像 agent 一样从外部连接,每一项检查给出四种状态之一:已通过、未通过、观察到风险,或者未检查并写明原因。从不给一个总分。