
最近帮同事排查一个 MCP Server 的连接问题日志里反复出现stream disconnected before completion: idle timeout waiting for sse。服务端进程明明还活着客户端却认为流已经断了。追了几天才发现问题根本不在业务代码而在我们选的那套传输模式上。MCP Server 的传输层现在经常被人混着讲SSE、Streamable、Stateless 三个词满天飞真正把边界说清楚的人却不多。这篇文章我就按自己的理解把这三种传输形态掰开揉碎讲一遍包括原理、代码层面的表现、实测过程和踩坑记录。1. 先厘清概念Stateless 不是并列的第三种协议而是 Streamable HTTP 的一种运行模式1.1 传输层的演进主线stdio、HTTPSSE、Streamable HTTP先说一个可能让标题党失望的结论严格按 MCP 规范看传输方式并没有“三种”并列。MCP 是基于 JSON-RPC 2.0 的协议传输层一开始只定义了两种形态一种是stdio用于本地进程间通信比如本地 MCP Server 和 IDE、命令行工具之间的对话另一种是用于远程访问的 HTTP 界面早期版本采用“HTTP SSE”的组合被简称为 SSE transport。2025 年初规范更新后官方用 Streamable HTTP 取代了原来的 HTTPSSE 传输方案。这里的关键是SSE这个词本身指的是 Server-Sent Events一种基于 HTTP 的服务端推送技术并不是 MCP 独有的东西。而Streamable HTTP是 MCP 规范自己定义的最新 HTTP 传输标准它把请求响应和流式推送收拢到了一个接口上。至于Stateless其实是 Streamable HTTP 下的一种无状态调用方式不是独立于 Streamable HTTP 之外的第三种协议。所以完整的关系应该这样理解MCP Server 的传输层有stdio和HTTP 传输两大类HTTP 传输又经历了“旧版 SSE 模式 - 新版 Streamable HTTP 模式”的演进而 Streamable HTTP 可以按有没有会话分为“有状态”和“无状态Stateless”两种用法。标题里的“三种”实际上是 SSE、Streamable有状态、Stateless无状态这三种你经常会在不同项目里遇到的形态。1.2 为什么传输层的理解偏差会引发排障事故很多人在本地启动 MCP Server 时不会遇到什么传输层问题因为大部分本地教程默认走的是 stdio也就是配置一个mcpServersJSON指定 command 和 args客户端直接通过子进程的标准输入输出通信。这种方式没有网络、没有端口、没有跨域甚至没有超时简单可靠。问题往往出在“远程化”之后同一个 Server 要同时服务 Web 前端、移动端、多个云端 Agent这时候必须把它暴露成 HTTP 接口。一旦走 HTTPSSE、Streamable、Stateless 的选择就决定了两件很重要的事一是连接的生命周期谁来维护二是断线之后能不能安全重试。同事遇到的 idle timeout 报错本质就是选了一种“需要长连接但没有做好心跳”的传输方式空闲期间被代理层切断了流而客户端又没有重连策略。下面我把这三种形态逐个拆开讲。2. SSE 模式单向推送骨架下的双向 RPC以及四个躲不开的坑2.1 SSE 机制的原始画像浏览器时代的设计SSE 全称 Server-Sent Events是 HTML5 标准里一种让服务器向浏览器单向推送文本事件的机制。最简单的用法是前端建一个EventSource对象指向一个 URL服务器源源不断地返回text/event-stream格式的文本块浏览器自动解析并触发事件。SSE 的报文格式很轻量每个事件块由若干行组成event:指定事件名data:携带数据块之间用空行分隔。相比 WebSocketSSE 的优势是建立在普通 HTTP 之上没有单独的握手协议天然支持自动重连缺点是它是单向的浏览器侧只能接收不能通过同一个连接往服务端发数据。MCP 要在 HTTP 上做双向的 JSON-RPC 通信早期规范就做了一个“变形”客户端通过 POST 请求把 JSON-RPC 消息发给服务端另一个独立的 GET 请求建立一条 SSE 流服务端把响应数据和主动通知都推送到这条流里。于是一个业务请求被拆成了“请求走 POST、响应走 SSE”两条通道这对客户端封装来说非常别扭。2.2 早期 SSE 模式下服务端是怎么工作的我早期用 FastAPI 写过一个简易的 MCP SSE 服务端核心逻辑可以这么概括每个客户端建一条全局 SSE 流服务端为每个 JSON-RPC 请求分配一个等待队列业务处理完成之后往这个队列塞消息SSE 生成器不断从队列里取消息推给客户端。import json from queue import Queue # 全局事件流队列所有客户端共享一条通知流 notification_queue: Queue Queue() # 根据 JSON-RPC 请求 id找到对应的响应队列 pending: dict[str, Queue] {} def handle_jsonrpc(payload: dict): request_id payload.get(id) q Queue() pending[request_id] q # 走到这里真正的业务逻辑在另一个任务里异步执行 # 业务完成后执行 q.put(response_payload) return q async def sse_generator(): # 第一步告诉客户端真正用来发送 POST 请求的地址 yield event: endpoint\ndata: /messages\n\n while True: # 先看有没有针对某个请求的响应 for rid, q in list(pending.items()): if not q.empty(): payload q.get_nowait() yield fevent: message\ndata: {json.dumps(payload)}\n\n # 再看有没有服务端主动通知 if not notification_queue.empty(): item notification_queue.get_nowait() yield fevent: message\ndata: {json.dumps(item)}\n\n else: yield : keep-alive\n\n这段示意代码不算完整但能说明两个关键设计POST 接口收到请求后立即返回真正的业务结果永远不会在 POST 响应里出现而是通过 SSE 流异步推回去。这意味着客户端必须在调用前先建立好 SSE 连接否则响应就会丢。2.3 SSE 模式的四个痛点每个都能让线上服务抖三抖第一个痛点是请求结果严重依赖长连接。客户端发完 POST 请求后必须一直盯着那条 SSE 流。连接一旦被中间代理切断客户端既不知道业务有没有执行成功也不知道要从哪里恢复。第二个痛点是空闲超时。SSE 流是用来推送事件的如果几十秒没有任何新事件Nginx、云负载均衡、网关都会按各自的空闲策略主动断开 TCP 连接。我在日志里看到的idle timeout waiting for sse就是这一层抛出来的。第三个痛点是幂等很难做。SSE 模式下没有标准的请求幂等机制客户端重试时如果直接重发同一个 JSON-RPC 请求那些有副作用的工具调用可能会被执行两次。第四个痛点是水平扩展困难。SSE 长连接必须绑定到具体某一台实例否则服务端推送给 A 机器的事件到不了客户端正在连接的 B 机器。要保证消息不丢就得引入 Redis 级别的发布订阅和连接路由架构复杂度立刻上去了。3. Streamable HTTP一个 POST 接口打天下会话与无状态两种姿势并存3.1 把请求通道和事件通道收口是 Streamable HTTP 最核心的变化Streamable HTTP 的设计思路很直接客户端仍然通过 POST 把一个 JSON-RPC 消息发给服务端服务端根据消息类型和Accept头决定响应格式。响应可能是干净的application/json比如initialize握手的结果也可能是text/event-stream比如工具调用过程中需要持续上报进度或者流式输出文本。关键点是响应流里推送的同样是 JSON-RPC 格式的消息客户端通过id字段把响应和原来的请求对上号。这样一来客户端不再需要建立两条独立的 HTTP 连接也不存在“POST 响应永远为空、真正结果在另一条流里”的割裂感。所有消息都从同一个入口进去也从同一个响应对应的流里出来。服务端主动通知仍然可以存在但它是作为某次 POST 响应流中的一个事件块出现而不是独立于请求之外凭空推送的。3.2 有状态模式Mcp-Session-Id 与完整的会话生命周期Streamable HTTP 默认支持有状态会话。客户端先发一个initialize请求服务端在响应头或者在响应体里携带Mcp-Session-Id之后客户端的每个请求都要带上这个头服务端就能在内存或外部存储里维护这个会话的上下文。典型的生命周期是initialize创建会话notifications/initialized通知服务端握手完成后续请求复用会话 id最后会话超时或客户端主动关闭。有状态的好处是服务端可以缓存很多事情比如客户端支持的能力、已经加载的资源列表、认证信息。这种模式适合“客户端先连上 Server 再执行多轮工具调用”的场景。但代价是连接有了亲和性要求如果负载均衡把同一个 Session 的请求分发到不同实例会话就失效了。生产环境下要么给这个端点开 sticky session要么把会话数据放进 Redis 这样的共享存储。3.3 无状态模式Stateless到底在解决什么问题无状态模式就是在 Streamable HTTP 之上放弃会话这个概念。客户端每次 POST 请求都是独立的请求里不携带 Session ID服务端也不承诺保存任何调用上下文。响应仍然可以是 JSON 或者 SSE 流取决于这一步操作本身是短任务还是长任务。Stateless 最大的好处是重试变得安全了。因为服务端不针对某个会话维护状态客户端网络超时之后可以直接重发相同请求天然符合幂等语义。另一个好处是水平扩展完全不受限制K8s 里所有副本都能处理任意请求Serverless 环境更友好。代价是没有了服务端主动通知、没有会话上下文那些依赖“先登录再操作”或者“长时间保持连接持续推送”的业务做不了。对大多数 MCP Server 使用者来说我强烈建议从无状态起步只有当业务确实需要跨请求记忆和长连接能力时再切换到有状态模式。4. 一张表看清 SSE、Streamable 有状态、Stateless 无状态到底差在哪4.1 核心维度对比维度HTTP SSE旧Streamable 有状态Streamable 无状态Stateless规范定位早期 HTTP 传输已被替代现行标准默认会话模式现行标准无会话模式请求模型POST 请求 GET 事件流双通道单一 POST 入口单一 POST 入口业务结果返回依赖 SSE 流异步推送JSON 或 SSE 流JSON 或 SSE 流服务端主动通知支持通过独立事件流支持作为 POST 响应数据流的一部分基本不支持连接生命周期长连接有 idle timeout 风险会话长连接需要心跳保活请求结束即断无长连接问题重试安全性弱容易重复执行副作用需配合幂等键和会话 id 判断强天然适合失败重发水平扩展困难必须做连接亲和需要 sticky session 或共享会话存储容易任意实例均等处理典型报错idle timeout / stream disconnectedSession 过期或连接被代理切断请求超时但重试安全4.2 不同场景的选型建议如果你是本地开发个人 MCP Server只给一个 IDE 插件或者命令行客户端提供工具直接用 stdio 最稳完全没有网络层烦恼。如果企业里需要一个远程 Agent 市场很多客户端会在同一个会话里连续调用多个工具建议用 Streamable 有状态模式并且把会话生命周期、超时策略做得很明确。如果服务是部署在弹性扩容的容器集群里的短任务平台或者你需要对外暴露一个高并发、低耦合的工具接口无状态模式就是最优解。旧代码里的 SSE 模式尽快迁移到 Streamable HTTP但迁移期内可以保留 SSE 兼容层让存量客户端先跑起来。5. 本地实测启动一个 MCP Server用 curl 和 Python 分别验证三种模式5.1 用官方 Python SDK 快速起一个 HTTP MCP Server官方 MCP Python SDK 里的 FastMCP 封装把很多细节都隐藏了最适合快速验证。安装依赖并创建一个server.pyfrom mcp.server.fastmcp import FastMCP mcp FastMCP(demo-server) mcp.tool() def add(a: int, b: int) - int: 将两个整数相加 return a b if __name__ __main__: mcp.run(transporthttp)然后执行python server.py默认监听在8000端口HTTP 入口在/mcp。这个transporthttp在最新 SDK 里就是 Streamable HTTP 传输它同时兼容有状态和无状态请求。很多人第一次跑的时候不知道要观察什么我一般习惯先用curl打一发探活请求确认端口和路由都正常。curl -N -sS -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: 2025-03-26, capabilities: {}, clientInfo: {name: curl-test, version: 0.0.1} } }响应会让你看到协议能力和会话信息这是理解有状态模式的起点。5.2 用 curl 验证会话、流式响应和无状态行为初始化响应里如果带了Mcp-Session-Id把它存下来后续请求在请求头里带上curl -N -sS -X POST http://127.0.0.1:8000/mcp \ -H Content-Type: application/json \ -H Accept: application/json, text/event-stream \ -H Mcp-Session-Id: 你的会话ID \ -d { jsonrpc: 2.0, id: 2, method: tools/call, params: {name: add, arguments: {a: 1, b: 2}} }如果服务端决定走流式响应你会看到text/event-stream格式的输出每行以data:开头后面跟一个 JSON-RPC 消息。如果不带 Session 头也能正常拿到响应说明这个 Server 也支持无状态模式。Session 头循环切换测试是我检查一个 MCP Server 到底是“纯有状态”还是“支持 Stateless”最直接的方法。5.3 用 Python 封装一套可复用的 SSE 流式解析逻辑客户端消费 SSE 流的时候最容易踩的坑是直接把整个响应体读出来再json.loads这在流式场景下一定会出错。正确做法是逐行读取按事件流格式解析。我封装了一个非常薄的工具函数import json import httpx def post_and_read_events(url: str, payload: dict, headers: dict): with httpx.stream(POST, url, jsonpayload, headersheaders, timeoutNone) as resp: for line in resp.iter_lines(): if not line: continue if not line.startswith(data:): # SSE 注释行和事件名行跳过 continue data_str line[len(data:):].strip() if not data_str: continue yield json.loads(data_str)为什么跳过非 data 行因为流里可能混有event:或者:心跳行。只关心data:块就能稳定拿到 JSON-RPC 消息。如果你要在 Vue 项目里消费 Python 后端的 SSE 流不要天真地直接用浏览器EventSource它只支持 GET而 MCP 的请求入口是 POST老老实实用fetchReadableStream去解析data:行。6. 断连、重试与幂等从 idle timeout 报错到流式解析的封装心得6.1 完整复现idle timeout waiting for sse的排查链路一开始看到stream disconnected before completion: idle timeout waiting for sse时我以为是服务端崩了实际上服务端日志一切正常。后来花了半天排查才发现问题出在一个典型链条上MCP Server 以 SSE 流模式对外提供接口网关和 Nginx 默认的空闲超时是 60 秒客户端调用了一个比较耗时的工具前 60 秒内没有任何事件产生网关直接切断了连接客户端丢掉了后面的流式结果。排查步骤建议按这个顺序来先打开访问日志确认断连时间点有没有常规业务请求再看 Nginxproxy_read_timeout和负载均衡的空闲超时配置然后看客户端 SDK 的 read timeout最后抓包确认断开时是 FIN 还是 RST。这个过程别一上来就改代码先把超时方找出来。6.2 三类修复手段与适用场景第一类是长连接保活。SSE 协议本身允许服务端周期性发送注释行作为心跳我的做法是在生成器里每隔 30 秒输出一个: keep-alive\n\n让代理层认为连接还在活跃。第二类是调代理超时Nginx 里可以单独给 MCP 入口关掉缓冲并拉长读取超时location /mcp/ { proxy_pass http://mcp-server; proxy_buffering off; proxy_read_timeout 3600s; proxy_http_version 1.1; proxy_set_header Connection ; }第三类是直接从架构上避免长连接也就是切到无状态模式。请求短平快响应结束连接即断空闲超时问题自动消失。如果某个任务确实会执行很久不要在一个流里死等改成“提交任务-拿到任务 ID-前端轮询结果”的设计更稳。6.3 在智能体框架二次开发时把流式解析和重试收口到统一服务层现在很多团队在基于智能体平台做二次开发把各种 MCP Server 接进来当工具仓库用。这种场景下一定不要让每个业务方各自实现一遍 SSE 解析、重试、会话管理坑太多。我比较推荐的做法是在中间架一个薄薄的适配服务把底层的 Streamable HTTP 协议完全藏起来对上只暴露普通的 HTTP 接口或消息队列。封装层至少要处理三件事把服务端流式事件映射成接口调用方的回调给所有写操作生成幂等键重试时带上同一个 key为长任务提供单独的轮询接口。实测下来把这几件事收口之后接什么 MCP Server 都只是换配置的事不用再为传输层细节改业务代码。最后再分享两个小技巧我个人现在新起 MCP Server 项目如果没有明确的会话要求一律用 Streamable HTTP 的无状态模式起步。这个选择帮我省掉了大量粘性连接、会话过期、重试幂等的问题。本地调试时则优先 stdio等需要局域网或公网访问再切 HTTP。排障的时候建议在所有日志里打上protocol-version和session-id两个字段没有这两个字段遇到连接类问题就只能靠猜有了它们基本一眼就能定位是协议不匹配还是会话状态异常。