1. MCP传输层拆解:stdio和Streamable HTTP到底差在哪
做MCP(Model Context Protocol)开发的朋友,大概率都遇到过这个问题:写完一个server,文档里写着支持stdio、支持Streamable HTTP,但真要你决定用哪个,或者要跟别人讲清楚流式消息从服务端发出来之后到底去了哪里,很多人会卡住。我最早也被绕晕过,后来把这两条传输链路分别跑通、抓包看过完整消息流之后,才算真正想明白。
先给结论:MCP的stdio和Streamable HTTP不是同一套东西的两个皮肤,它们的进程模型、消息通道、会话机制完全不同。stdio是“服务器跑在客户端进程里,用管道说话”,Streamable HTTP是“服务器是独立服务,客户端通过网络请求和SSE流对话”。这两条路决定了你项目的部署形态、鉴权方式、超时策略、代理兼容性,全都不一样。
选型前要搞清楚的根本问题是:你的MCP server是打算“本地随用随起”,还是一个“常驻的远程服务”?对MCP协议来说,这俩问题的答案直接指向传输层选型。而在传输层之上,不管走stdio还是HTTP,应用层都是同一套JSON-RPC 2.0消息,所以工具定义、调用方式、内容协商这些逻辑完全不需要重写,真正需要改的只有“消息怎么从A点送到B点”这一层。
搞懂这一层,你就能回答下面这类实际问题:为什么有些client只支持stdio?为什么远程MCP server要配SSE?为什么用HTTP模式时streamable这个单词会被反复强调?顺着传输层的区别一条条理清楚,很多困惑就自己解开了。
1.1 stdio传输的工作机理:子进程和管道组成的本地通道
stdio模式下,MCP client做一件事:把MCP server当作一个子进程拉起。client负责spawn这个进程,然后把server进程的标准输入和标准输出拿来做通信信道。client向server发请求,就是把一条条JSON-RPC消息写到server的stdin;server回消息或主动推消息,就是往自己的stdout写。整个过程中,server进程不需要监听任何端口,也不需要知道网络相关的东西,它只需要保证“从stdin读JSON-RPC、往stdout写JSON-RPC”就够了。
这套模型有几个隐蔽但关键的特性。
第一个特性是进程生命周期绑定。stdio server随client启动而启动,随client退出而退出。client挂了,子进程就失去通信对端。所以stdio模式下根本没有“独立服务”这一说,server是client的附属品。这也意味着stdio server天然适合做本地工具链,比如你在编辑器里配一个MCP server做代码分析,它就是由编辑器进程拉起,用户什么都不用额外启动。
第二个特性是双向通信其实存在,但常被忽略。很多介绍只讲“client发请求,server给响应”,好像stdio只能做请求-响应。实际上MCP服务端是可以主动推送消息的(比如服务端主动发送日志、资源更新通知),stdio模式下server主动推消息就是往stdout写,client从stdout读。没毛病,这就是全双工。问题在于,stdio没有“把两条消息区分给不同接收者”的概念,大家就在同一条管道里按顺序收发,靠JSON-RPC的消息ID做配对。
第三个特性是stdio只适合“客户端本地发起”。如果MCP server是一台远程机器上的服务,client和server不在同一台机,stdio完全套不上。你想通过ssh到远程机器上执行一个MCP server进程,还是得把远程输出拉回本地,这本质上就是又套了一层网络,不如直接用HTTP方案来得干净。
我在实操中见过一个很典型的事故:某同学把MCP server部署在一台服务器上,然后在本地client里配置command,尝试用ssh远程执行server命令,期望client能直接跟远程server通信。折腾一天没通。原因是stdio管道是进程级的,client的spawn只能拉起本机进程,跨机器的stdin/stdout管道根本不存在。这个场景,正确的做法就是在服务器上跑一个HTTP模式的MCP server,让client通过网络去访问。
1.2 Streamable HTTP的架构形态:独立服务、HTTP请求与SSE响应流
Streamable HTTP模式下,MCP server不是子进程,而是一个常驻的HTTP服务。client通过网络与server交互,主要做两件事:第一,送HTTP POST请求发JSON-RPC消息;第二,建立响应流来接收server返回的消息和主动推送。
Streamable HTTP这套规范取代了早期的HTTP+SSE分离式方案。老版本要配两个端点(一个是SSE接入端点,一个是消息发送端点),用起来麻烦。现在的Streamable HTTP把逻辑泛化成一个概念:client通过POST发送一个请求,响应可能是一个标准的HTTP JSON响应体,也可能是一条流式响应,在这个流里持续输出多个消息和事件。也就是说,“服务器返回数据”这件事,被扩展成了“服务器可以持续往响应流里吐数据”,这也是Streamable这个词的来源。
要注意:协议名里有HTTP,但底层用了一个常被误解的技术——SSE(Server-Sent Events)。SSE是HTTP协议上的服务端单向推送机制,特点是一次HTTP连接建立后,服务端可以不断往同一个连接里写入以特定格式分隔的数据块。很多人问“MCP的SSE和WebSocket有什么区别”,这里一句话能解释:SSE是单向的,服务端往客户端推;client想给server发消息,还是得走普通的POST请求。WebSocket是双向的。MCP选SSE而不是WebSocket,主要是为了简化兼容性、穿透代理更省事,还能复用HTTP的鉴权链路。
所以,流式消息传到哪里这个问题,在Streamable HTTP模式下答案就清楚了:服务端把流式消息写进与客户端建立的HTTP响应连接里,这个连接一直保持打开,数据以SSE事件格式不断被推送过来,直到服务端结束这个流。你可以简单地把它理解成“HTTP请求发出去之后,连接不立刻关闭,而是变成一个持续出数据的通道”。
这样的通道会把MCP server变成一个可以被无数client接入的独立服务,远程部署、多client共享、按流量负载均衡这些都成为可能,代价就是网络层的东西全都要你操心:连接超时、代理缓冲、鉴权、跨域、TLS,每一项都不再是透明的。
2. 流式消息的终点:两条链路上的数据走向与消息边界
标题里那个问题“流式消息传到哪里”,其实有两层含义。第一层是物理层:数据包从server出来,最后到达哪个进程、哪个端口、哪个连接。第二层是逻辑层:一大段流式数据到client之后,client怎么从一段连续文本里正确切出每一条独立的消息。两层都搞懂,才算真的能把MCP传输层跑明白。
2.1 stdio模式下流式消息的走向:一条管道,串行收发的次序规矩
stdio模式下,server“推送”消息就是在自己的stdout上做事。让我们把这条链路展开:server内部生成消息(比如一条通知事件)-> 序列化成JSON字符串 -> 写入stdout对应的文件描述符 -> client持有一根读管道,pipe里数据以字节流形式到达 -> client侧解析JSON,还原成结构化消息。
这条链路里最容易被低估的是顺序。管道是字节流,没有消息边界,client必须自己从连续的字节里切出完整的JSON对象。MCP规范采用的还是JSON-RPC那一套:消息之间用换行符分隔,每一条消息都是单行JSON,也就是所谓的newline-delimited JSON。server写stdout时必须保证一条消息只占一行,不能在里面塞任何额外的换行或日志输出。
这是stdio模式一个特别容易踩的坑:程序员习惯写print调试。在MCP server里随便print一行日志,这行日志就直接进了stdout管道,被client当成MCP消息解析,直接导致JSON解析失败、消息错位、client报错。你想调试server输出,只能走stderr,stdout这条信道是留给协议消息的专用通道。打个比方:stdout是高速公路的行车道,stderr是应急车道,你把车停在行车道上下来拍照,后面全堵了。
还有一个时序问题:stdio是一个串行管道,server往stdout写消息是有顺序的,如果server同时要处理多个请求或推多个事件,必须自己做好消息排队的逻辑,不能两个线程同时往同一个fd上写。实践中我建议在server内部对stdout的输出加一个写锁或队列,保证一条消息完整写完再写下一条。否则两条消息的字节交错在一起,client根本没法切分。
2.2 Streamable HTTP模式下流式消息的走向:SSE连接里的流水线
Streamable HTTP模式下,流式消息的路径是这样的:client先发一个POST请求(这个请求本身可能是发起会话、执行工具调用等),HTTP响应头的Content-Type被设置为text/event-stream,表示这是一个SSE流。之后server就可以在这个响应流里按SSE格式分块推送消息:先是event:开头的类型标记行,接着是data:开头的数据行,然后一个空行表示一条事件结束。
client侧有对应的事件解析逻辑:读HTTP响应体,按SSE格式解析事件,从data字段里还原出JSON-RPC消息对象。这个过程和stdio切JSON行的思路完全不同,SSE自带边界标记(空行就是边界),所以理论上不用数JSON,直接按事件块切就行。
这里就出现一个很多人误解的点:“流式消息”是不是只指AI对话令牌那个流?其实MCP里的流式HTTP分为两个层次。一个是HTTP传输层上的“流式响应”:服务端把一大批消息不断推给client,比如服务端要推送日志列表、批量通知,都可以放一个流里慢慢推。另一个是应用层内容上的“流式生成”:比如一个工具调用返回了一段AI生成的文本,这段文本可能通过多个增量事件传回。两者是嵌套关系:应用层的流式内容,承载在传输层的SSE流里。
所以如果问“流式消息传到哪里”,完整回答是:先传进TCP连接对应的HTTP响应流,再经由一个SSE解析器还原成事件,最终被上层JSON-RPC层消化成消息。你需要在client代码里看到两类代码:一类负责发起HTTP连接,另一类负责解析SSE事件并交给MCP框架。很多MCP SDK把这些细节都封装好了,但如果封装出了问题或者你需要自己写client,就必须知道底层数据的流动方式。
2.3 消息边界和分帧方案:为什么两边都要“切消息”
不管stdio还是HTTP,MCP的消息都是一个结构完整的JSON-RPC对象,而传输层给的是连续字节流。从字节流里正确切分出消息,是client接收端必须解决的问题。
在stdio里,边界靠换行符。协议要求每条JSON消息序列化为一行,末尾有\n。client按行读取,每读一行就解析一次。这要求server输出的JSON不能带多余换行,也不能有未转义的换行出现在字符串里。JSON规范本身允许字符串里包含转义的\n,只要序列化的时候做了转义(变成\\n),实际输出的字符就不会包含真正的换行符。所以这块对正常使用JSON库的开发者来说基本无感,但对那些手工拼JSON字符串的人来说就是个提醒:不要手工拼,不要随便格式化。
在Streamable HTTP里,边界靠SSE事件格式。每条SSE事件由若干字段行(event:、data:等)和一个空行构成。client解析器一行一行读,碰到空行就认为一个事件结束。比stdio更宽松的是,SSE的data字段里可以包含换行,多条data行会按规范拼接成一个data值,所以理论上你可以把一个很长很长的JSON拆成多行data。但MCP官方实践建议依旧保持每个JSON消息对应一个SSE事件,data字段内容就是一个完整的JSON字符串。
这里有个细节值得留意:HTTP层面的流式响应有时会被中间的代理或网关缓存,导致client不是实时收到server推送的分块,而是一下子拿到一整段缓冲内容。这对消息解析本身不影响(解析器照样一次解析出多条),但对“流式”的体感影响很大——你本来期望看到逐条消息蹦出来,结果全卡在一起到达。排查这种问题,重点要检查链路里有没有代理开启了响应缓冲,后面排查部分我会细讲。
3. 选型标准:四个维度帮你判断该用stdio还是Streamable HTTP
回到这个标题的核心字眼:“怎么选”。这个选择不能凭感觉,要看四个维度:部署形态、连接方式、通信需求、运维复杂度。我把每个维度都展开讲清楚,并且给一个最终的决策清单。
3.1 部署形态:进程内随起随用 vs 常驻网络服务
第一个问题先问自己:你的MCP server打算跑在哪里?
如果server是给单个client在本地用的,比如你在编辑器里配置一个读取本地数据库schema的MCP server,那stdio是天然首选。用户不需要额外启动服务,client一启动,server进程自动拉起来,用完随client退出。整个过程对用户是隐藏的,体验最干净。
如果server要部署在远程机器上,或者要同时给多个client提供服务,stdio就完全不行了。理由我在前面已经说过:stdio的进程模型决定了它绑定于启动它的那个client。多个client要共享同一个server,你不可能让每个client各自拉一个server子进程然后各自连同一个数据库,这样资源会爆炸。正确做法是把server做成一个常驻HTTP服务,跑在服务器上,多个client通过网络来访问同一个实例。
还有一种中间情况:server和client在同一台机器上,但server不想随client启动,而是想常驻。这种情况也可以选HTTP模式,因为HTTP模式不要求client持有server的进程句柄,client只需访问一个本地端口即可。
3.2 连接方式与双向通信:单管道 vs 请求连接加响应连接
MCP要用到双向通信:client既要发请求给server,server又要能主动给client推消息。两种协议对双向通信的支撑方式很不一样。
stdio模式下,双向靠的是同一条管道的两个方向:客户端写stdin就是发消息,server写stdout就是推消息。这没问题,但有一个限制:server必须被client拉起,且server和client必须共享同一台机器的进程环境。如果server和client分处两地,这条路就断了。
Streamable HTTP模式下,双向通信的实现方式是多连接的:client通过POST请求发消息给server,server通过SSE响应流推消息给client。问题又来了:SSE那个连接是client发起的HTTP请求得到的响应流,server不能凭空反过来给client另起一个连接,server只能在那条已经存在的响应连接上持续写入。所以Streamable HTTP约定了一个机制:client应该保持至少一个SSE请求处于打开状态,让server有通道可以推送消息。如果你断开这个流,server就没有任何办法主动联系client了。
实际操作中,很多MCP client会在合适的时机(通常是初始化握手之后)启动一个专门的请求来获取SSE流,并维持这个请求不关闭。server要推送的任何东西都往这个流里写。如果这个流断了,client需要重新发起请求来重建接收通道。这个“保持接收通道打开”的细节,在选型和架构设计时就要想清楚。
3.3 网络边界、鉴权与安全:从零信任到层层把关
对MCP server来说,stdio模式天然拥有一道“信任边界”:能启动server进程的client,大概率已经在系统层面拥有了同样权限。stdio server不需要额外考虑鉴权,因为它只服务于本地启动它的那一个client,通信不经过网络协议栈,也不存在被别的机器嗅探的问题。
HTTP模式就完全不同了。server一旦监听一个TCP端口,就等于向所有能访问这个端口的网络实体开放。你必须自己解决鉴权问题:token怎么校验?请求要不要签名?SSE连接创建时怎么验证client身份?TLS要不要开?CORS怎么配?在真实生产环境里,这些全都要认真设计。
我见过一个把MCP server放到公网服务器上、没加任何鉴权还监听0.0.0.0的案例,结果没几天就被人扫描到,被拿来发了一堆垃圾请求。MCP协议本身没有内置鉴权,安全全靠HTTP层叠加。这也是很多企业项目宁可把server放在内网,或者只监听localhost也不暴露公网的原因。
另外,HTTP模式下你的server暴露的攻击面比stdio大得多。不仅要处理HTTP请求头、请求体、超大payload、慢速连接,还要考虑SSE长连接可能被滥用占满连接数。如果你是服务端开发,一定要加连接数限制和请求体大小限制,这些在stdio模式下根本不存在。
3.4 代理与运维:站在网络链路的角度看长连接
选HTTP模式,你就正式踏入网络世界,需要面对代理、网关、负载均衡这些组件的“友好度”。
SSE长连接与很多传统代理配置存在冲突。传统HTTP代理通常为短请求设计,默认可能会缓冲整个响应(buffer full response)然后一次性转发给client。如果代理缓冲了SSE流,client就收不到实时推送,而是等server把整个流结束之后一起拿到,流式体验消失。解决方案是配置代理关闭缓冲(如Nginx的proxy_buffering off)并调大proxy_read_timeout,让长连接保持不被中断。
此外还有连接超时问题。SSE连接经常要挂几小时甚至更久,中间任意一个网关的idle超时都可能切断连接。client需要实现自动重连机制,断开后重新发请求建流。stdio就没有这个问题——管道是进程级的,只要进程活着,没有“超时断开”一说。
运维层面也要想清楚:stdio类似命令行工具,调试方便,直接在终端跑起来看输出即可;HTTP server则要配日志、监控、健康检查、负载均衡。如果你的server本来就需要部署成微服务,那HTTP模式完全符合你的运维习惯;如果只是一个小工具,搞一套HTTP服务反而是过度设计。
我已经在多个项目里做过同样的判断,总结成一句话:本地工具走stdio,远程服务走HTTP;单体client私有用stdio,多client共享或跨网络用HTTP。若拿不准,就看“有没有第二个人或第二个进程需要访问这个server”——有,就往HTTP方向走;没有,stdio最简单。
4. 实操过程:从零搭一个同时支持两种传输的MCP server
说了这么多原理,很多人更关心怎么落地。我以Python生态为例,因为MCP的Python SDK最成熟,覆盖面也最广。这里的思路同样适配TypeScript/Java等语言,SDK API大同小异。
4.1 初始化server实例并注册工具
第一步,创建一个MCP server实例,并定义一个简单的工具。我用的是FastMCP这个高阶封装,它能帮你把stdio和HTTP两种模式都封装好,而且切换传输方式只需要改一行启动代码。
from mcp.server.fastmcp import FastMCP import time mcp = FastMCP("demo-server") @mcp.tool() def get_current_timestamp() -> str: """返回当前时间戳""" return str(int(time.time())) @mcp.tool() def echo_with_stream(text: str, repeat: int) -> str: """模拟一个会分段返回结果的工具""" chunks = [] for i in range(repeat): chunks.append(f"{i}:{text}") time.sleep(0.1) return "\n".join(chunks) if __name__ == "__main__": mcp.run()这里重点要说的是:FastMCP在run()方法里会读取环境变量或参数来决定用stdio还是HTTP模式。默认不带参数时,它运行在stdio模式,启动后直接跟父进程的管道通信。如果你想跑HTTP模式,可以显式指定传输方式。
4.2 切换传输方式的完整启动方案
Python的FastMCP启动HTTP模式,通常会在本地起一个Web服务。实际项目中我习惯写成这样:
if __name__ == "__main__": # stdio模式 # mcp.run() # 默认就是stdio # Streamable HTTP模式,监听在指定端口 import uvicorn from mcp.server.fastmcp import FastMCP mcp.run(transport="http", port=8000)不过要注意,不同版本的SDK对HTTP模式的启动方式有差异。有的版本通过增加一个挂载到ASGI应用的适配器来运行(比如FastMCP的http_app属性),然后交给uvicorn托管,这种方式更适合要跟其他服务共存的情况:
from mcp.server.fastmcp import FastMCP import uvicorn mcp = FastMCP("demo-server") @mcp.tool() def get_current_timestamp() -> str: return str(int(time.time())) if __name__ == "__main__": app = mcp.http_app() # 获取ASGI应用 uvicorn.run(app, host="127.0.0.1", port=8000)这套写法背后做的事情是:SDK把MCP的JSON-RPC处理逻辑封装成一个ASGI应用,同时实现了一个SSE端点和消息发送端点。client往消息端点POST消息,server通过SSE端点的流式响应推回数据。你不需要手写SSE格式,SDK在底层帮你处理。但这不代表你不需要知道SSE的存在——后面排查问题的时候,看不懂SSE就无从下手。
4.3 配置client连接两种server
client侧,我用官方Python SDK的mcp.client模块做一个演示。连接stdio server:
from mcp.client.stdio import stdio_client, StdioServerParameters server_params = StdioServerParameters( command="python", args=["path/to/server.py"], ) async with stdio_client(server_params) as (read, write): ...连接HTTP模式的server,用的是streamablehttp_client:
from mcp.client.streamable_http import streamablehttp_client async with streamablehttp_client("http://127.0.0.1:8000/mcp") as (read, write): ...这两段代码跑通之后,你就有了一个透明感知传输方式的client。你发给server的请求都走JSON-RPC,返回结果通过read通道给到上层。stdio模式下read读到的是子进程stdout的行,HTTP模式下read读到的是SSE流里解析出来的消息。好就好在这层差异被SDK统一掉了,业务代码完全不用感知。
4.4 实时观察流式消息去了哪里
想直观看到流式消息的走向,有两个办法。
第一个办法是在stdio模式下,直接在终端手动启动server,然后自己给它发一行JSON。注意stdio server不是给人类交互用的,但它本质上就是“管道读JSON往管道写JSON”,所以你可以模拟client向它发消息:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"demo","version":"1.0"}}}' | python server.py运行之后server会回传一堆JSON-RPC响应。虽然初始化流程需要严格的握手顺序,直接手动批量输入不全时server行为会异常,但你能亲眼看到server的stdout输出这些消息,这正是client真正读取的东西。
第二个办法是HTTP模式。用curl发一个POST请求,然后看响应头、响应体:
curl -N -X POST "http://127.0.0.1:8000/mcp" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-demo","version":"1.0"}}}'注意-N参数:curl默认会缓冲整个响应,-N关闭缓冲,让数据一到就打印。这是观察SSE流的关键。如果server端处理过程中有多次写入,你会看到响应体会以多个事件块的形式逐步出现在终端。
我强烈建议你跑一下这个curl命令,因为很多server收到了正常的HTTP POST但没返回text/event-stream响应头,协议对端就认为这是普通HTTP响应而不是流,处理方式会完全不同。亲手看一次响应头,比读十篇文档都管用。
5. 常见问题与排查技巧实录
我在这条路上踩过的坑不少,挑几个高频问题整理成一个速查表,基本覆盖初学者到中级工程师会遇到的场景。
| 异常现象 | 可能原因 | 排查方向 |
|---|---|---|
| stdio模式下client连不上server | command或args配置错误;server启动即崩溃 | 直接在终端手动执行server命令,验证能否正常运行 |
| stdio下客户端收到“Invalid JSON” | server在stdout打印了日志或调试信息 | 把所有日志改走stderr;检查是否手工拼接JSON并带多余换行 |
| HTTP模式下curl能通但client不通 | 鉴权头缺失、path不对、协议版本协商失败 | 看client发的请求头和curl对比;确认HTTP端点路径完全一致 |
| SSE流收到一段后卡住 | 代理缓冲、网络包被缓冲、server端逻辑阻塞 | 关掉代理缓冲;加大超时;在server端加日志看是否还在写 |
| SSE连接空闲后被断开 | 中间代理/网关的idle timeout | 开启应用层心跳或调大代理read timeout |
| HTTP模式启动后端口被占用 | 端口冲突或前一个进程未退出 | 换端口或检查进程列表 |
| 多client连接同一HTTP server时一个断开全部断开 | server端做了全局单一会话绑定 | 检查HTTP server会话管理,确认是否支持多会话并行 |
这里挑几个最隐蔽的展开说说。
5.1 stdio模式下的stderr陷阱
stdio模式下意外输出是最大的坑。很多MCP server框架在初始化时会打印banner或日志,这些如果走stdout,client必然炸。
我曾经在server里引入了一个第三方库,这个库在import时向stdout打了一行版本信息,结果client侧断断续续报“received invalid JSON”。排查了半小时才发现是import产生的副作用。解决方案就是三个字:全走stderr。如果你的框架本身有日志系统,确认所有日志handler都绑定到stderr;如果有些库你管不了,可以考虑在启动早期重定向stdout里的非协议输出,或者在测试阶段强制用只含必需输出的最小依赖。
5.2 HTTP模式下代理缓冲对SSE的破坏
生产环境部署Streamable HTTP server时,前面大概率有Nginx或类似的反向代理。默认配置下,Nginx会缓冲后端响应,直到整个响应结束才发给client。这对短请求没问题,但对SSE是致命的——你的client会感觉server“完全没响应”,其实数据全堵在Nginx缓冲区里。
解决办法是给对应location关闭缓冲:
location /mcp { proxy_pass http://127.0.0.1:8000; proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; proxy_read_timeout 3600s; }几个参数的作用都说一下:proxy_buffering off让数据尽快转发;proxy_http_version 1.1保证chunked传输能正常工作;proxy_read_timeout 3600s拉长空闲超时,防止连接被断。如果你前面还有CDN,CDN层的缓冲同理,需要找对应开关。
5.3 SSE连接被断开后的重连策略
流式HTTP模式下,SSE连接是“易碎品”,网络抖动、代理重启、服务器发布都可能导致连接断开。MCP client必须有重连机制。
我在实现client时做了这样一件事:主循环里读取SSE流的代码要捕获连接断开异常,然后在指数退避后重新发起新的HTTP请求,重建SSE连接。重连期间,如果client要发请求给server,仍然可以正常POST,不依赖于SSE连接;但要接收server主动推送的消息,就必须等新SSE连接建立好。如果你的业务强依赖server推送(比如工具执行状态变化),重连的空窗期要设计好数据补偿逻辑。
很多MCP SDK自带重连,但默认参数对生产环境不一定合适,建议看清楚SDK的断开检测和重连策略再配置。
5.4 会话状态与长连接的协调
Streamable HTTP里的会话状态是个容易被忽略的点。MCP协议的会话通常通过请求头或会话ID关联;server在会话里保存client的上下文。SSE连接断开后,会话不一定失效,有些server会保留一段时间,client重连时带上同样的会话ID可以继续用。
但如果你用的是慢速、多Worker的部署,会话信息如果存在内存里会出问题。某次server发布重启后,内存会话全丢,client重连时带上旧会话ID,server找不到会话直接报错。这类问题没有标准答案,只能在设计阶段就决定:会话状态存内存(简单但重启即丢)、数据库(持久但慢)、还是完全无状态(每次请求都带上完整上下文,简单但payload大)。对大多数中小项目,我会建议把状态尽量放在client侧,server保持无状态,这样重连、扩容都会简单很多。
5.5 关于“stdio还是HTTP”的最后一个提醒
最后吐槽一个选型里常见的思维误区:有些人觉得“既然HTTP模式更现代更通用,那所有情况都用HTTP不就好了”。这忽略了stdio在本地场景下的明显优势:零端口、零鉴权配置、零网络依赖,你不需要处理CORS、不需要处理超时、不需要关心代理。两个模式是互补关系,不是替代关系。
根据个人经验,最稳的组合是:本地开发、调试阶段用stdio,跑通逻辑后再给server加一层HTTP包装,部署到服务器;本地编辑器和CLI工具继续保留stdio连接,远端API或Web应用走Streamable HTTP。一套MCP server代码同时暴露两种通道,端点是同一个,逻辑完全复用,代价只是启动时选择不同的transport。
我在实际写MCP工具时已经习惯性地把传输层抽象出来:业务工具函数完全不知道外面是stdio还是HTTP,启动脚本里留一个环境变量控制transport类型。这样本地调试、远程部署、给别人分享测试都只需要改一个变量。如果你还没这样组织代码,下一次重构的时候可以考虑把“传输方式”和“业务逻辑”解耦,后面真的会省很多事。