MCP传输协议对比:从HTTP+SSE到Streamable HTTP的演进与迁移指南 光子AI这个项目最初接MCP远程服务时用的还是老一套HTTPSSE传输一个 /sse 长连接负责收服务器的推送另一个 /messages 地址负责发请求。后来我们把连接层切到MCP新规范里的 streamable_http一开始很不适应明明都是“走HTTP”为什么协议变了之后客户端和服务器的代码结构全要重写这篇就是把两者的区别、原理和我在源码层面看到的关键差异拆开讲清楚。无论你是想维护老MCP服务还是准备新接一个MCP服务器看完应该能明白该选哪条路、怎么迁。1. 先搞清楚两个名字在说什么1.1 SSE单向推送的老牌“广播”SSE全称Server-Sent Events属于HTML5规范。它不是MCP发明的而是浏览器早就支持的一种HTTP服务器推送技术。它的核心思路很简单客户端发起一个HTTP GET请求服务器不结束响应而是把Content-Type设为text/event-stream之后一直往这条连接里写特定格式的文本块。每个文本块可以包含event、data、id、retry这些字段字段之间以空行分隔。举个例子一条SSE消息长这样Content-Type: text/event-stream event: message id: 42 data: {jsonrpc:2.0,method:notifications/updated,params:{}}字段的意思说起来很直白event给这个事件起个名字客户端可以根据名字区分不同类型data是真正要传的内容可以多行拼接id是事件序号方便断线重连时把“上次收到哪条”告诉服务器retry告诉客户端如果断线了隔多少毫秒再重连。用生活里的场景类比SSE就是一个广播电台听众只需要打开收音机电台就能持续不断把声音推过来。但问题是听众没法用收音机直接给电台回话想回话得换一部电话。这个“收音机”和“电话”的区分恰恰是理解MCP新旧传输差异的关键。1.2 Streamable HTTPMCP 对传输层的重新定义Streamable HTTP并不是一种新的HTTP方法也不是第三个独立协议它是MCP规范里对“传输层”的一种具体定义。它的核心约束是客户端和服务器通过一个HTTP端点交换JSON-RPC消息响应可以是普通的单次JSON也可以是用SSE格式包装的流式响应而会话状态通过HTTP头里的Mcp-Session-Id来维护。也就是说streamable_http仍然使用了SSE这种数据格式但SSE在新协议里的地位变了。在老传输里SSE是一个独立、长期挂起的连接服务器可以随时往里面推东西在新协议里SSE变成了“某个请求的响应形态”——我POST出去一个调用服务器可以选择一口气把结果返回也可以用流式方式一段一段往回传传完用[DONE]收尾。这个变化乍看只是实现细节实际上牵动了很多东西连接的建立时机、断线重连、负载均衡、鉴权方式、服务器状态保持全部跟着变了。下面我按架构演进、协议原理、源码细节三个层面展开说。2. 架构演进从两个端点到一个端点2.1 旧 HTTPSSE 为什么要拆成 /sse 和 /messages老版本MCP远程传输的约定是这样的客户端先GET一个/sse地址服务器返回一个不关闭的SSE长连接。连接建立后服务器先发一个endpoint事件告诉客户端“你之后要给我发消息请POST到我给你的这个URL。”这个URL通常是另一个路径例如/messages。客户端收到endpoint事件后把JSON-RPC消息通过POST发到messages_url服务器收到后先返回202表示“我收到了”真正的处理结果稍后再通过那条SSE连接推回给客户端。为什么会设计成两个端点原因在于SSE本身是单向的。HTTP的请求-响应模型天然支持双向通信但SSE流一旦建立就成了服务器到客户端的单向通道。MCP的消息模型里包含请求、响应、通知三类JSON-RPC消息客户端要发请求服务器要回响应两边都要有“说话”的通道。于是老规范被拆成两条路SSE管服务器到客户端POST管客户端到服务器再靠消息里的id字段把请求和响应配对。这种设计的代价很快就暴露出来了。我在维护光子AI的MCP服务时遇到过几个非常典型的问题第一SSE长连接太脆弱。生产环境几乎必然经过Nginx、Kubernetes这类基础设施这些组件默认会做空闲超时连接一会儿不传数据就被掐掉。SSE断线后EventSource会自动重连但重连只是建立了新连接老连接上挂着的服务端上下文很可能已经丢了。第二状态难追踪。老协议没有标准会话概念客户端断线重连后服务器上一个正在执行的长任务例如一次耗时几十秒的工具调用状态怎么续没有统一规定只能自己实现一套连接ID和上下文恢复机制。第三鉴权受限。浏览器端的EventSource API只支持GET而且不能自定义HTTP头。想带token只能塞到URL query里既不安全也不好写。新协议用普通fetch或requests发起POST想放什么header都可以。2.2 Streamable HTTP 的单端点模型新协议把问题收敛得很干净所有JSON-RPC消息都发给同一个端点通常叫/mcp。服务器有两种响应选择直接返回application/json的普通JSON或者返回text/event-stream的SSE流。典型的交互变成了这样请求-响应客户端POST/mcp服务器直接返回JSON。适合tools/list、resources/list这类一次拿全量的调用。流式响应客户端POST/mcp调用一个耗时工具服务器用SSE流一段一段返回进度或token最后用[DONE]标记结束。适合AI输出、长任务、日志流。通知客户端POST一个没有id的JSON-RPC通知服务器返回204表示收到。比如初始化完成通知。服务器主动推送客户端如果需要可以GET/mcp建立一条SSE流服务器在这条流上发通知。例如资源变更、进度更新。这里面最关键的是第三条和第四条从“必须存在”变成了“可选”。老SSE传输下服务器推送是默认的、绕不开的新协议下如果只是普通请求响应完全可以不开任何流式通道。连接的生命周期由会话ID管理不再依赖一条永远挂着的TCP连接。2.3 协议版本前进后“前后区别”改变了哪些默认行为MCP的协议版本从早期版本迭代到2025-03-26这一版时streamable_http正式成为标准推荐传输方式。伴随这个变化很多默认行为发生了改变。老客户端连接MCP服务器的默认地址是/sse新客户端是/mcp。一个MCP服务器如果只实现了新规范那么你去GET/sse返回的是404而不是一条事件流。反过来如果服务器还是老实现你POST/mcp也会得到404。判断一个服务是“新协议还是老协议”没有任何花活直接看端点就是最有效的手段。另外SDK层面的默认实现也变了。老SDK里开放的SSE传输类被标记为废弃新代码默认注册的是Streamable HTTP传输类。这意味着如果你照着老教程写代码很可能连依赖都装不对。我用一张表把核心差异列出来方便后面看源码时对照维度老HTTPSSEStreamable HTTP端点数量至少两个/sse、/messages一个/mcp客户端发消息POST到messages端点POST到/mcp服务器推消息必须依赖长期SSE连接可用GET建流也可用流式响应会话状态无标准机制Mcp-Session-Id 头断线恢复重连后上下文难保持靠会话ID恢复响应风格典型异步先202后推送可同步JSON响应也可流式鉴权便利性EventSource不便带header普通请求可带任意header3. 原理剖析连接、报文与生命周期3.1 旧 SSE 的报文结构和握手流程理解SSE报文结构是看懂老MCP传输的第一步。SSE的语法非常轻一个事件块由若干行组成字段名和值之间用冒号加空格分隔事件块之间用空行分隔。客户端读取时遇到空行就认为一个事件结束了。data字段如果有多行解析时会把多行内容用换行符拼起来作为一整段数据所以服务端在构造事件时要注意不要把 JSON 拆到错误的行上。老MCP握手流程是这样的客户端GET/sse。服务器返回200 OKContent-Type是text/event-stream连接保持打开。服务器立即发一个event: endpoint事件data字段是后续消息投递地址。客户端保存这个地址。客户端向该地址POSTinitialize请求。服务器返回202 Accepted表示请求已收入。过一会儿服务器通过SSE连接发一条event: message事件data字段里是真正的JSON-RPC响应。客户端根据响应里的id和之前POST出去的请求对上号。这个流程中消息配对是客户端自己实现的。如果同一时间发出了多个请求就得维护一张类似pending的表发出请求时把id和回调函数记下来SSE里来了对应id的响应再从表里取出回调执行。我在代码里看到很多人的实现都栽在“响应来的时候请求的上下文已经丢了”这个坑上。3.2 Streamable HTTP 的请求、响应与会话ID新协议里报文的形态清楚很多。先看初始化请求。客户端POST到/mcp请求头里的Accept显式声明支持JSON和SSEPOST /mcp HTTP/1.1 Host: example.com Content-Type: application/json Accept: application/json, text/event-stream {jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2025-03-26,capabilities:{},clientInfo:{name:demo-client,version:0.1.0}}}服务器的响应返回协商好的协议版本、能力声明和服务器信息同时关键的是在响应头里带上会话IDHTTP/1.1 200 OK Content-Type: application/json Mcp-Session-Id: 550e8400-e29b-41d4-a716-446655440000 {jsonrpc:2.0,id:1,result:{protocolVersion:2025-03-26,capabilities:{tools:{listChanged:true}},serverInfo:{name:demo-server,version:1.0}}}从这一步开始客户端要把Mcp-Session-Id的值保存下来后续每个请求都放进HTTP头里。如果服务器实现了无状态模式可以不返回这个头但绝大多数生产实现都依赖它维持会话。再看流式响应。客户端调用一个耗时工具时服务器可以用SSE流来返回。比如HTTP/1.1 200 OK Content-Type: text/event-stream Mcp-Session-Id: 550e8400-e29b-41d4-a716-446655440000 data: {jsonrpc:2.0,method:notifications/progress,params:{progressToken:p1,progress:1,total:3}} data: {jsonrpc:2.0,id:2,result:{content:[{type:text,text:任务完成}]}} data: [DONE]第一条data是进度通知第二条是真正的JSON-RPC响应最后[DONE]表示流结束。客户端解析时要区分带id的是请求-响应不带id的是通知。这正是我前面提到的“SSE降级为报文级能力”的含义——这里没有独立长连接SSE只是这个POST响应的一种传输编码方式。3.3 生命周期从 initialize 到会话关闭新协议把MCP连接的生命周期讲得比老协议明确得多。一个完整周期是客户端发送initialize请求带协议版本和客户端能力声明。服务器返回协议版本协商结果和会话ID。客户端发送notifications/initialized通知告诉服务器“我这边已经准备好处理后续消息了”。双方开始正常交换消息工具调用、资源读取、采样请求都在这个阶段发生。会话结束。客户端可以主动关闭连接服务器支持的话客户端也可以DELETE/mcp来明确结束会话。服务端可能设置空闲超时。超时后会话失效客户端再带旧ID发请求会收到错误响应。此时客户端需要重新走initialize流程。这中间最值得注意的改进是重连语义。老协议下SSE断了客户端重连回来后服务器无法区分这是一个新客户端还是同一个客户端所有服务端状态都成了悬案。新协议下只要客户端还在本地保存着Mcp-Session-Id重新发起连接时把这个ID带回来理论上就能恢复之前的会话上下文。这对长时间运行的工具调用尤其重要——我正在执行一个需要两三分钟的任务中间网络闪断了新协议还能继续找回去。4. 源码级对比从客户端实现看差异4.1 旧 SSE 客户端的实现骨架只看协议描述还不够我把两版客户端的典型实现骨架写出来对比一下。先看老SSE客户端。它的核心是一个后台SSE事件循环外加一个POST发送函数。注意我用的是“典型实现思路”不是某个SDK的完整源码但结构足以说明问题。import json import threading import requests try: from sseclient import SSEClient except ImportError: SSEClient None class LegacySSEClient: def __init__(self, sse_url): self.sse_url sse_url self.messages_url None self._pending {} self._next_id 0 def _sse_loop(self): for event in SSEClient(self.sse_url): if event.event endpoint: self.messages_url event.data continue if event.event message: message json.loads(event.data) req_id message.get(id) if req_id in self._pending: callback self._pending.pop(req_id) callback(message) def start(self): thread threading.Thread(targetself._sse_loop, daemonTrue) thread.start() while not self.messages_url: time.sleep(0.01) def send(self, method, params): req_id self._next_id self._next_id 1 payload { jsonrpc: 2.0, id: req_id, method: method, params: params, } requests.post(self.messages_url, jsonpayload)这段代码的问题非常明显send只是把消息投递出去结果不会同步返回而是不知道什么时候从SSE里冒出来。你必须单独维护一套回调配对逻辑而且SSE一旦断开重连所有pending状态都要自己想办法迁移。我在光子AI早期版本里就写过类似代码后来发现只要有一步网络抖动用户那边看到的现象就是“工具明明执行了前端半天没反应”。4.2 新 Streamable HTTP 客户端的实现骨架新协议客户端就清爽很多。核心思路是发起POST请求根据响应头里的Content-Type决定按JSON解析还是按SSE解析。如果你不需要长期保持SSE连接就不需要任何后台线程。import json import requests class StreamableHTTPClient: def __init__(self, endpoint): self.endpoint endpoint self.session_id None def initialize(self): payload { jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2025-03-26, capabilities: {}, clientInfo: {name: demo-client, version: 0.1.0}, }, } resp requests.post( self.endpoint, jsonpayload, headers{Accept: application/json, text/event-stream}, ) self.session_id resp.headers.get(Mcp-Session-Id) return self._parse_body(resp, 1) def send(self, method, params, req_id): payload { jsonrpc: 2.0, id: req_id, method: method, params: params, } headers {Accept: application/json, text/event-stream} if self.session_id: headers[Mcp-Session-Id] self.session_id resp requests.post( self.endpoint, jsonpayload, headersheaders, streamTrue, ) return self._parse_body(resp, req_id) def _parse_body(self, resp, req_id): content_type resp.headers.get(Content-Type, ) if text/event-stream not in content_type: return resp.json() result None for line in resp.iter_lines(decode_unicodeTrue): if not line or not line.startswith(data:): continue data line[5:].strip() if data [DONE]: break message json.loads(data) if message.get(id) req_id: result message return result和旧实现比最大的变化是“同步感”回来了一个请求发出去要么同步拿到JSON结果要么在循环里等流结束拿最终结果不再需要跨通道配对。streamTrue只代表响应体可能较大但你的业务逻辑可以保持经典的“调用-等待-返回”模型。4.3 代码层面的三个关键注意点自己动手写Streamable HTTP客户端时有几个地方特别容易出问题我一个个说。第一初始化阶段的版本协商不能写死。请求里带的protocolVersion是客户端期望的但服务器可能返回一个更低或更新的版本。代码里必须读响应里的result.protocolVersion后续所有请求都按那个版本发。否则就会出现“我把版本写成最新服务器老一点直接拒绝”的兼容性灾难。第二SSE流的解析不能只处理data:这一种写法。规范要求冒号后面有空格但有些服务器实现偷懒发的是data:{jsonrpc:...}。我建议解析时先用strip()去掉空白再判断前缀。另外多行data字段的情况在MCP里不常见但如果你写通用解析器最好把多行拼接逻辑也做了否则遇到多行data会被切开导致JSON解析失败。第三注意[DONE]这个结束标记。它不是一个合法的JSON所以解析SSE时不能无脑对每一条data都json.loads。代码里要先判断是不是[DONE]是的话直接结束。我在不同语言SDK上看到过不同处理方式有的是发data: [DONE]有的是发data:[DONE]做兼容时两种都要认得。5. 迁移与踩坑记录5.1 我的迁移步骤清单如果手头有一个老SSE传输的MCP服务想切到streamable_http我建议按下面这个顺序操作每一步都先验证再往下走。第一步确认SDK和依赖版本。直接把MCP相关SDK升级到支持新传输的版本能省掉很多自己造轮子的时间。升级后跑一下现有测试看看哪些API废弃了。第二步改路由。把服务端路由从/sse和/messages收敛成一个/mcp。如果你用的是某个Web框架通常就是加一个统一的POST handler再按HTTP Method区分POST处理JSON-RPC消息GET建立推送流。第三步实现会话管理。服务端收到initialize时生成一个UUID作为会话ID放进响应头Mcp-Session-Id同时把会话上下文存到内存或Redis。后续请求通过这个ID找回上下文。空闲超时可以设成几分钟到几十分钟不等。第四步改客户端。把后台SSE线程删掉改成“发起POST、按Content-Type解析”的模型。老代码里所有依赖messages_url的地方直接换成/mcp所有从SSE事件里取数据的逻辑改成从响应流里读。第五步报文验证。先用curl发一次initialize确认能拿到会话IDcurl -i -X POST http://localhost:8080/mcp \ -H Content-Type: application/json \ -H Accept: application/json, text/event-stream \ -d {jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2025-03-26,capabilities:{},clientInfo:{name:curl,version:1.0.0}}}看到响应头里有Mcp-Session-Id再把后续请求的header补上。这样一步步来基本不会大规模返工。5.2 常见问题速查表我在迁移和后续维护中遇到的高频问题整理在这张表里排查顺序也写在答案里了问题原因排查与解决初始化后拿不到sessionId服务器版本过老或初始化响应被网关吞掉header先看响应头确认网关没有剥离自定义header再确认SDK版本后续请求返回400 Missing session id客户端丢了会话ID或从初始化响应里没读出来在客户端保存响应头的值每次请求都带上SSE流读到一半断开反向代理空闲超时或开启了response buffering关掉proxy_buffering调大proxy_read_timeout浏览器跨域请求报错服务器CORS配置没放行Mcp-Session-Id在Access-Control-Allow-Headers里加上Mcp-Session-Id发送请求没有触发流式响应请求头Accept没写text/event-stream将Accept设为application/json, text/event-stream老客户端连新服务器连接/sse 404协议版本不兼容升级客户端到新transport或让服务器保留老端点兼容长任务在执行客户端进程重启后恢复不了会话ID没持久化或服务器重启丢失会话客户端持久化sessionId服务端把会话上下文放Redis等外部存储最容易被忽视的是网关层。本地测试一切正常一旦部署到Nginx后面SSE流就会被缓冲或超时掐断。我的配置经验是至少加这三行proxy_set_header Connection ; proxy_http_version 1.1; proxy_buffering off; proxy_read_timeout 3600s;第一行关掉连接头第二行强制HTTP/1.1第三行关缓冲最后把响应超时拉长。如果还是断再看看请求经过的其他代理层例如容器网络的连接超时。5.3 还没迁的人应该怎么选关于选型我的态度很明确新项目直接上streamable_http不需要纠结。原因不是旧SSE不能用了而是生态已经整体转向新协议。SDK默认实现、新写的教程、后续的功能增强都会围绕新协议展开。你现在用老协议等于把未来的维护成本提前背在自己身上。老项目如果暂时不想动短期也不是不能撑。只要SDK还支持旧SSE传输线上又没有频繁断连的问题可以继续跑。但如果你的MCP服务要接入浏览器端、要穿过复杂的网络网关、或者要支持长时间的工具调用那老SSE的短板会越来越明显不如尽早迁。还有一个容易被忽略的点如果你只做本地进程内集成压根不涉及远程传输那么这两个协议的区别对你没有实际影响。MCP同样支持stdio传输本地调用工具不需要关心SSE还是streamable_http。只有把MCP服务部署成远程HTTP服务时前面的所有讨论才真正生效。我在光子AI项目里最后体会是真正耗费时间的不是改协议代码而是把Nginx、超时、CORS、会话恢复这些周边环境调顺。代码结构上新transport明显更干净调试也直观——至少我可以在curl里看到全部往返。如果你还在老SSE版本上做开发趁早迁如果刚接触MCP直接学streamable_http不要走回头路。