ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MCP双模服务从零实现:stdio与Streamable HTTP完整方案

MCP双模服务从零实现:stdio与Streamable HTTP完整方案 最近大模型应用的圈子几乎被一个缩写刷屏——MCP。上个月我接了一个内部需求把一个工具服务暴露给团队里的AI客户端使用。第一次我只做了stdio模式本地跑得顺顺利利工具在Claude Desktop里也能正常调用。结果同事在另一台服务器上想远程连这个服务才发现stdio模式根本够不着只能紧急补了一套HTTP接口。绕了一圈之后我干脆把两个模式都写进了同一个服务本地用stdio远程用Streamable HTTP一条启动参数切换工具逻辑一行都不用复制。这篇文章就是我从零搭建这个双模MCP服务时沉淀下来的完整方案包含协议层面的关键拆解、可以直接抄的代码以及调试过程中踩过的几个真坑。这篇记录适合谁看如果你正准备把自己的工具、API、内部服务改造成MCP服务或者已经实现了一个MCP服务但是只支持stdio、想补上HTTP能力又或者你只是想把MCP的两种传输模式彻底搞明白那这篇应该能帮你省不少时间。下面要写的全部内容都基于我亲手跑通的代码不是纯理论每一步都有产出。1. 先把传输层的账算清楚stdio 和 Streamable HTTP 各自解决了什么问题1.1 MCP协议到底在做什么为什么会有两种通道MCP全称Model Context Protocol模型上下文协议。很多人把它比喻成AI应用界的USB-C这个类比很形象AI客户端只要实现了MCP客户端的能力就能通过统一协议接入各种工具和数据源不用给每个工具写一套专属集成。但这个类比只解释了一半另一半是传输层——MCP协议本身基于JSON-RPC 2.0客户端和服务端通过交换JSON消息来完成握手initialize、发现能力tools/list、调用工具tools/call。真正决定部署方式的是这些JSON消息在两端之间走什么管道。MCP规范把这个管道抽象成transport传输层。官方目前主推两种stdio和Streamable HTTP。早期还有一种基于HTTPSSE的老式transport因为端点设计太别扭客户端要先连/sse端点拿消息地址再往/message端点回传请求一来一回太麻烦后来被Streamable HTTP取代了现在新项目基本不需要回头看它。1.2 stdio模式本地进程间的轻量通道stdio指的是标准输入输出。MCP服务被作为子进程启动客户端把自己的stdin、stdout和子进程连起来服务从stdin读取JSON-RPC消息往stdout写入JSON-RPC消息。整个过程不涉及端口、不涉及网络、不涉及CORS。这个模式的优势非常明显零网络开销不需要处理鉴权、跨域、超时客户端进程和服务进程在同一条生命周期线上客户端退出子进程一般也就结束了。典型场景是本地桌面工具比如Claude Desktop、各种本地知识库应用、IDE插件它们都是直接用命令拉起一个MCP服务进程。缺点是它天生绑定在客户端本机无法远程调用同时服务进程拥有本地文件系统权限工具能做什么完全取决于服务代码本身所以如果你要暴露的文件操作类工具比较敏感要自己想清楚信任边界。1.3 Streamable HTTP模式把MCP服务变成真正的网络服务Streamable HTTP用HTTP作为载体通常部署在一个/mcp端点上。客户端通过POST发送JSON-RPC请求服务端返回JSON当服务端需要主动推送数据时比如任务进度、长耗时操作的中间结果通过GET建立一条SSEServer-Sent Events流往下推。如果所有工具都是快速返回的纯POST也能工作Streamable的意思是按需流式而不是必须流式。相比stdioStreamable HTTP把MCP服务彻底网络化了它可以部署在独立服务器、容器、K8s里多个客户端可以同时连同一个服务也可以跟现有的网关、鉴权、限流、监控体系对接。代价也很明确——你要开始处理HTTP层的一堆问题CORS、会话管理、超时、并发、SSE连接保活、反向代理配置。这些在stdio里根本不存在。1.4 双模各自适合什么场景我把两种模式的关键差异整理成一张表方便你在设计自己的服务时做取舍。维度StdioStreamable HTTP通信机制客户端拉起子进程通过stdin/stdout交换JSON-RPCHTTP SSE通常暴露一个/mcp端点部署方式跟随客户端进程本地安装独立服务可远程、可容器化网络需求无本机进程间通信需要网络可达需要考虑代理、防火墙会话管理进程级会话天然隔离不需要额外管理服务端必须维护sessionId和管理会话生命周期典型场景Claude Desktop、本地CLI、IDE插件、本地知识库团队共享工具、Web应用、Agent平台、跨团队集成维护成本很低几乎没有HTTP负担需要处理CORS、鉴权、超时、连接保活安全边界谁启动进程谁控制工具的权限边界服务暴露在网络上鉴权和限流必须跟上一句话总结进程内能解决的用stdio跨机器必须用Streamable HTTP。成熟的服务没必要二选一两个都支持就能同时覆盖本地客户端和远程客户端这就是我们后面要做的双模。2. 技术选型与项目骨架为什么我选择了TypeScript 官方SDK2.1 选型理由MCP服务可以手写但没有必要。官方提供了Python和TypeScript/JavaScript的SDK第三方也有一些封装比如Python生态的fastmcp。我最终选TypeScript 官方SDK主要基于三点。第一官方TS SDK对Streamable HTTP的支持最完整Stdio和StreamableHTTP都实现了同一套Transport接口写双模的时候代码结构非常自然。第二TS的类型系统可以直接让你看到JSON-RPC消息的形态、Tool定义的结构、Transport接口的契约对理解协议很有帮助。第三Node生态里调试和部署最省事MCP Inspector这种官方调试工具就是为Node场景准备的。Python的fastmcp也不错写起来很简洁但如果你想做双模、又不想自己维护太多传输层胶水TS官方SDK会少走很多弯路。手写JSON-RPC解析这种事除非你想深入协议源码否则我劝你交给SDK它把initialize握手、协议版本协商、错误码、通知这些细节都封装好了你的精力应该花在业务工具上。等你真正理解了SDK的行为再去看协议文本会发现很多困惑迎刃而解。2.2 初始化项目与依赖安装先建一个项目目录我命名为mcp-dev-toolbox整篇文章的示例代码都基于这个服务。npm init -y npm install modelcontextprotocol/sdk express cors npm install -D typescript types/node types/express types/cors tsx npx tsc --init依赖拆开说modelcontextprotocol/sdkAnthropic官方的MCP SDK提供McpServer、StdioServerTransport、StreamableHTTPServerTransport。express承载Streamable HTTP模式的端点处理JSON请求体、路由、中间件。cors处理浏览器客户端的跨域问题。如果你的客户端全是非浏览器场景这个可以省但加上没坏处。tsx开发时直接跑TypeScript文件省去先编译再运行的步骤。typescript全家桶类型检查、编译产物。tsconfig.json里需要重点注意两个配置不然ESM模块导入会出问题。{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: dist, rootDir: src, strict: true, esModuleInterop: true, skipLibCheck: true } }这里有个常见坑SDK的包导入路径是带.js后缀的比如modelcontextprotocol/sdk/server/mcp.js。如果moduleResolution设置不对TS会提示找不到模块。用NodeNext模式配合ESM项目就可以绕开这个问题。2.3 项目目录结构我在动手写代码前先把目录拆好让每个文件的职责单一化这也是后面双模能合而不乱的关键。mcp-dev-toolbox/ ├── src/ │ ├── tools.ts // 工具业务函数纯逻辑不感知MCP │ ├── server.ts // 创建McpServer实例注册工具 │ ├── stdio.ts // stdio模式入口 │ ├── http.ts // Streamable HTTP模式入口 │ └── index.ts // 统一入口根据参数选择模式 ├── package.json └── tsconfig.jsontools.ts只放业务函数server.ts负责把业务函数注册进McpServerstdio.ts和http.ts只关心transport层的接线index.ts做启动分发。每一层都不需要关心其他层的细节。2.4 演示工具的选择我设计了一个开发者工具箱服务包含三个纯函数工具Base64编码、JSON格式化、服务器时间。选这些工具的原因很简单无状态、可复现、不依赖第三方网络服务读者不需要申请任何API key就能跑通整个流程。如果你的服务需要访问外部API业务逻辑同样封装在tools.ts的函数里就行MCP这部分是完全一样的。不过我特意避开了计算器这种需要解析表达式的工具因为在示例里引入safe eval会牵扯到JavaScript安全执行的问题容易把教程带偏。Base64和JSON格式化这种纯函数工具逻辑一行就能看懂注意力可以完全放在MCP机制本身上。3. 注册工具与Schema定义让AI客户端看得懂你的服务3.1 McpServer的创建与工具注册server.ts是整条链路的核心创建McpServer实例并注册工具的代码都在这里。// src/server.ts import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { encodeBase64, prettyJson, getServerTime } from ./tools.js; export function createServer(): McpServer { const server new McpServer({ name: dev-toolbox, version: 1.0.0, }); server.tool( base64_encode, 把一段UTF-8文本编码为Base64字符串。适用于传输二进制数据、构建数据URI等场景。, { text: { type: string, description: 要编码的原始文本 } }, async ({ text }) { return { content: [{ type: text, text: encodeBase64(text) }] }; } ); server.tool( json_pretty, 把压缩或杂乱的JSON字符串格式化为易读的缩进格式。如果输入不是合法JSON会返回错误信息。, { json: { type: string, description: 任意合法的JSON字符串 } }, async ({ json }) { try { return { content: [{ type: text, text: prettyJson(json) }] }; } catch (e) { return { content: [{ type: text, text: JSON解析失败: ${(e as Error).message} }], isError: true, }; } } ); server.tool( get_server_time, 获取服务器当前的本地时间。, {}, async () { return { content: [{ type: text, text: getServerTime() }] }; } ); return server; }tools.ts里的实现很简单// src/tools.ts export function encodeBase64(text: string): string { return Buffer.from(text, utf8).toString(base64); } export function prettyJson(input: string): string { const parsed JSON.parse(input); return JSON.stringify(parsed, null, 2); } export function getServerTime(): string { return new Date().toLocaleString(zh-CN, { timeZone: Asia/Shanghai }); }注意一个细节server.tool()的第三个参数接收的是JSON Schema对象。MCP协议中工具的参数定义就是JSON Schema大模型客户端在调用工具前会把inputSchema和工具描述一起交给模型让模型决定这个任务该调哪个工具、参数应该传什么。SDK内部会把JSON Schema转成zod做运行时校验但对外暴露给客户端的始终是JSON Schema。3.2 工具描述与参数Schema的重要性这是我踩过坑之后最想强调的一点。很多人写MCP工具description就一句话Base64 encode a string参数description干脆不写。结果大模型在调用时经常会传错——把一段Base64字符串丢进来试图解码或者把json参数传成一个JSON对象而不是字符串。原因其实很好理解模型是靠描述来理解工具语义的描述不清楚模型只能瞎猜。描述字段的写法有讲究要往给模型看这个方向写而不是给人看。具体来说包含三点这个工具是干什么的什么场景下适合用。参数的含义、类型、格式最好带上示例值。什么情况下会失败失败时返回什么样的错误信息。比如我们上面的json_pretty描述就写明了如果输入不是合法JSON会返回错误信息当模型拿到非JSON输入时它可以预判到结果形态并且向用户解释。工具描述是你和大模型之间唯一的沟通桥梁投入产出比极高值得多花几分钟打磨。3.3 错误返回的约定仔细看json_pretty的回调我用了isError: true来标记错误返回。这是MCP规范里的一个关键约定工具调用失败时返回值可以带isError标记。如果不带这个标记客户端会认为工具调用成功了只是返回了一段普通文本带上之后模型知道工具执行失败了它会主动向用户说明情况或者尝试调整参数再调一次。错误信息的内容也有讲究。别只丢一个抛异常堆栈给模型要在文本里包含为什么失败、大概怎么修正的提示。因为模型看到错误后最合理的动作就是尝试修复后重试你给它足够信息它才能真正做对。3.4 工具数量大了怎么组织如果你的服务里工具数量超过二十个不建议在server.ts里一行行写server.tool()声明。可以维护一个工具定义数组每个元素包含name、description、schema、handler然后循环注册。这样便于统一加权限校验、开关控制也方便做批量测试。McpServer除了tool()之外还有prompt()和resource()分别对应MCP协议的提示词能力和资源能力。如果服务的主要价值是提供数据而不是执行操作resource会更合适。但从实际接入情况看绝大多数MCP客户端第一时间用到的都是tools建议先把tool链路吃透再研究另外两个。4. stdio模式落地本地客户端的搬运工4.1 连接代码stdio模式真正跑起来的代码少得让人意外一个函数就够了。// src/stdio.ts import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { createServer } from ./server.js; export async function startStdio() { const server createServer(); const transport new StdioServerTransport(); await server.connect(transport); }connect()之后SDK会自动开始从stdin读取消息把服务端输出写进stdout。整个进程的生命周期由客户端控制客户端退出服务跟着退出。4.2 最大的坑stdout被日志污染stdio模式下stdout是协议通道不是日志输出通道。这句话我遇到太多人栽过跟头了。调试的时候顺手写一句console.log(server started)日志直接混进stdout客户端的JSON-RPC解析器直接被污染轻则解析失败重则把日志当成消息处理现象非常诡异。解决办法就一条日志一律走stderr。console.error、console.warn都可以它们和stdout是两条独立管道不影响协议通信。如果你用了pino这类日志框架一定要显式把输出流配置为process.stderr。我自己的习惯是写一个简单的logger模块统一封装log.info、log.error内部固定输出到stderr。这样不管以后切换到HTTP模式还是别的传输模式日志行为都是一致的。4.3 用MCP Inspector做冒烟测试MCP官方提供的Inspector是调试MCP服务的神器。它是一个Web UI可以连接本地的stdio服务查看工具列表、手动调用工具、查看协议消息日志。启动命令npx modelcontextprotocol/inspector node dist/stdio.js如果开发阶段用tsx直接跑源码npx modelcontextprotocol/inspector npx tsx src/stdio.tsInspector会弹出一个网页左边选择Transport Type为STDIO右边会自动生成带参命令。点击连接后你可以在界面里看到tools/list返回的工具列表然后逐个选工具填参数手调。这一步虽然简单但它是检验MCP服务是否正常的最快路径。协议层的握手问题、Schema问题、返回值格式问题在Inspector界面里一眼就能看到。4.4 接入Claude Desktop等本地客户端以Claude Desktop为例在配置文件claude_desktop_config.json里加一段{ mcpServers: { dev-toolbox: { command: node, args: [dist/stdio.js] } } }换一个客户端配置字段可能略有差异但本质都一样客户端用指定的命令拉起一个子进程然后通过stdio和它通信。这里有个实际经验图形界面客户端启动时的PATH环境变量经常不包含Node.js的安装目录尤其Windows系统。如果你配置了command: node却连接失败客户端日志往往没有任何提示其实是因为它根本找不到node命令。绕坑的办法是把node写成绝对路径比如C:\Program Files\nodejs\node.exe或者把node目录加进系统PATH并重启客户端。这个坑我踩过一次排查了一个多小时最后发现是环境变量问题。5. Streamable HTTP模式落地让远程客户端连上来5.1 一个/mcp端点承载三种语义Streamable HTTP模式的核心是一个端点通常命名为/mcp需要同时处理GET、POST、DELETE三种请求。GET客户端要建立SSE流。如果是新会话服务端会创建会话ID并通过响应头的mcp-session-id返回给客户端如果是已有会话复用连接并返回SSE流。POST客户端发送JSON-RPC消息服务端处理并返回结果。结果既可以是纯JSON也可以是SSE流取决于请求的Accept头和业务是否需要流式推送。DELETE关闭会话释放服务端资源。为什么必须有SSE这一路因为JSON-RPC的响应不一定总在请求后立刻返回。Long-running任务、进度通知、服务端主动推送这些都需要一条服务端到客户端的通道。在HTTP/1.1下服务端不能主动往已有的连接里推数据SSE是用得最顺的通道。如果工具都是快速返回的纯POST就够。5.2 会话管理是要害HTTP是无状态的但MCP协议是有状态的客户端必须initialize成功之后才能调用tools/call同一个会话内的SSE推送必须关联到正确的会话。所以你必须用mcp-session-id把状态串起来。官方SDK的StreamableHTTPServerTransport内置了会话状态管理它创建时生成sessionId处理请求时维护内部状态。但transport对象本身不是全局的多会话场景下你要在应用层维护一张sessionId到transport的映射表。http.ts的完整实现// src/http.ts import express from express; import cors from cors; import { randomUUID } from node:crypto; import { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; import { createServer } from ./server.js; import type { McpServer } from modelcontextprotocol/sdk/server/mcp.js; interface Session { server: McpServer; transport: StreamableHTTPServerTransport; lastActive: number; } const sessions new Mapstring, Session(); export function startHttp(port 3000) { const app express(); app.use(cors()); app.use(express.json({ limit: 1mb })); function findSession(req: express.Request) { const sessionId req.headers[mcp-session-id]; return typeof sessionId string ? sessions.get(sessionId) : undefined; } app.post(/mcp, async (req, res) { const existing findSession(req); if (existing) { existing.lastActive Date.now(); await existing.transport.handleRequest(req, res, req.body); return; } const server createServer(); const transport new StreamableHTTPServerTransport({ sessionIdGenerator: () randomUUID(), }); sessions.set(transport.sessionId, { server, transport, lastActive: Date.now() }); transport.onclose () sessions.delete(transport.sessionId); await server.connect(transport); await transport.handleRequest(req, res, req.body); }); app.get(/mcp, async (req, res) { const existing findSession(req); if (!existing) { res.status(400).json({ jsonrpc: 2.0, error: { code: -32000, message: No session found }, id: null, }); return; } existing.lastActive Date.now(); await existing.transport.handleRequest(req, res); }); app.delete(/mcp, async (req, res) { const existing findSession(req); if (!existing) { res.status(400).json({ jsonrpc: 2.0, error: { code: -32000, message: No session found }, id: null, }); return; } await existing.transport.handleRequest(req, res); }); app.listen(port, () console.error([http] MCP server listening on :${port})); }这里有几个设计选择要说清楚。第一每个新会话都createServer()一份新的McpServer实例。为什么要这样MCP的状态机是否已完成initialize、会话级通知、初始化参数是绑定在server实例里的。如果所有会话共享同一个serverSDK在接收消息时无法区分这条消息属于哪个session状态会串。每个session独立实例最稳妥。代价是内存开销大一点但对大多数工具服务来说无所谓。如果工具内部持有重型连接池可以在createServer里传入共享的底层客户端句柄但server实例本身还是按会话建。第二POST不带mcp-session-id也可能是两种情形新会话第一次连接或者客户端把sessionId丢了。规范要求服务端在第一次连接时创建会话并返回sessionId所以这里在找不到existing时就新建会话。客户端如果已经初始化过但丢了ID会拿到一个新的会话重新走一次initialize这个宽容策略能减少客户端闪断带来的体验问题。第三transport.onclose回调里删除map记录必要但容易漏。如果忘了删每一个断开的会话都会在Map里留一个永远不释放的对象跑一晚上内存就肉眼可见地涨上去了。5.3 客户端首次连接的完整过程一个典型的Streamable HTTP握手流程是这样的客户端POST /mcp发起initialize请求。服务端返回JSON-RPC响应并在响应头带上mcp-session-id。客户端发送notifications/initialized通知表示初始化完成。客户端POST /mcp带tools/list请求。服务端返回工具列表。客户端POST /mcp带tools/call请求。服务端处理工具调用返回结果。用curl来模拟就是下面这样。先初始化并拿sessionIdcurl -i -X POST http://localhost:3000/mcp \ -H Content-Type: application/json \ -H Accept: application/json, text/event-stream \ -d {jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2025-06-18,capabilities:{},clientInfo:{name:curl-client,version:0.1.0}}}响应大概是HTTP/1.1 200 OK Content-Type: application/json mcp-session-id: 550e8400-e29b-41d4-a716-446655440000 {jsonrpc:2.0,id:1,result:{protocolVersion:2025-06-18,capabilities:{tools:{}},serverInfo:{name:dev-toolbox,version:1.0.0}}}注意Accept头我写的是application/json, text/event-stream。这是Streamable HTTP的一个细节客户端必须声明自己能接受哪些响应类型。如果只写application/json服务端即使想返回流式事件也会选择返回JSON。再带sessionId查工具列表curl -X POST http://localhost:3000/mcp \ -H Content-Type: application/json \ -H mcp-session-id: 550e8400-e29b-41d4-a716-446655440000 \ -d {jsonrpc:2.0,id:2,method:tools/list}协议版本协商也值得提一下。MCP目前有几个协议版本号比如2024-11-05、2025-03-26、2025-06-18。客户端在initialize时带上自己支持的版本服务端会返回自己选用的版本。如果你手里的SDK比较老客户端请求了新版协议服务端会主动回退到自己支持的版本。所以服务端代码里一般不需要写死版本SDK已经帮你处理了。5.4 CORS、鉴权和反向代理CORS问题只在浏览器客户端里出现。如果你服务的调用方是Node进程、Python脚本、curl没有跨域约束。但Web应用里的MCP客户端就有跨域问题。开发阶段直接app.use(cors())放开全部来源生产环境一定要限制成白名单不然任何网站都可以从浏览器发起跨域请求。鉴权方面MCP规范本身没有定义HTTP层的安全机制。实践经验里常见的有几种Bearer Token中间件最简单直接在express里加一层校验。网关统一做API Key、IP白名单、配额管理。对敏感工具加操作审计和审批流。如果服务部署在公网我强烈建议至少在服务内加一个简单的token校验中间件后续再交给网关统一打理。反向代理也是个高频坑。如果前面挂着nginxSSE连接默认会被nginx缓冲掉导致客户端收不到实时事件。需要在nginx配置里关掉缓冲location /mcp { proxy_pass http://127.0.0.1:3000; proxy_buffering off; proxy_read_timeout 3600s; proxy_set_header Connection ; proxy_http_version 1.1; }proxy_buffering off是关键否则SSE数据会积压在nginx层。proxy_read_timeout也要调大否则一个长时间空闲的SSE连接会被nginx掐断。5.5 流式输出与长任务场景热搜里有个词是使用MCP工具流式输出内容到文件这正好是Streamable HTTP的优势场景。如果工具执行是比较耗时的任务比如批量处理大文件、调用外部慢接口你可以在工具回调里通过SDK发送progress notification进度事件走SSE通道推给客户端。客户端拿到增量数据后自己决定是写到文件还是实时展示。不过要多说一句MCP目前的工具调用模式结果还是要放在最终返回里的细粒度的工具结果流式返回在协议层面还没有完全开放。如果产品需求是大模型工具执行过程中边跑边出结果最稳妥的做法是服务端通过SSE推送notification客户端监听事件做增量处理最终结果仍然走tools/call的返回。别把notification当成替代tools/call响应的机制这是规范边界。6. 双模合一Transport抽象与单入口切换6.1 核心思路同一套server不同的transport新手做双模最容易写出两份代码一份stdio服务文件、一份HTTP服务文件工具逻辑复制两遍。这是最差的做法后面任何工具改动你都要同步两次早晚出错。MCP SDK之所以把StdioServerTransport和StreamableHTTPServerTransport都叫做Transport是因为它们实现了同一套契约负责把JSON-RPC消息从物理通道上收上来、再发回去。McpServer看到的是抽象的transport不关心底层是进程管道还是HTTP连接。所以我们要做的只是把createServer()的结果分别交给两种transport业务工具逻辑保持唯一来源。6.2 统一入口代码index.ts负责按参数选启动方式// src/index.ts import { startStdio } from ./stdio.js; import { startHttp } from ./http.js; async function main() { const mode process.argv[2] ?? process.env.MCP_MODE ?? stdio; if (mode http) { const port Number(process.env.PORT ?? 3000); startHttp(port); } else if (mode stdio) { await startStdio(); } else { console.error(Unknown mode: ${mode}); process.exit(1); } } main().catch((e) { console.error(e); process.exit(1); });这样同一份编译产物有两种启动方式node dist/index.js # 默认走stdio node dist/index.js http # 启动Streamable HTTP MCP_MODEhttp PORT8080 node dist/index.js # 用环境变量控制我个人之所以argv和环境变量都支持是因为argv适合本地开发时手动切环境变量适合部署在容器里时用编排系统通过env指定两种场景都有需要。6.3 为什么是一种服务的两种接线方式把MCP服务理解成协议端点想问题会非常清晰McpServer是处理器所有的工具定义、参数校验、响应组装都在这层transport是接线适配器负责把JSON-RPC消息送到处理器再原样把处理结果送回去。stdio是进程内管道这根线HTTP是网络连接这根线。换了线不换处理器。这个抽象带来的直接好处工具逻辑、Schema、错误处理只写一份。未来出现新传输方式只需再实现一个transport类。测试可以复用tools.ts直接用单测覆盖server层可以用SDK的InMemoryTransport跑内存测试HTTP层再跑端到端。6.4 双模联调的小建议我建议的测试顺序是先stdio后HTTP。stdio模式下协议问题最容易暴露先在Inspector里把工具列表和调用全部跑通再启动HTTP模式用Inspector的HTTP连接指向http://localhost:3000/mcp。如果你要让两个MCP服务在内存里互相对话SDK提供了InMemoryTransport它可以在不实际开端口的情况下模拟两端通信非常适合写自动化测试。这类细节在官方SDK的example目录都能找到现成代码。7. 端到端调试的完整链路与高频踩坑7.1 我的调试路线整个服务写完我建议按下面的顺序做端到端验证。这个顺序的核心原则是先用最简单的环境把协议层问题排除干净再逐步引入网络层复杂度。第一步stdio冒烟npx modelcontextprotocol/inspector node dist/index.js连接后看工具列表调用三个工具确认返回内容正确。这一步通过说明server注册和工具逻辑没有问题。第二步HTTP冒烟node dist/index.js http用Inspector选择Streamable HTTP传输类型URL填http://localhost:3000/mcp连接并重复工具调用。这一步通过说明Streamable HTTP握手、会话管理、响应封装没有问题。第三步curl手动握手按照5.3节的方式手动POST initialize、GET SSE、POST tools/list把整个协议流程在裸命令层面再看一遍。这一步能帮你彻底理解每个请求和响应头以后排查线上问题心里有底。第四步接真实客户端Claude Desktop或者你实际要接入的MCP客户端走一遍用户视角的完整流程。7.2 高频踩坑对照表这里是我实际调试过程中遇到过、以及群里朋友问过最多的问题整理成一张表。现象原因解法stdio客户端连接后一直无响应日志打到了stdout污染了协议通道所有日志显式输出到stderrHTTP模式下POST返回404sessionId没有正确传递服务端找不到映射检查请求头mcp-session-id是否带上且一致initialize成功但调tools/list报server not initialized客户端没有发送notifications/initialized通知按握手顺序发通知SDK客户端一般自动处理SSE连接建立后很快断开反向代理缓冲或read timeout太小nginx关闭proxy_buffering调大proxy_read_timeout浏览器客户端访问失败CORS未放开配置cors中间件生产环境限制白名单工具执行失败但客户端显示成功回调只返回文本没加isError标记try/catch包裹工具逻辑失败时返回isError: trueGET /mcp返回400 No session found客户端未先通过POST建立session就尝试建立SSE流正确客户端不会这样curl测试时注意先POST拿sessionId内存缓慢增长会话Map没有清理在transport.onclose里删除session映射增加lastActive超时回收mcp-session-id的大小写不用纠结HTTP header本身不区分大小写mcp-session-id、MCP-Session-Id都能被服务端正确识别。如果是在代理层看到header莫名消失那是代理配置的问题需要单独排查。7.3 日志与可观测性HTTP模式下的日志策略和stdio完全不同。stdio的日志是给自己调试用的HTTP的日志是给别人排查用的。建议记录的内容至少有sessionId按会话维度排查问题。工具名按工具维度看调用频率和失败率。每次调用的耗时、结果大小。SSE连接建立和断开事件。会话初始化失败、鉴权失败等异常事件。日志输出到stderr或者日志文件都行用结构化日志更好方便采集到ELK或者Loki里做聚合。既然服务已经是一个网络服务了就意味着会同时被很多客户端用没有日志辅助定位出了事你会有种大海捞针的绝望感。7.4 生产化清单如果双模服务不是只在本地跑给自己看下面这些我建议尽早补齐鉴权在express加Bearer Token中间件或者交给网关。限流至少对POST /mcp做限流防止被恶意刷接口。工具执行超时给每个工具加一个最大执行时间超时直接返回错误防止进程被长耗时调用拖死。会话回收给Session对象加lastActive字段超过15分钟没有请求就主动调DELETE并清理Map。进程守护Node进程用PM2或者容器编排托管。健康检查暴露一个/healthz端点返回存活状态方便负载均衡探测。配置管理端口、时区、CORS白名单、鉴权token全部走环境变量。这些都是常规的HTTP服务防护手段但很多MCP新手只把它当工具服务忽略掉了。一旦暴露到公网这些问题全都会反咬一口。最后说点我自己的体会。MCP协议本身不复杂真正难的是以服务提供者的视角把细节想周全。工具定义要让模型看得懂传输模式要符合使用场景会话和鉴权要为长期运行负责。我第一次只做stdio的时候觉得MCP真简单后来补HTTP模式时才发现网络层的坑一个都不少。现在回过头看双模并没有让代码变复杂它只是逼你把业务工具和传输方式这两件事彻底分开而分开本身就是让代码变健康的过程。如果这篇文章能帮你少踩几个坑那这份经验就值了。
返回列表