
第一次把MCP服务跑通的时候我用的是Stdio传输客户端直接拉起一个子进程消息从标准输入进去结果从标准输出出来整个过程像两个人通过一根管子递纸条。这套流程本地开发确实很爽配置简单、没有网络端口、不用考虑鉴权但一旦你想把同一个服务部署到远程、供多个MCP客户端共用Stdio立刻捉襟见肘——你不可能让每个远端调用都临时spawn一个进程。这时候就该Streamable HTTP出场了。这篇教程就是围绕“同一套工具逻辑同时支持Stdio和Streamable HTTP两种传输入口”展开的。适合两类人一是写过MCP服务但只在本地Stdio里跑通、想把它搬到服务器上的开发者二是刚接触MCP、想搞清楚两种传输模式到底怎么选怎么写的朋友。我会从工程结构讲起把核心代码、验证链路、真实踩坑全部拆开讲保证你照着做也能从零构建一个双模MCP服务。1. 先搞清楚双模到底解决什么问题1.1 两种传输的定位差异MCPModel Context Protocol解决的核心问题是让AI模型通过一套标准协议去调用外部工具和数据源。而“传输层”解决的是更底层的问题——协议消息怎么从客户端跑到服务端。Stdio传输的本质是进程间通信。MCP客户端比如桌面应用、IDE插件在你的机器上直接启动一个子进程把MCP服务跑起来然后通过进程的标准输入和标准输出传递JSON-RPC消息。它的特点是零网络配置没有端口、没有IP、没有防火墙进程生命周期由客户端管理关掉客户端进程自然结束天然安全文件系统权限、网络权限都局限于当前用户环境一个服务进程只能服务一个客户端因为stdin/stdout是一对一的管子Streamable HTTP传输则是把MCP服务做成一个常驻的HTTP端点。客户端通过HTTP POST发送请求服务端除了在POST响应里返回结果还可以通过SSEServer-Sent Events流主动向客户端推送消息。它的特点是可以部署到服务器远程访问一个服务实例可以同时服务多个客户端需要自己处理鉴权、CORS、超时、限流会话管理从“进程生命周期”变成了“协议层会话”维度StdioStreamable HTTP部署位置本机子进程远程服务器多客户端不支持一对一支持一对多安全边界进程边界依赖应用层网络开销无有需考虑延迟适用场景本地开发、私人工具团队共享、线上服务1.2 为什么双模是最舒服的开发部署组合很多人会问那我到底该用哪种我的答案很直接开发时用Stdio发布时用HTTP二者可以共存。开发阶段你每分钟都在改代码用Stdio跑起来最快改完重启一个进程就是完整的调试链路。MCP官方调试工具对Stdio模式的支持也最成熟断点、日志、协议消息一览无余。等工具稳定了你想把它共享给团队其他成员或者部署到一台服务器上供多个客户端调用这时启动HTTP模式同一个进程、同一套工具逻辑立刻暴露成一个可远程访问的端点。不需要为部署重写业务代码。这就是双模服务的核心价值业务逻辑只写一遍传输层做成可插拔的开关。1.3 双模架构背后的三条原则我做完这个项目后总结出三条原则对任何MCP服务设计都适用第一工具注册与传输细节分离。工具的定义、参数Schema、执行函数是一层传输方式是另一层。前者是“做什么”后者是“怎么把消息送进来”。两者一旦耦合后面加新传输方式会让你想删库跑路。第二入口做模式分发而不是分成两个项目。不要把Stdio版和HTTP版拆成两个代码仓库那样工具逻辑漂移只是时间问题。一个入口根据环境变量选择传输模式干净利落。第三协议层的差异交给SDK业务层别碰协议细节。初始化握手、协议版本协商、JSON-RPC消息封装、SSE流管理这些都应该由MCP SDK处理。你只需要关心工具执行函数怎么写以及返回的数据结构是否合规。2. 工程结构先行依赖、目录与模式切换2.1 技术选型为什么走TypeScript官方SDKMCP官方提供TypeScript和Python两套SDK我的建议是除非你团队全员Python否则优先TypeScript。原因很简单官方TypeScript SDK的更新节奏最快类型定义最完整生态里各种调试工具、示例项目基本以TS为主。核心依赖清单{ dependencies: { modelcontextprotocol/sdk: ^1.x, express: ^4.x, zod: ^3.x }, devDependencies: { typescript: ^5.x, tsx: ^4.x, types/express: ^4.x } }选Express而不是Fastify或Koa是因为MCP官方示例和SDK内部代码基本都围绕Express的req/res类型编写接入成本最低。zod用来定义工具输入参数Schema它不仅能做运行时校验还能自动推导类型和MCP SDK的tool()方法配合得非常顺。2.2 目录结构与统一Server实例工程结构直接决定你后面维护的幸福感。我推荐这样一个目录flight-status-server/ ├── src/ │ ├── index.ts # 入口按MCP_MODE分发启动模式 │ ├── server.ts # 创建统一McpServer实例注册所有工具 │ ├── tools/ │ │ └── flight.ts # 具体工具实现可以按业务域拆多个文件 │ ├── transports/ │ │ ├── stdio.ts # Stdio模式装配代码 │ │ └── http.ts # HTTP模式装配代码 │ └── utils/ │ └── logger.ts # 日志工具关键见第5章 ├── package.json └── tsconfig.json核心思想是server.ts只负责构建一个McpServer实例并注册工具完全不知道外界用的是Stdio还是HTTP。两个transport文件负责把各自的传输协议“接”到这个server实例上。2.3 用环境变量做启动模式分发入口文件的逻辑非常简单就是一个开关// index.ts import { buildServer } from ./server; const mode process.env.MCP_MODE || stdio; async function main() { const server buildServer(); if (mode stdio) { await startStdio(server); } else if (mode http) { await startHttp(server); } else { console.error(Unknown MCP_MODE: ${mode}); process.exit(1); } } main().catch((err) { console.error(Fatal error:, err); process.exit(1); });这里有个细节入口文件里不要写任何和具体工具相关的逻辑。buildServer()把工具注册做得越干净测试就越容易。你要知道某个工具执行函数的行为直接单测它就行根本不需要启动整个MCP服务。3. 核心代码落地工具实现与两种传输层接入3.1 先写一个能查航班状态的示例工具为了不空谈理论我拿一个“航班状态查询”服务当例子。这个工具接收航班号返回模拟的航班状态。工具实现的关键点有三处描述、参数Schema、返回结构。// tools/flight.ts import { z } from zod; import type { McpServer } from modelcontextprotocol/sdk/server/mcp.js; export function registerFlightTools(server: McpServer) { server.tool( query-flight, 查询指定航班的实时状态返回当前航班是否准点、出发到达时间、登机口等信息, { flightNo: z.string().describe(航班号例如 CA1234), }, async ({ flightNo }) { // 这里实际会调用航司API或内部数据源 const status { flightNo, departure: 08:30, arrival: 11:45, gate: C22, status: 准点, }; return { content: [ { type: text as const, text: JSON.stringify(status, null, 2), }, ], }; } ); }关于返回结构这个点要特别说MCP工具返回的content是一个数组数组里可以有多段内容每段可以是text、image等类型。绝大多数场景下你返回一段文本就行格式是JSON字符串。但有个常见误解——是不是直接返回对象不是text字段必须是字符串所以这里用JSON.stringify包一层。如果你有多个工具就在server.ts里集中注册// server.ts import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { registerFlightTools } from ./tools/flight; export function buildServer() { const server new McpServer( { name: flight-status-server, version: 1.0.0, }, { capabilities: { tools: {}, }, } ); registerFlightTools(server); return server; }3.2 Stdio接入三行代码跑通本地调试Stdio接入的代码短得让人不敢相信整个transport文件就三行核心逻辑// transports/stdio.ts import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import type { McpServer } from modelcontextprotocol/sdk/server/mcp.js; export async function startStdio(server: McpServer) { const transport new StdioServerTransport(); await server.connect(transport); }就这么多。StdioServerTransport会自动从process.stdin读取JSON-RPC消息把响应写到process.stdout。你不需要自己处理一行行消息的拆包、粘包SDK全包了。跑起来的方式也很简单MCP_MODEstdio npx tsx src/index.ts然后用MCP调试工具第4章细讲指定命令为npx、参数为tsx src/index.ts即可。3.3 HTTP接入GET/POST路由与会话处理HTTP模式的核心类是StreamableHTTPServerTransport它做的事情比较多读取请求、判断是初始化还是普通调用、维护session、通过SSE推送服务端消息。你只需要做两件事把Express的req/res对象交给它然后启动进程。// transports/http.ts import express from express; import { randomUUID } from crypto; import { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; import type { McpServer } from modelcontextprotocol/sdk/server/mcp.js; export async function startHttp(server: McpServer, port 3000) { const app express(); app.use(express.json()); const transport new StreamableHTTPServerTransport({ sessionIdGenerator: () randomUUID(), }); // POST处理客户端发送的JSON-RPC请求 app.post(/mcp, async (req, res) { await transport.handleRequest(req, res); }); // GET建立SSE流让服务端能向客户端主动推送 app.get(/mcp, async (req, res) { await transport.handleRequest(req, res); }); app.listen(port, () { console.error(MCP HTTP server listening on port ${port}); }); }这里有两个容易被忽略的坑第一GET和POST都要配。很多人只写到POST发现工具调用也能返回结果但服务端如果想通过SSE推送进度通知、资源变更事件GET不配就全堵死了。StreamableHTTPServerTransport内部会根据请求方法自动分流处理。第二console.error而不是console.log。日志问题我在第5章详细说这里先记住一个原则HTTP模式下console.log输出到stdout还没事但如果你两种模式在一个入口文件里养成习惯一律走stderr或文件日志将来切到Stdio模式就不会翻车。3.4 initialize握手与协议版本协商有些读者会疑惑上面的代码里我没写任何处理initialize消息的逻辑协议握手怎么完成的答案是SDK内部帮你做完了。当客户端发起initialize请求时StreamableHTTPServerTransport和McpServer会协同完成协议版本协商。客户端会在protocolVersion字段里声明自己支持的协议版本SDK会找到双方都兼容的版本写进响应。服务端支持的协议版本集合由SDK版本决定一般不用手动干预。你只需要知道握手过程长什么样方便你调试时对号入座。标准流程是客户端发送initialize请求服务端返回sokuyo协议版本、服务端能力列表支持哪些工具/资源、服务端信息客户端发送notifications/initialized通知表示握手完成客户端可以发送tools/list获取工具列表客户端发送tools/call调用具体工具在HTTP模式下第1步的响应头里会带MCP-Session-Id客户端拿到这个值后后续请求都要在请求头里带上它服务端才能识别是同一个会话。4. 验证链路从本地Inspector到远端curl4.1 用官方调试面板验证Stdio模式MCP官方提供了一个图形化调试工具叫Inspector跑起来非常简单npx modelcontextprotocol/inspector启动后会看到面板地址。在配置页里选择STDIO传输类型填上命令和参数比如Command填npxArguments填tsx src/index.tsEnvironment变量里加上MCP_MODEstdio。点击连接后左侧会显示整个交互过程initialize的请求和响应内容tools/list返回的工具列表调用工具后返回的完整消息我的习惯是连接后先看一眼tools/list的JSON结构确认工具描述和参数Schema都正确传上去了再逐个调用工具验证返回结果。如果你在Inspector里看到工具列表出现乱码多半是第5章要讲的stdout污染问题。4.2 用curl手动验证HTTP模式启动HTTP模式后MCP_MODEhttp PORT3000 npx tsx src/index.ts先验证服务端是否活着curl -X POST http://localhost:3000/mcp \ -H Content-Type: application/json \ -H Accept: application/json, text/event-stream \ -H MCP-Protocol-Version: 2024-11-05 \ -d { jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2024-11-05, capabilities: {}, clientInfo: { name: curl-client, version: 0.1.0 } } }注意响应头MCP-Session-Id复制它的值然后请求tools/listcurl -X POST http://localhost:3000/mcp \ -H Content-Type: application/json \ -H MCP-Session-Id: 上一步拿到的session-id \ -H MCP-Protocol-Version: 2024-11-05 \ -d {jsonrpc:2.0,id:2,method:tools/list,params:{}}最后调用工具curl -X POST http://localhost:3000/mcp \ -H Content-Type: application/json \ -H MCP-Session-Id: session-id \ -H MCP-Protocol-Version: 2024-11-05 \ -d { jsonrpc:2.0, id:3, method:tools/call, params:{ name:query-flight, arguments:{flightNo:CA1234} } }这组curl命令是排查HTTP模式问题最常用的武器。比如服务端报错时response里会带JSON-RPC标准的错误码和错误信息你能一眼看出是协议版本不匹配、参数校验失败还是内部异常。4.3 接入真实MCP客户端的关键配置验证完协议层最后把它接进真正的MCP客户端。主流MCP客户端都支持在配置文件里声明MCP服务。Stdio模式配置大概长这样{ mcpServers: { flight-server: { command: node, args: [dist/index.js], env: { MCP_MODE: stdio } } } }HTTP模式配置长这样{ mcpServers: { flight-server: { url: http://localhost:3000/mcp, headers: { Authorization: Bearer your-token } } } }两种配置的核心差异就在command/args/env和url/headers。如果你把代码部署到远程服务器客户端那边只需要改成服务器地址和对应的鉴权头配置本身没有任何其它魔法。5. 上线前必须知道的五个深坑与规避方案5.1 Stdio模式的头号杀手stdout污染这个坑坑过无数第一次写MCP服务的人。Stdio传输模式下stdout是协议通道不是日志通道。你在工具函数里写一句console.log(调试信息)这段信息会直接混进发往客户端的JSON-RPC消息流里客户端解析直接失败表现症状五花八门工具列表不完整、调用结果解析异常、握手成功但列表超时。解决方案很简单// utils/logger.ts import fs from fs; const logStream fs.createWriteStream(/tmp/mcp-server.log, { flags: a }); export function log(...args: any[]) { const line new Date().toISOString() args.map(String).join( ); logStream.write(line \n); // 关键无论何时都不要往console.log写业务日志 }5.2 会话过期与无状态模式的选择HTTP模式默认是有状态会话服务端内存里会保留session相关的上下文。这里的问题有两个一是session泄漏有些客户端断开时不清除session服务端不会立刻知道长期运行内存只增不减二是横向扩展——你部署两个服务实例客户端第一次请求打到实例A第二次负载均衡到实例B实例B根本不认识这个session。如果你的业务场景工具之间没有状态依赖也就是每个工具调用都是独立的优先考虑无状态模式。具体做法看SDK版本部分版本提供了关闭会话/简化会话的选项。判断标准如果你不需要跨工具调用保存用户上下文就尽量做无状态请求让每个请求都自包含这能省掉一整套会话管理的麻烦。5.3 CORS和鉴权一个都不能漏HTTP模式暴露到公网后第一件事就是要考虑谁可以访问。MCP协议本身不内置鉴权机制你得自己在HTTP层加。最简单的方案是在工具调用前检查Bearer Token进一步可以做API Key per user甚至接内部统一鉴权网关。CORS也很容易漏。如果你的MCP客户端跑在浏览器扩展或网页IDE里跨域请求是必然的Express默认不允许跨域需要在响应头里加上app.use((req, res, next) { res.setHeader(Access-Control-Allow-Origin, *); res.setHeader(Access-Control-Allow-Methods, GET, POST, OPTIONS); res.setHeader(Access-Control-Allow-Headers, Content-Type, MCP-Protocol-Version, MCP-Session-Id, Authorization); next(); });这里提醒一点鉴权归鉴权CORS归CORS不要混在一起。CORS只解决“浏览器能不能跨域发起请求”鉴权解决“这个请求是不是合法用户”两者必须同时到位。5.4 长耗时工具的客户端和服务端双重超时MCP工具一般期望在较短时间内返回结果。如果你有一个工具要查数据库、调外部接口耗时可能超过10秒这时候要意识到两个超时问题客户端请求超时和服务端进程超时。我的经验是把工具按耗时分成两类。快速工具直接同步返回结果慢工具有两种处理思路——要么把同步等待时间控制在10秒内返回一个“已受理”的结果要么MCP SDK的进度通知机制配合SSE流先告诉客户端“正在处理”处理完再推送最终结果。HTTP模式下有SSE通道这个能力是Stdio模式不太容易做到的。5.5 工具内部错误千万别让整个服务崩溃工具执行函数里最忌讳的是直接抛错。一旦异常向上传播整个请求会变成协议错误客户端拿到的是服务端内部错误而不是工具级的结构化错误信息。正确做法是捕获异常把它转成MCP协议认可的结果返回async ({ flightNo }) { try { const info await lookupFlight(flightNo); return { content: [{ type: text, text: JSON.stringify(info) }], }; } catch (e) { return { content: [{ type: text, text: 查询失败${e.message} }], isError: true, }; } }isError: true会让客户端明确知道这次调用失败了但不影响后续工具调用。你可以在日志里把完整错误堆栈记录下来返回给用户的是精简信息。这样服务整体保持可用单个工具失败不会影响其他工具的调用。我在实际部署双模服务后还有一个体会代码里工具注册得再规范也不如提前跑通一次完整链路来得踏实。Inspector过一遍Stdiocurl过一遍HTTP再找个真实客户端验证接入配置三轮走完基本不会出大问题。至于远程部署后的监控重点关注HTTP模式的session数量变化、内存增长曲线和错误率这几个指标比盯着日志文件名有用得多。