从零手写MCP Agent:stdio与SSE传输模式底层原理与踩坑实践

发布时间:2026/9/24 23:49:57
从零手写MCP Agent:stdio与SSE传输模式底层原理与踩坑实践 MCP 这个词最近一段时间在 AI 工程圈里几乎成了标配热词尤其是 Agent 项目一多你会发现大家最后都会绕回同一个问题Agent 怎么才能干净、统一地接上各种外部工具。我最初对 MCP 的态度其实挺抵触的心想又来个新协议学不动了。直到我在一个真实项目里要给 Agent 接公司内部十几个工具一个个写 Function Calling 适配器写到快吐才彻底明白 MCP 到底解决了什么痛点。这篇文章不是官方文档的翻译稿而是我从零手写一个 MCP Agent 服务的完整记录重点放在 stdio 和 SSE 这两种传输模式上。我会把协议层怎么设计的、两种连接模式底层怎么跑的、服务端和客户端分别怎么实现、以及我实际踩过的一堆坑全部摊开来讲。适合两类人看已经在用 SDK 但想搞清楚底层原理的开发者以及刚接触 MCP、正准备入门的同学。看完你至少能自己写一个不依赖 SDK 的 MCP 服务端还能把市面上那些“连接失败”“流被断开”的报错原因解释清楚。1. 先看清楚MCP 到底在解决什么问题1.1 一次 Agent 接工具的全链路回顾先说个场景。你有一个 Agent它要完成“帮用户查一下杭州明天的天气并把结果生成一张图”这种任务。在没有 MCP 之前你得给模型写两个 functionget_weather和generate_chart然后在代码里分别对接天气 API 和图表库。这看起来不难但问题出在规模化当你要接的工具有 30 个、50 个而且是不同团队维护的每个人自定义一套参数格式、鉴权方式、返回结构Agent 侧的适配代码会膨胀到根本没法维护。MCPModel Context Protocol就是在这个背景下出现的。它做的事情本质上很朴素把“工具”抽象成统一的 JSON-RPC 接口定义一套标准化的发现工具、调用工具、读取资源的流程。任何服务方只要实现这套协议任何 Agent 只要实现这套协议两边就能直接对话。这就是它名字里 Protocol 的含义——不绑定具体语言不绑定具体厂商只定通信规矩。用生活里的类比来说MCP 相当于给工具行业定了一个统一的“插座标准”。以前每个家电都有自己的插头形状你得准备一堆转接头现在大家都按同一个标准生产插座和插头插上就能用。Agent 是那个用电器MCP Server 是那个插座而 MCP Client通常内嵌在 Agent 里就是那根电源线。1.2 为什么会有 stdio 和 SSE 两种传输模式理解了 MCP 的价值下一个问题就是消息到底怎么在两台“程序”之间传MCP 协议规范里给出了两种标准传输方式stdio通过标准输入输出传递 JSON-RPC 消息适合本地进程间通信。SSE基于 HTTP 的 Server-Sent Events 单向长连接适合跨网络的远程服务。为什么需要两种因为场景完全不同。stdio 模式下MCP Server 是 Agent 所在机器上的一个子进程Agent 启动它、往它的 stdin 写消息、从它的 stdout 读消息。这种方式延迟低、没有网络开销、天然安全不需要鉴权因为进程就在你眼皮底下。但它有个硬限制服务器必须和客户端在同一台机器上而且必须是可执行程序。SSE 模式则把 MCP Server 变成一个 HTTP 服务部署在任意机器上。Agent 通过 HTTP 远程调用适合“云端 Agent 调用公司内网工具”“多客户端共享同一个工具服务”这类场景。代价是要处理网络问题超时、断连、跨域、代理缓冲全都是坑。在我这个项目里两种模式我都要支持。因为工具库里有本地文件操作这类必须走 stdio 的低延迟工具也有需要共享给团队其他 Agent 用的数据查询服务只能远程走 SSE。这也是很多生产级 MCP 服务的常见架构本地工具用 stdio 拉起公共服务用 SSE 暴露。1.3 Agent、Client、Server 三者的角色边界动手写代码之前先把角色边界理清楚不然你会被“Agent 到底算 host 还是 client”这个问题绕晕。MCP 协议里定义了三个角色MCP Host用户直接交互的应用程序比如 Claude Desktop、IDE 插件或者我们写的这个 Agent 本体。MCP ClientHost 内部负责连接 Server 的组件它维护连接状态、发送请求、接收响应。MCP Server提供工具、资源、提示词的进程或服务。关键点在于在我们的项目里Agent 既是 Host 也是 Client。它对外是用户面对的智能体对内要作为 Client 去连接各种 MCP Server。所以“手写 MCP Agent”实际上包含两部分工作一是实现一个 MCP Client负责和 Server 通信二是实现 Agent 本身的核心循环LLM 推理 工具调用编排。而标准 MCP Server 那一侧我们也要自己写因为需要同时验证 stdio 和 SSE 两条链路。这个区分非常重要因为很多新手会把 Agent 理解成 MCP Server其实完全反过来——Agent 是服务的消费者是主动发起连接的那一方。2. 两种传输模式的底层原理2.1 所有消息共享同一层壳JSON-RPC 2.0无论用 stdio 还是 SSEMCP 的应用层协议是统一的全部建立在 JSON-RPC 2.0 之上。你只需要掌握四种消息形态整个协议的 80% 就通了请求Request带id字段对方必须回result或error。比如initialize、tools/call。响应Response带与请求相同的id包含result或error。通知Notification没有id对方不用回复。比如notifications/initialized、notifications/tools/list_changed。内置方法除了业务方法还有ping保活、cancelled取消请求这种协议级方法。一个典型的tools/call请求长这样{ jsonrpc: 2.0, id: 2, method: tools/call, params: { name: get_weather, arguments: { city: 杭州 } } }响应则长这样{ jsonrpc: 2.0, id: 2, result: { content: [ { type: text, text: 杭州明日晴气温 18~26℃ } ], isError: false } }注意content是一个数组元素可以有不同的typetext、image、resource等这给了多模态返回很大的灵活性。你在实现客户端的时候不要假设返回的一定是字符串要遍历content数组按类型处理。2.2 stdio 模式子进程里的换行分隔消息stdio 传输的底层机制其实特别简单客户端用child_process.spawn拉起服务端进程然后客户端 → 服务端写入子进程的stdin服务端 → 客户端从子进程的stdout读取日志和调试信息一律走stderrMCP 规范里规定stdio 模式下每条 JSON-RPC 消息以换行符\n作为分隔也就是我们熟悉的 JSONL 格式。这点和 LSP 不同LSP 用的是Content-Length头 消息体的帧格式MCP 简化成了换行分隔。好处是能直接用readline一行一行读坏处是 JSON 里不能出现裸换行符序列化时要注意。我推荐的标准实现结构是这样的import { createInterface } from node:readline; const rl createInterface({ input: process.stdin }); rl.on(line, async (line) { if (line.trim() ) return; let msg; try { msg JSON.parse(line); } catch (e) { // 收到非法 JSON按协议应该回一个 JSON-RPC 错误 writeMessage({ jsonrpc: 2.0, id: null, error: { code: -32700, message: Parse error } }); return; } const result await handleMessage(msg); if (result) writeMessage(result); }); function writeMessage(obj: unknown) { process.stdout.write(JSON.stringify(obj) \n); }这个模式的精髓在于它把进程当成了网络 socket 用。子进程的 stdin/stdout 就是两根管道再加上操作系统的进程管理异常退出、信号处理就构成了一个完整的 IPC 通道。由于不需要监听端口也没有网络安全问题stdio 模式特别适合本地敏感工具的接入——比如操作文件系统、执行本地命令的工具。2.3 SSE 模式HTTP 长连接里的单向流SSEServer-Sent Events很多人容易和 WebSocket 搞混。一句话区分WebSocket 是双向全双工通道而 SSE 是服务器到客户端单向的流式推送。那么问题来了MCP 是双向通信的客户端要发请求给服务器服务器也要发响应给客户端只靠 SSE 怎么够MCP 的解法是混合通道下行通道服务器 → 客户端一条 SSE 长连接服务器在这条流上推送事件。上行通道客户端 → 服务器一个普通的 HTTP POST 端点客户端把 JSON-RPC 请求 POST 过去。具体流程是这样的。客户端先GET /sse服务器立刻返回一个流式响应并在流的开头发一个endpoint事件event: endpoint data: /messages?sessionId8f2a1c3e这个endpoint事件告诉客户端以后你要发消息就 POST 到这个路径。而 POST 过来的请求服务器处理完后把响应通过已建立的那条 SSE 连接推送回去。这样就用“HTTP POST SSE”拼出了一个双向通信而且下行天然支持流式——这对大模型回答实时渲染来说简直量身定做。SSE 连接建立后还有一个关键的Mcp-Session-Id头。服务器在endpoint事件里给了 sessionId 之后客户端后续的 POST 请求都要带Mcp-Session-Id: 8f2a1c3e这个头服务器才能把请求关联到正确的那条 SSE 连接上。很多“服务端收到了消息但客户端没反应”的问题追根究底都是这个头丢了。2.4 连接生命周期握手、初始化、运行、关闭不管哪种传输连接建立之后都要走一遍标准生命周期。首先是initialize握手。客户端发一个带id的initialize请求告诉服务器自己是谁、支持的协议版本、能力和期望{ jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2024-11-05, capabilities: { roots: { listChanged: false } }, clientInfo: { name: my-agent, version: 0.1.0 } } }服务器必须回应自己支持的协议版本。这里有个容易踩的坑如果客户端请求的版本服务器不支持规范要求响应里返回服务器支持的版本由客户端决定是否兼容。所以你要是自己实现客户端不能假设服务器一定会回跟你一样的版本要做协商处理。握手成功后客户端要发一个notifications/initialized通知表示“我知道你是什么情况了可以开始干活”。之后就可以用tools/list拉取工具列表用tools/call调用工具。注意notifications/initialized是个通知没有id服务器不会回响应——如果你在客户端强行等它的响应就会一直等到超时。关闭阶段相对随意stdio 模式直接杀进程即可SSE 模式断开连接就行。但规范里还有shutdown方法优雅退出时最好走一遍给服务器清理资源的机会。3. 从零实现的完整过程3.1 技术栈与项目结构我选的是 Node.js TypeScript。原因很简单Agent 的生态在 JS/TS 里最顺而且 Node 对子进程和 HTTP 流都有很好的支持。当然这个协议是语言无关的Python、Go、Java 都能实现核心逻辑是一样的。项目目录结构如下mcp-agent/ ├── src/ │ ├── protocol/ │ │ ├── types.ts # JSON-RPC 类型定义 │ │ ├── jsonrpc.ts # 消息封装与错误码 │ │ ├── stdio-server.ts # stdio 模式服务端 │ │ ├── sse-server.ts # SSE 模式服务端 │ │ └── client.ts # 客户端核心连接管理 请求路由 │ ├── agent/ │ │ ├── loop.ts # Agent 主循环 │ │ └── tools.ts # 工具注册与调用映射 │ ├── llm/ │ │ └── provider.ts # LLM 调用封装 │ └── main.ts └── package.json两个服务端stdio 和 SSE都实现同一组业务逻辑initialize、tools/list、tools/call、ping。我把公共部分抽出来两个传输层只负责“收发消息”这样就能直观对比两种模式在传输层上的差异。3.2 实现 stdio 模式服务端stdio 服务端的核心就是把 stdin 读进来的行解析成 JSON-RPC 请求处理完写回 stdout。前面已经给过基本代码这里补上几个关键细节。第一个细节是异步请求的交错处理。MCP 允许客户端连续发多个请求服务器可以乱序返回靠id关联。所以不能用“读一行 → 处理 → 写一行”这种串行阻塞方式要异步派发rl.on(line, (line) { const msg JSON.parse(line); // 不 await直接派发保证并发 handleMessage(msg).then((response) { if (response) writeMessage(response); }); });第二个细节是日志隔离。这个坑我踩得最惨第一次运行 stdio 服务时我图方便用console.log打调试信息结果客户端那边 JSON.parse 直接爆炸报错还是那种极其隐晦的Unexpected token D——因为日志和 JSON 数据混在一条 stdout 流里了。正确做法是所有的日志都用console.error或者写日志文件。我后来干脆封装了一个log()函数统一输出到 stderr并带上时间戳和级别方便排查。第三个细节是子进程环境变量。客户端 spawn 服务端时如果工具是用npx或全局命令行启动的会面临 PATH 找不到的问题。比如在 macOS 上图形界面启动的进程 PATH 里没有node的安装路径直接 spawn 一个“依赖于 node 的 CLI 工具”就会失败。稳妥的做法是 spawn 时显式拼 PATHimport { spawn } from node:child_process; const child spawn(serverCommand, serverArgs, { stdio: [pipe, pipe, pipe], env: { ...process.env, PATH: ${process.env.PATH}:${process.env.HOME}/.local/bin } });3.3 实现 SSE 模式服务端SSE 服务端我用 Express 实现。核心是两条路由GET /sse建立下行流POST /messages接收上行消息。先看GET /sseimport express from express; import { randomUUID } from node:crypto; const app express(); app.use(express.json()); const sessions new Mapstring, express.Response(); app.get(/sse, (req, res) { res.setHeader(Content-Type, text/event-stream); res.setHeader(Cache-Control, no-cache, no-transform); res.setHeader(Connection, keep-alive); res.flushHeaders(); const sessionId randomUUID(); sessions.set(sessionId, res); // 关键先把 endpoint 事件推给客户端 res.write(event: endpoint\ndata: /messages?sessionId${sessionId}\n\n); req.on(close, () { sessions.delete(sessionId); }); });注意三个点。第一Cache-Control里我加了no-transform这个是防止某些代理服务器对响应做压缩或缓冲导致 SSE 事件不能及时到达。第二flushHeaders很关键Express 默认会把响应头攒到第一次write才发但 SSE 必须尽早建立连接否则客户端会在等待响应头时超时。第三endpoint事件要在一建立连接后就推客户端没有这个事件就不知道往哪 POST。再看POST /messagesapp.post(/messages, async (req, res) { const sessionId req.query.sessionId as string; const sseRes sessions.get(sessionId); if (!sseRes) { res.status(404).json({ error: session not found }); return; } const msg req.body; // 处理 JSON-RPC 请求拿到结果可能流式 const result await handleMessage(msg); // 通过 SSE 下行通道推给客户端 sseRes.write(event: message\ndata: ${JSON.stringify(result)}\n\n); // POST 请求本身立刻返回不要等流式结束 res.status(202).end(); });这里容易被忽略的是响应时序。客户端 POST 一个tools/call请求过来服务器如果同步处理handleMessage的 Promise 要等工具执行完才 resolve。对于耗时的工具比如调外部 API这个 POST 会挂很久。更合理的做法是POST 立刻返回202 Accepted把请求丢进任务队列等工具执行完再通过 SSE 推送响应。但要注意如果客户端实现是“等 POST 的响应”那你必须改成“等 SSE 上的 message 事件”。我在实际项目里用的是第二种方案POST 只负责接收和入队立即返回所有响应全靠 SSE 推。这样天然支持流式输出——LLM 的每一个 token 块都可以通过 SSE 推给客户端实现“实时渲染大模型回答”的效果。3.4 实现 Agent 客户端循环服务端有了客户端的 Agent 主循环才是重头戏。一个完整 Agent 循环长这样拼接 system prompt 和用户消息发给 LLM。LLM 返回结果可能包含tool_calls。如果没有tool_calls直接输出最终回答结束。如果有遍历tool_calls把工具名映射到对应的 MCP Server调用tools/call。把工具结果作为新的消息追加到对话上下文回到第 1 步。编号 4 里的映射是整个 Agent 架构的精髓。多个 MCP Server 注册进来后Agent 需要维护一张“工具名 → (serverClient, serverInfo)” 的映射表。tools/list返回的工具上带有name但不同 Server 之间可能重名所以我的做法是注册时给每个 Server 加前缀比如fs_list_files、db_query用前缀避免冲突。核心调用代码大致如下class MCPServerConnection { async listTools(): PromiseTool[] { const res await this.request(tools/list, {}); return res.result.tools; } async callTool(name: string, args: Recordstring, unknown) { const res await this.request(tools/call, { name, arguments: args }); return res.result; } }在request方法内部我需要维护一个pending映射表键是请求的id值是{ resolve, reject }。收到响应时通过id找到对应的 Promise 并 resolve。这套逻辑在 stdio 和 SSE 两种模式下是复用的两边只是传输层的读写实现不同。这也说明了把这个请求路由层独立出来非常值得——我的client.ts没有任何一行代码关心消息是从管道来的还是从 HTTP 来的。3.5 流式输出与中断控制大模型回答的实时渲染依赖流式能力。在 SSE 模式下这件事很自然LLM 服务端把 token 分块通过 SSE 的message事件推给客户端前端拿到一部分就渲染一部分。但这里有一个容易忽略的协议细节tools/call的result里有一种特殊结构叫_meta加流式提示规范里叫内容块流式更新内容块增量。简单理解就是服务器可以先返回一个content数组的骨架再通过后续的notifications/tools/call_progress或增量消息不断往里填内容。原生实现流式比较复杂。我的建议是如果你的 MCP Server 也是自己写的干脆在tools/call的响应里直接返回完整结果把流式效果放在外层也就是“LLM 生成最终回答”这个环节。MCP 工具结果往往是结构化数据JSON、文本本身不需要流式真正需要流式的是 LLM 的最终回答那部分走你自己的 SSE 通道即可。中断控制是另一个容易翻车的地方。用户觉得回答太慢点了个取消这时候你需要前端用AbortController中止 SSE 的fetch流Agent 进程内向 LLM 发一个中止请求如果有正在执行的 MCP 工具调用向 MCP Server 发一条notifications/cancelled通知带requestId和原因。// 客户端取消请求 const controller new AbortController(); async function startStreaming(url: string) { const resp await fetch(url, { signal: controller.signal }); const reader resp.body.getReader(); // 读取流... } // 用户取消时 controller.abort();如果不发cancelled通知MCP Server 上的工具可能还在执行白白浪费资源甚至可能产生副作用比如重复扣费、重复写库。这一点在提测时最容易漏但生产环境特别重要。4. 踩坑实录这些问题你一定也会遇到4.1 stdio 模式的高频问题问题一子进程启动后没有任何输出客户端卡死在 initialize。排查思路分三步。第一步确认子进程真的起来了用ps看进程第二步确认有没有报错把子进程的stderr管道出来看日志第三步确认进程 PATH 是否正常很多npx启动的工具在 GUI 环境下 PATH 不完整。我遇到过最隐蔽的情况是工具依赖的全局命令在 bash 里能用但在 spawn 的环境里找不到原因是spawn默认继承了 Node 进程的环境变量而 Node 进程如果是 IDE 启动的环境的 PATH 是精简过的。问题二日志把 stdout 污染了客户端 JSON 解析报错。这个前面说过解决方法是日志一律走stderr。但还有一个更隐蔽的变体第三方库内部调用了console.log。比如某个日志库默认输出到 stdout你没注意结果线上数据流里时不时混进一条日志。排查方法是在客户端侧对收到的每一行做个“是否为合法 JSON”的检查非法就打印出来。不要默默丢弃因为那可能是重要的错误线索。问题三工具执行时间长客户端等不及就超时了。stdio 模式没有网络超时通常是客户端自己设置的超时时间。我建议把超时设成可配置的默认 30 秒但对耗时工具单独调大。另外工具如果可能执行很久服务端应该在工具内部发进度通知notifications/tools/call_progress这样客户端知道工具还活着而不是傻等。4.2 SSE 模式的高频问题问题一直接报错 stream disconnected before completion: idle timeout waiting for sse。这个报错我一度被折磨到怀疑人生后来才弄明白。SSE 连接建立后如果一段时间内没有任何数据从服务器流向客户端中间的网络设备Nginx、云负载均衡器就会认为连接空闲主动断开。这里的“空闲”指的是没有字节流动不是“没有消息”。解决方法是加心跳。SSE 协议支持注释行就是不产生事件的空消息: keepalive服务器每隔 20 到 30 秒往 SSE 流里写一行这样的注释连接就有持续的字节流动代理就不会判定空闲。Nginx 侧的proxy_read_timeout也要调大默认 60 秒对 LLM 流式输出来说太短了。我当时在开发环境用ngrok穿透内网服务这个问题尤其明显因为隧道服务会在更短的空闲时间内断连。问题二会话丢失POST 消息 404。场景是客户端先GET /sse拿到了endpoint事件的 sessionId但后续 POST 请求没有带Mcp-Session-Id头或者服务器重启后 session 表清空了。我的服务端用 Map 存 session进程一重启就全没了。这在本地开发时还好部署到生产就必须把 session 存到 Redis并做心跳续期。还有个连环坑SSE 连接断开后 session 要不要删。如果你的 POST 请求已经入队、任务正在执行这时客户端断开了 SSE任务结果就推不出去了。我的做法是 session 删除前检查是否有在途任务有的话等任务完成再删或者把结果缓存起来等客户端重连后补推。问题三跨域和鉴权。SSE 是 EventSource 的默认能力但 EventSource 不能自定义请求头这对带 token 的鉴权极其不友好。工作区里有Authorization头的请求我建议直接用fetchReadableStream自己解析 SSE而不是用EventSource。这样既能带请求头还能用AbortController控制中断。服务端要注意 CORS 配置Access-Control-Allow-Origin和Access-Control-Allow-Headers都要显式放开。4.3 排查与调试技巧最后分享一些排查工具和方法这些帮我省了无数时间。第一做一个极简的 MCP 调试客户端。不用写完整 Agent就用 Node 脚本发一条initialize、一条tools/list能跑通再开始折腾 Agent 循环。这能把问题快速定位到“协议层”还是“业务层”。我甚至给调试客户端加了一个--transport stdio|sse参数同一套业务代码在两个传输间来回切换对比行为差异。第二善用curl测 SSE。很多时候排查 SSE 问题服务端是不是正常一测便知curl -N http://localhost:3000/sse-N参数禁用缓冲让输出实时显示。如果curl能看到endpoint事件说明服务端 SSE 基本正常问题更可能在客户端解析或代理层。第三记录所有的链路日志并按 requestId 串联。我踩过最深的坑之一就是工具调用链路的排查。一次工具调用会经历“Agent → MCP Client → MCP Server → 工具实现”四层每一层都可能出错没有日志串联根本无从查起。我在request()方法里给每个请求生成一个内部requestId所有日志都带上它排查时直接 grep 这个 ID整条链路一目了然。第四把标准问题整理成速查表。经验多了之后我整理了一张常见问题表贴在自己的笔记里这里也分享给读者现象可能原因解决方案initialize 请求无响应子进程 PATH 不完整 / 日志污染 stdout排查 spawn 环境日志改走 stderr工具调用超时工具执行过长客户端超时设置太短设置可配置超时增加进度通知SSE 连接被断开网络代理空闲超时服务端加注释心跳调大代理 read_timeoutPOST 报 404session 不存在或已过期检查 Mcp-Session-Id 头session 持久化消息串线响应 id 与请求未正确关联用 pending Map 按 id 路由响应并发调用乱序服务端异步处理但未按 id 返回客户端强制按 id 匹配不依赖顺序4.4 关于两种模式选型的一点心得可以根据场景快速判断该用哪种工具跑在本地、对延迟敏感、需要访问本地文件或执行命令选 stdio省掉网络层的所有麻烦工具是远程服务、要供多个 Agent 共享、或者 Agent 本身在云上选 SSE接受网络带来的复杂度。还有一种混合用法本地写一个 stdio 服务端外面套一层 SSE 网关层做转发既保留本地工具的体验又暴露给远程调用。我在实际写代码时的体会是传输层其实只占整个项目的一小部分真正的复杂度集中在请求路由、并发控制、会话管理和错误处理上。这也提醒我不要过度迷恋“手写”这件事——如果你的目标不是学习协议细节而是快速上线直接使用官方的 SDK 绝对更划算。手写一遍的意义在于它迫使你把协议规范从头到尾读一遍把那些“为什么会有这个规则”都搞清楚。以后再遇到 SDK 的底层 bug你不会一脸懵而是能直接看出来问题出在哪一层。