聊 MCP 的三种传输方式之前,先承认一件事:大多数人在看 MCP 文档的时候,最容易卡住的就是传输层这一节。stdio、SSE、Streamable HTTP 三个词单独拎出来都认识,合在一起却不知道它们为什么同时存在、各自解决什么问题。这不怪你,因为 MCP 的传输层设计带有很明显的演进痕迹——从本地进程管道,到 HTTP 长连接,再到统一的流式 HTTP,每一步都是被真实场景逼出来的。
MCP(Model Context Protocol)是 AI 应用和外部工具、数据源之间通信的开放协议,消息格式统一走 JSON-RPC 2.0。客户端要调用工具、订阅资源、接收通知,说到底都是在传输层上搬运一条条 JSON-RPC 消息。传输方式不只是"管道"这么简单,它决定你的 MCP 服务端能部署在哪里、客户端用什么方式连、连接生命周期怎么管理、安全边界画在哪。同样是写一个工具服务,跑在本地笔记本上还是部署在远程服务器上,传输层的选择完全不同。
这篇文章我会围绕实际开发视角,把三种传输方式的原理、消息流转、适用场景和踩坑经验完整过一遍。无论你是刚开始写 MCP 服务端,还是正在评估"本地工具走 stdio、远程服务走 HTTP"这类架构,应该都能从中拿走一些直接用得上的信息。
1. MCP 传输层到底是什么:先建立正确的认知框架
1.1 协议层和传输层的分工
MCP 的分层思路跟 HTTP 很像。协议层负责"说什么":定义 JSON-RPC 2.0 的方法名、参数、返回结构。你在 MCP 文档里看到的 initialize、tools/list、tools/call、notifications/resources/updated 都属于协议层。传输层负责"怎么说":消息以什么编码、走什么通道、用什么模型管理连接。两者解耦之后,换传输方式不需要改业务代码,这也是官方 SDK 一直在维护 Transport 抽象的原因。
写 MCP 服务端时,我的习惯是把协议处理逻辑(请求分发、工具调度、错误处理)和传输逻辑(读消息、发消息、连接管理)拆成两个模块。协议模块保持传输无关,对外暴露一个"收到一条消息、回一条消息"的接口;传输模块负责把接口接到具体通道上。这样不仅 stdio 和 HTTP 之间切换成本低,连测试也方便,直接构造消息就能跑协议层的单元测试。很多人在项目早期图省事,把消息解析和业务逻辑揉在一起,等到要加 HTTP 传输时才发现到处都耦合了 stdin/stdout,改起来非常痛苦。
MCP 协议本身不限定传输方式,这意味着同一份 protocol 层代码可以跨传输复用。实际项目中,我用 stdio 实现了本地工具集,后来同一个协议模块几乎原封不动地挂到 Streamable HTTP 上供远程客户端访问,只换了传输层一个组件。分层带来的收益,在项目规模变大之后会越来越明显。
1.2 三者的本质差异:进程、双通道、单端点
三种传输方式背后是三种截然不同的资源模型,理解这一点比背任何 API 都重要。
stdio 是进程模型。客户端负责拉起服务端子进程,子进程的 stdin/stdout 就是消息通道,连接的生命周期等于子进程生命周期。只要进程不退出,通道就是可用的;进程死了,连接自然断开。这里没有端口、没有 URL、没有网络连接的概念。
HTTP+SSE 是双通道模型。服务端暴露两个 HTTP 端点:一条 SSE 长连接用于服务端往客户端推消息,一个普通 POST 端点用于客户端往服务端发请求。双向通信靠两条独立通道拼出来,这是它管理复杂度高的根源。
Streamable HTTP 是单端点模型。一个 HTTP 端点既能处理客户端请求,也能按需回推流式消息。服务端根据客户端 Accept 头决定返回普通 JSON 还是 SSE 流,还能用 GET 建立长连流、用 DELETE 主动断开会话。会话状态通过 mcp-session-id 头显式传递,不再依赖"临时 URL 和连接的一对一绑定"。
把三者放在演化轴上看会更清晰:stdio 解决本机进程通信,HTTP+SSE 解决跨机器通信,Streamable HTTP 解决前两者的统一与现代 HTTP 生态落地问题。MCP 规范从早期版本走到现在,HTTP 传输定义经历过一次大调整,现在官方推荐远程服务用 Streamable HTTP,stdio 作为本地传输一直被保留。理解了这些背景之后,再看各种 MCP 客户端配置里"command + args"和"url"两种配置项的差异,就不会再迷茫了。
1.3 一个容易忽视的前提:JSON-RPC 消息才是主角
无论在哪种传输方式下,传输层搬运的统一是 JSON-RPC 2.0 消息。有三类消息必须区分清楚:
- 请求(Request):带唯一 id,必须有对应响应。
- 通知(Notification):不带 id,不需要响应,比如日志、进度更新。
- 响应(Response):匹配请求的 id,携带结果或错误信息。
一个典型场景:客户端调 tools/call 请求服务端执行某个工具。如果是普通请求-响应,服务端处理完返回 result 即可;如果工具执行时间长,服务端可以先用通知发进度,再在结束时返回最终 result。这些消息语义在三种传输里完全一样,差别只在于"服务端能否主动推消息"。stdio 里两边都是写管道,主动推送就是直接写 stdout,天然双向支持;SSE 里服务端靠 SSE 流推;Streamable HTTP 里服务端通过返回的流推。
这个"主动推送能力"是选型时极容易被忽略的点。很多人只看"能不能连上",忽略了"工具执行过程中能不能给客户端持续回报状态"。如果你要做的工具本身就是长任务,比如批量处理文件、调用外部慢接口,传输层对推送的支持程度会直接影响用户体验。后面讲到具体传输时,我会反复回到这个维度上。
2. stdio 传输:本地进程通信的朴素方案与实战避坑
2.1 stdio 如何搬运 JSON-RPC:管道机制与消息格式
stdio 的做法是把 MCP 服务端当子进程跑。客户端进程 spawn 服务端程序,然后拿子进程的 stdin 当发送通道、stdout 当接收通道。消息格式有硬性要求:每条消息必须是一整行 JSON,末尾带换行符;消息内部不能出现未转义的换行,也不能一行塞多条消息。
为什么这么设计?为了解析简单。按行读取是成本最低的解析方式,客户端和服务端都不需要引入额外的消息边界协商逻辑,不会出现部分帧、粘包这类问题——行本身就是边界。相比长度前缀协议(比如 WebSocket 的帧头),按行分割几乎没有实现成本,对本地进程通信来说足够用了。JSON 本身不支持裸换行,所以序列化之后天然不会破坏行边界,这也是一种巧妙的约束叠加。
一个最小可用的 stdio 服务端,在 Node.js 里大概长这样:
const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin }); rl.on('line', async (line) => { const request = JSON.parse(line); const response = await handleRequest(request); // 你的协议处理函数 process.stdout.write(JSON.stringify(response) + '\n'); });所有调试日志一律走 process.stderr 或 console.error,这一点再怎么强调都不过分。我见过不止一个项目在服务端代码里漏了一行 console.log,结果客户端莫名其妙报"JSON 解析失败",排查了半天才发现是日志污染了 stdout。
提示:stdio 模式下 stdout 是协议专用通道,任何非 JSON 内容都会破坏消息流。日志请一律走 stderr。
真要在生产环境用 stdio 接入多个本地工具,建议在服务端启动阶段就显式约定"stdout 只写协议消息"这一规则,项目内禁用 console.log,统一走日志框架。团队里新来的成员最容易在这个地方犯错,代码审查时把这个作为硬性要求能省掉很多线上事故。
2.2 stdio 的完整生命周期与配置实操
stdio 的会话生命周期完全跟着子进程走。客户端负责三件事:启动进程、喂消息、处理退出。在 Python 客户端里,启动和发送 initialize 请求大概是这样:
import subprocess, json proc = subprocess.Popen( ["node", "server.js"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) def send(req: dict) -> dict: proc.stdin.write((json.dumps(req) + "\n").encode()) proc.stdin.flush() line = proc.stdout.readline() return json.loads(line) resp = send({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "local-client", "version": "0.1.0"}, }, }) print(resp)这套模式就是连接的绝大部分。上层应用通过配置文件告诉客户端怎么启动这个进程:命令是什么、参数是什么、环境变量有哪些。常见的 MCP 客户端配置里会有一个 mcpServers 段落,里面每项最多就是一个 command、一组 args、一组 env。相比 HTTP 服务,没有端口、没有 URL、没有鉴权,配置成本低到可以忽略。
但生命周期管理恰恰是坑最多的地方。进程退出时,要判断是正常退出还是异常退出,检查退出码和 stderr 输出;客户端退出时要主动 terminate 子进程,避免残留孤儿进程。我在做本地服务封装时还发现一个细节:有些服务端程序收到结束信号不会主动清理临时文件和释放资源,必须在客户端侧统一兜底,退出时先给子进程发结束信号,等几秒不行再强杀。这个兜底逻辑看着简单,在跨平台环境下(比如 Windows 上没有标准 SIGINT 语义)尤其容易出问题,需要针对平台分别处理。
另一个实际经验是:不要把 stdout 的读取放在一个不设超时的阻塞循环里。如果服务端因为某种原因没有输出(比如死锁了),客户端就会永久挂起。给行读取加超时或者做一个看门狗线程,超时后主动杀掉子进程并上报错误,比让用户干等强得多。
2.3 为什么 stdio 只适合本地:边界、安全与性能
stdio 最重要的特性是"不暴露网络"。服务端程序只跟父进程通信,外部机器无法主动连进来,没有端口扫描、没有越权调用这类网络攻击面。本地文件访问、读取密钥、操作本机数据库这类权限敏感的工具,用 stdio 是天然安全的——使用权限就是进程启动权限,谁启动了这个进程,谁就拥有它的能力,用户对这一点有清晰感知。
也正因为如此,stdio 的范围被锁定在"客户端和服务端在同一台机器、同一个用户"的本地场景。跨机器部署、多人共享、Web 端接入基本都不适合。一旦服务端要部署到服务器上、让多个远程客户端并发访问,stdio 就完全使不上劲。有人会尝试用远程命令执行的方式包装 stdio 实现"远程 stdio",那会引入另一套复杂度和安全隐患,而且失去 stdio 最大的优势。
性能方面,stdio 的管道吞吐足以支撑本地调用链路上频繁的工具调用,JSON 序列化和反序列化反而可能是主要开销。真正的性能瓶颈很少出现在管道本身,更多出在服务端实现里有没有正确处理并发。这里要特别提醒一点:stdio 虽然是管道通信,但 MCP 协议没有规定必须串行处理消息。客户端完全可能在未收到前一个请求响应时就发送下一个请求,服务端如果按 readline 逐行 await 处理,实际上就变成了串行,高并发场景下整体响应会被拖慢。如果服务端的工具是 IO 密集型的异步操作,建议直接异步处理请求,响应顺序由响应里的 id 保证,而不是靠处理顺序保证。
3. SSE 传输:被 HTTP 长连接"压垮"的半双工方案
3.1 理解 SSE 的协议本质:单向推送加额外回传通道
SSE(Server-Sent Events)本身是 HTML5 标准里的技术,用于服务器向浏览器单向推送事件。它的做法是客户端发起一个普通 HTTP GET 请求,服务器收到后不结束响应,而是保持连接打开,持续往响应体里写事件数据。每条 SSE 事件由若干字段组成,最重要的就是 data 字段,以两个换行符作为事件分隔:
data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05"}}MCP 早期版本的 HTTP 传输选择 SSE,看中的是它不需要额外依赖(原生 HTTP 就能支持)、天然适合服务端向客户端推送通知的场景。但 SSE 有一个本质限制:它是单向的。客户端想发消息给服务端,不能复用这条 SSE 连接,必须另走一条 HTTP 通道。所以在老版 MCP HTTP 传输里,服务端一般暴露两个端点:一个用于建立 SSE 流的端点,一个用于接收客户端 POST 请求的消息端点。
这也解释了为什么老版 SSE 用起来总有一种"别扭感":一个双向会话被硬拆成了两条单向通道,所有状态都要在这两条通道之间同步,任何一个环节掉链子,会话完整性就出问题。相比之下,WebSocket 天然双向,但服务端支持和中间件穿透成本比 SSE 高不少,MCP 早期选择了 SSE 也是权衡之后的结果。
3.2 老版 HTTP+SSE 的会话拆解:endpoint 事件与消息端点
老版 HTTP+SSE 的完整会话流程是这样的:
- 客户端 GET 请求服务端的 SSE 端点,服务端建立 SSE 连接,后续所有服务端到客户端的消息都往这里推。
- 服务端在 SSE 连接建立后,先推送一个特殊的 endpoint 事件,里面带一个消息端点的 URL。这个 URL 通常是服务端临时生成的,可能带着 session 标识:
event: endpoint data: /mcp/message?sessionId=abc123- 客户端拿到这个 URL 后,后续的 JSON-RPC 请求都通过 POST 方式发到这个消息端点。
- 服务端处理完请求,响应通过第 1 步建立的那条 SSE 连接推回给客户端。
这套流程最大的问题在于"管理起来很拧巴"。一个完整的请求-响应跨了两条 HTTP 通道,任何一条出问题,会话就处于悬空状态。SSE 连接断了,客户端的 POST 请求可能还在发出,服务端的响应却发不回去。客户端要实现会话恢复,必须跟踪"当前工作的是哪条 SSE 连接、对应哪个消息端点",这组状态同步在无状态 HTTP 场景里非常反直觉。
实际项目中我遇到的典型问题还有两类。一类是代理缓冲:不少 HTTP 代理和网关默认会缓冲响应体,SSE 长连接的内容如果被缓冲,客户端会长时间收不到事件,表现就是"偶尔能通、时不时卡住"。必须在代理层关掉缓冲或设置合适的 flush 行为。另一类是连接超时:常规 HTTP 链路有超时设置,SSE 如果长时间没有事件(比如一个耗时工具几十秒没输出),中间层可能直接把连接掐了。SSE 规范本身建议服务端定期发以冒号开头的心跳注释行维持连接,MCP 服务端实现时也应该加上心跳,客户端也别把超时调得太短。
3.3 为什么老版 SSE 会被取代:并发、会话与实现复杂度
从协议设计角度说,老版 HTTP+SSE 不是不能用,而是复杂度管理成本太高。MCP 规范演进后,官方明确推荐远程 HTTP 服务使用 Streamable HTTP,老版 SSE 传输逐渐被标记为废弃。原因可以归纳为三点。
第一,双端点设计割裂了会话。SSE 连接和消息端点之间靠临时 URL 绑定,代理、负载均衡、客户端重连都会破坏这个绑定。服务端要维护一张"sessionId 到 SSE 连接"的映射表,多个服务实例部署时还得考虑映射表存哪里,一趟下来复杂度直接上了一个数量级。
第二,响应格式分裂。同样的 JSON-RPC 响应,有时从 SSE 流里来,有时从 POST 响应的 body 里来,客户端得维护两套解析逻辑。老版规范里这个边界尤其模糊,服务端到底走流还是直接回 JSON,很多时候没有明确约定,导致客户端实现五花八门。
第三,没有标准化的终止机制。客户端想主动断开会话,只能靠关闭 SSE 连接,服务端依赖连接断开的底层事件来清理资源。在代理层拦截、连接泄漏等情况下,服务端很难可靠感知会话结束,时间一长就容易积累一堆僵尸会话。这些痛点正是 Streamable HTTP 着重解决的。
4. Streamable HTTP:远程 MCP 服务的当前标准玩法
4.1 三个关键改进:单端点、双响应模式、可终止会话
Streamable HTTP 是 MCP 规范在 2025 年引入并确立的 HTTP 传输方式,设计目标很明确:把老版 SSE 的复杂度在协议层消灭掉。我总结它的三个关键改进。
第一,单端点。客户端只需要知道一个 base URL,所有 JSON-RPC 消息都 POST 到同一个端点。不再需要先 GET 建立流、拿 endpoint 事件、再跑去另一个地址发消息。服务端可以同时支持 GET 用于打开 SSE 流、POST 用于收发消息、DELETE 用于终止会话,但常规请求响应只需要 POST。
第二,双响应模式。客户端在 POST 请求里通过 Accept 头声明自己期望的响应格式:application/json 表示接受普通 JSON 响应,text/event-stream 表示接受 SSE 流。服务端根据 Accept 头决定返回方式。核心思想是:如果这次请求-响应不需要服务端主动推送过程性消息,直接返回 JSON 就够了;如果需要流式推送工具执行进度、资源更新通知等,就返回 SSE 流。响应不一定非要用流,这就给了服务端很大的灵活性。
第三,标准化的会话生命周期。服务端可以在首次请求的响应里带 mcp-session-id 头,后续请求带上这个头就能保持会话。GET 用于打开一条 SSE 流,让服务端随时推送通知;DELETE 用于主动终止会话,服务端能借此释放资源。会话该不该建、建多久、谁负责销毁,终于有了一套明确约定。
注意:Streamable HTTP 不是抛弃了 SSE,而是把 SSE 从"传输方式"降级为"消息编码的一种可选项"。需要推送时用 SSE 编码,不需要时用纯 JSON,一个端点按需切换——这是它设计上比老版优雅的核心原因。
我在实际使用中的感受是:Streamable HTTP 最打动人的地方是"服务端可以选择怎么回应"。你可以在一个请求里直接返回 JSON 结果,也可以在另一个请求里开启流式返回,客户端不需要预先知道这次请求会走哪种模式,靠 Accept 头和响应头协商就行。这种弹性让传输层从"固定的管道"变成了"按需适配的通道"。
4.2 服务端实现要点:Accept 协商与会话设计
写 Streamable HTTP 服务端,核心逻辑是响应协商。一个简化版的框架思路:
server.post("/mcp", async (req, res) => { const accept = req.headers.accept ?? "application/json"; const message = req.body; // JSON-RPC 消息 const result = await handleMessage(message, req.headers["mcp-session-id"]); if (accept.includes("text/event-stream")) { // 需要流式返回时:回一个 SSE 流 res.setHeader("content-type", "text/event-stream"); res.write(`data: ${JSON.stringify(result)}\n\n`); res.end(); } else { // 普通请求-响应 res.setHeader("content-type", "application/json"); res.send(JSON.stringify(result)); } });这个示例只展示了最简单的分支。真正生产级的实现还要考虑:流式模式下工具执行过程中可能有进度通知,事件怎么穿插在最终 result 之前;会话校验失败时返回什么错误码;keep-alive 心跳怎么维持 GET 流不断开。我的建议是不用手写这些,直接用官方 SDK 里现成的 Transport 实现,它把这些细节都封装好了,你只需要注册协议处理器。手写传输层是一次很好的学习过程,但生产环境没必要重复造轮子。
会话管理上,有状态和无状态两种风格我都见过。简单本地部署可以做成有状态,内存里存 sessionId 到上下文的映射;多实例部署建议无状态,把必要的会话上下文序列化后放进 mcp-session-id 头里,或者使用外部存储,让任意实例都能处理同一会话的请求。选哪种取决于你的实际拓扑,但一定要把"会话清理"想清楚。我见过一个线上服务因为客户端异常退出时没有调 DELETE,服务的会话映射表一直不清理,内存缓慢上涨,最后只能靠重启缓解。服务端可以加一个空闲超时回收机制,定期清理长期不活跃的会话。
4.3 客户端接入要点:响应分支与旧版兼容
客户端接入 Streamable HTTP,关键也是看响应头。发送 POST 请求时,Accept 头建议同时声明两种格式:
const res = await fetch("https://mcp.example.com/mcp", { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", }, body: JSON.stringify(request), });拿到响应后,先看 content-type,再决定走哪条解析路径。application/json 就直接解析 body 里的 JSON-RPC 响应;text/event-stream 就按 SSE 格式逐段解析事件。如果响应头里引入了 mcp-session-id,记得存下来,后续请求带上。
兼容老版客户端也是 Streamable HTTP 规范里明确考虑的:如果客户端只带 Accept: text/event-stream,服务端可以退回老式 SSE 行为。对服务提供方来说,如果服务暴露在公网给各种第三方客户端用,响应分支要写得健壮,尤其是 HTTP 状态码和错误信息的处理。很多客户端在收到非 200 的响应时会直接判定失败,但你必须在错误响应体里带上 JSON-RPC 错误结构,让客户端能把错误透传给上层。
我还遇到过一个性能细节:不要把每次 POST 都默认要求流式返回。对 tools/call 这种一问一答的调用,直接 JSON 响应最快;initialize、工具列表这些更是只适合 JSON。流的价值在于长任务、进度推送和订阅场景。客户端如果对所有请求都声明要流式,很多服务端实现会强制走 SSE,白白增加连接管理和解析开销。正确做法是 Accept 头里两种格式都带上,让服务端根据请求特性自己选择最优响应方式;只有对确实需要进度反馈的请求,才单独强调流式支持。
5. 三种传输方式怎么选:一份可以直接抄的对照手册
5.1 传输方式差异速览表
| 维度 | stdio | 老版 HTTP+SSE | Streamable HTTP |
|---|---|---|---|
| 通道模型 | 子进程 stdin/stdout | SSE 长连接 + POST | 单 HTTP 端点 |
| 部署位置 | 本机(同一进程树) | 远程 HTTP | 远程 HTTP |
| 连接生命周期 | 等于子进程生命周期 | 等于 SSE 连接生命周期 | 会话 ID 管理,GET/DELETE 控制 |
| 服务端主动推送 | 写 stdout 即可 | 通过 SSE 流 | 按需 SSE 流 |
| 网络暴露 | 无 | 暴露 HTTP 端口 | 暴露 HTTP 端口 |
| 鉴权 | 不适用(本地) | 需自行实现 | 支持标准 HTTP 鉴权方式 |
| 典型场景 | 本地文件、本地数据库、开发工具链 | 老系统遗留接口 | 新远程服务、公网部署 |
| 协议状态 | 一直保留推荐 | 已废弃 | 当前推荐 |
| 配置复杂度 | 最低(command+args) | 较高(两个端点加会话映射) | 中(单 URL 加可选会话头) |
这张表基本覆盖了我选型时全部会关心的维度。最直观的结论是:本地优先 stdio,远程优先 Streamable HTTP,老版 SSE 除非你已经在维护存量系统,否则没有任何理由在新项目里选它。
5.2 按场景选型的实战建议
我把实际项目里最常见的几种场景整理成建议,可以直接套用。
- 给 AI 编程工具挂一个本地工具服务,比如文件操作、读取项目结构、调用本地命令行:选 stdio。无网络暴露、配置简单、跟本地环境天然绑定。
- 给公司内部搭建供多个团队共用的 MCP 服务,比如统一的知识库查询接口:选 Streamable HTTP。需要并发访问、需要鉴权、需要监控,HTTP 生态里这些都是成熟方案。
- 在浏览器 Web 应用里接入 MCP:选 Streamable HTTP。老版 SSE 的双端点设计对浏览器端的 fetch 和 EventSource 都不友好,单端点方案干净得多。
- 在边缘函数、Serverless 环境里跑 MCP:选 Streamable HTTP,并且尽量无状态化,会话信息用 mcp-session-id 头携带。
- 维护老系统:如果存量客户端已经按老版 SSE 对接,可以继续用,但要在底层做好连接保活和会话重建;新功能一律按 Streamable HTTP 扩展。
一个容易被忽略的点是"转发"场景。如果你的 MCP 服务端只是一个薄转发层,背后还有真正的业务系统,传输选型更要谨慎。stdio 转发到远程是自欺欺人;老版 SSE 做转发会放大连接管理的复杂度;Streamable HTTP 配合标准鉴权和会话机制是最省心的。我做内部网关的时候就吃过亏,最开始图省事用老版 SSE 转发,结果上游服务一切换、会话映射就乱套,后来重写成 Streamable HTTP,问题直接消失。
6. 传输层常见问题与排查技巧实录
6.1 问题现象与解决速查表
| 现象 | 可能原因 | 排查方向与解法 |
|---|---|---|
| stdio 模式报"JSON 解析失败" | 服务端日志混入 stdout | 检查服务端代码里的 console.log,统一改走 stderr;在客户端侧打印原始日志辅助定位 |
| stdio 模式工具调用后卡住 | 服务端进程退出或死锁 | 检查退出码和 stderr;确认服务端是否异步处理长任务,别阻塞事件循环 |
| SSE 连接时不时断线 | 代理缓冲、中间层超时、心跳缺失 | 关代理缓冲,服务端加心跳事件,客户端把超时调大并做好重连 |
| 老版 SSE 会话恢复失败 | endpoint 事件携带的临时 URL 失效 | 确认服务端是否把 sessionId 和 SSE 流稳定绑定;重连时重新建立 GET 流 |
| Streamable HTTP 返回 406 | 服务端不认可客户端 Accept 头 | 确认 Accept 头同时带 application/json 和 text/event-stream;查看服务端规范要求 |
| Streamable HTTP 长任务无进度 | 客户端没要求流式,服务端走了普通 JSON | 对这种请求强调 text/event-stream Accept;服务端实现按需协商 |
| HTTP 会话内存缓慢上涨 | 会话没被 DELETE,服务端映射表未清理 | 客户端退出时调 DELETE;服务端加空闲超时回收 |
| 远程 HTTP 鉴权失败 | 头名或凭据传递方式不对 | 确认客户端使用的鉴权头和 MCP 服务端期望的一致 |
这张表里的问题我几乎全都实际撞见过。最魔幻的一次是 SSE 连接在测试环境一直正常、一上生产就断,最后定位到生产网关开启了响应缓冲,SSE 事件被囤积在网关层,客户端等不到下一个事件触发超时。这类问题有个共性:传输层的问题表面上是"连接不稳",根因往往在链路中间件和配置上,而不是 MCP 本身。
6.2 排查思路与方法论沉淀
排查 MCP 传输问题,我总结了一套固定顺序,能省很多时间。
第一,先确认消息有没有到达对端。在服务端打印收到的每条原始消息,在客户端打印收到的每条原始响应。传输层的问题,九成会在消息流日志里现出原形。stdio 就把 stderr 日志打开看;HTTP 就在代理层或服务端加请求日志。先看数据有没有通,再看通得对不对。
第二,区分连接问题和消息问题。连接问题表现为断连、超时、重连,消息问题表现为 JSON 解析失败、协议字段错误。两类问题的排查入口完全不同。连接问题先看中间层(代理、负载均衡、防火墙),消息问题先看序列化和协议实现。我最常看到的情况是大家把所有问题都当连接问题排查,绕着圈找不到点。
第三,用最小复现收窄范围。写一个只发 initialize 请求的最简客户端脚本,从握手开始一级一级往上试。MCP 的握手(initialize、initialized、工具列表)每一步都有明确响应,哪一步断了,问题就定位在哪一层。我帮别人排障时最常说的就是:不要一上来就跑复杂工具调用,先让最小握手跑通,再逐步增加复杂度。
第四,关注协议版本。MCP 规范版本迭代快,服务端声明的 protocolVersion 跟客户端不一致时,某些行为可能完全不同。如果你用了预发布版本的 SDK,注意它实现的传输规范是哪个版本。对外提供服务的,尽量在 initialize 响应里如实上报版本,客户端做兼容判断也有依据。
我个人还有一个习惯:给每个 HTTP 传输的 MCP 服务都加一个健康检查端点,返回协议状态、活跃会话数和最近错误计数。这相当于给传输层上了监控,问题爆发前往往能从会话数异常增长里先感知到。
最后说一个很多人没意识到、但实际价值很高的点:传输选型不是一次性的。早期你可能只是本机工具用 stdio,后来业务起来了,想把同一个工具能力开放给远程团队,就需要在同一份协议处理逻辑上换一个 Streamable HTTP 传输。这正是 MCP 分层设计最大的红利。我自己的项目就是从这条路径走过来的,先把业务逻辑封装成传输无关的协议模块,后来接 HTTP 只花了一个下午。所以不管你现在只用哪种传输,都建议把协议层和传输层保持解耦——这是我能给出的最实用的一条建议。