
1. 项目背景与核心概念为什么“双模”是刚需先说结论MCPModel Context Protocol模型上下文协议本质上是一套“给AI模型接外设”的通用协议。它把工具、数据源和能力统一封装成标准接口让AI客户端Cursor、Claude Desktop、Codex等能以一致的方式调用。你不需要为每个AI客户端单独定制对接方案只要实现一套MCP服务就能同时在多个客户端里复用。但这里有个关键矛盾不同的AI客户端接入MCP服务的方式完全不同。有的客户端跑在本地终端环境通过标准输入输出Stdio与子进程通信有的客户端运行在云端或浏览器环境通过网络HTTP接口调用。如果只支持一种传输方式就意味着你的服务只能在部分客户端使用换一个客户端就要重新适配一套。这也是为什么我从一开始就决定不偷懒直接构建一个同时支持 Stdio 与 Streamable HTTP 双模的MCP服务——一套代码两种传输方式处处可用。在动手之前有必要把两个关键术语彻底讲清楚Stdio模式MCP client如 Claude Desktop、本地命令行工具启动你的服务进程通过标准输入stdin发送JSON-RPC请求通过标准输出stdout接收响应。通信走的是进程间管道不涉及网络端口配置天然适合本地工具、CLI插件、桌面端集成。Streamable HTTP模式这是MCP协议规范中面向HTTP传输的演进方案支持无状态/有状态的请求模式通过HTTP端点如/mcp处理请求部分场景还会结合SSEServer-Sent Events做服务端消息推送。它适合云端部署、Web应用集成、多客户端并发访问的场景。从我这一年的实操体验来看很多项目死在“只支持Stdio”这种单一模式上本地调试没问题一上生产环境、要提供给远端客户端调用就直接被卡死。反过来如果只做HTTP本地终端工具又用不了。双模不是炫技是真实需求逼出来的。本文适合这几类读者正在给团队搭建内部AI工具链的开发者、想把现有数据/工具能力开放给AI客户端的后端工程师、以及MCP生态的插件作者。我会从零开始把项目初始化、工具定义、双模传输实现、测试调优到部署踩坑全流程走一遍。2. 技术选型与项目脚手架搭建2.1 语言与SDK的选型逻辑MCP官方SDK目前有TypeScript、Python、Java、Kotlin、C#等多个版本。我选择TypeScript Node.js原因很直接生态成熟modelcontextprotocol/sdk 官方支持完善TypeScript的类型定义能显著减少协议字段拼写错误而且Node.js的进程管理和HTTP服务实现都非常成熟天然适合“一个进程双协议”的需求。如果你更熟悉Python用官方的mcp包也能实现类似效果但根据我的对比体验TypeScript版本在处理Streamable HTTP的SSE流式响应和并发会话管理时相关示例和排查资料更丰富遇到问题更容易找到参考。2.2 项目初始化与依赖安装先创建一个空项目并初始化mkdir mcp-dual-mode-server cd mcp-dual-mode-server npm init -y安装核心依赖npm install modelcontextprotocol/sdk npm install zod # MCP SDK 依赖 zod 做参数校验 npm install typescript tsx types/node --save-dev其中tsx是TypeScript直接运行工具开发调试时不用频繁编译生产部署则用tsc编译成JS再跑。我建议开发阶段都用tsx调试效率提升明显。初始化TypeScript配置npx tsc --inittsconfig.json中几个关键配置项需要留意{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, skipLibCheck: true }, include: [src/**/*], exclude: [node_modules, dist] }这里strict: true强烈建议打开。MCP协议对参数结构敏感严格类型检查能在编译期拦住大量低级错误比如工具参数少了必填字段、响应的结构不符合协议规范等。2.3 目录结构规划我习惯按“工具注册、传输层、共享逻辑”三个维度拆文件src/ ├── index.ts # 入口根据环境变量启动不同传输模式 ├── server.ts # MCP服务定义工具注册、能力声明 ├── tools/ │ ├── weather.ts # 示例工具1天气查询 │ └── notes.ts # 示例工具2笔记读写 ├── transports/ │ ├── stdio.ts # Stdio 传输启动 │ ├── http.ts # Streamable HTTP 传输启动 │ └── config.ts # 双模配置读取这样设计的好处是工具实现不关心底层走什么协议传输层只负责“把消息送出去”接口边界清晰后面扩展新工具、新传输方式都不用大改。3. 核心实现服务端与工具定义3.1 服务端骨架的搭建在server.ts中核心是创建一个McpServer实例并向它注册工具。代码如下import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { registerWeatherTool } from ./tools/weather.js; import { registerNotesTool } from ./tools/notes.js; export function createServer() { const server new McpServer({ name: dual-mode-demo-server, version: 1.0.0, }); registerWeatherTool(server); registerNotesTool(server); return server; }这里需要注意name和version不是随便填的。在Stdio模式下这些信息会出现在MCP client的日志和配置检查中在HTTP模式下则体现在服务能力声明initialize响应里。命名要清晰稳定避免后续升级时客户端混淆。3.2 工具定义的关键细节以天气查询工具为例看一个完整定义import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { z } from zod; export function registerWeatherTool(server: McpServer) { server.tool( get_weather, 根据城市名称查询当前天气支持中文城市名, { city: z.string().describe(城市名称例如北京、上海), unit: z.enum([celsius, fahrenheit]).optional().describe(温度单位默认摄氏), }, async ({ city, unit celsius }) { // 这里是实际业务逻辑 const temperature await queryWeatherFromDataSource(city, unit); return { content: [ { type: text, text: ${city} 当前温度${temperature}${unit celsius ? °C : °F} }, ], }; } ); }三个容易被忽略的实操要点第一工具描述description字段一定要写得具体。在Stdio模式下AI模型根据描述决定是否调用工具、怎么传参。描述模糊会导致模型传错参数比如“查询天气”和“根据城市名称查询天气支持中文城市名”对模型的引导效果完全不同。第二返回内容必须是content数组格式元素要有type: text。这是MCP协议规定的结构不符合格式会让客户端无法解析。如果你要返回结构化数据给模型做后续判断可以加structuredContent字段但content数组是必须的。第三参数用zod定义时describe()注释是给AI模型看的不是给人看的。命名要语义化比如用city而不是c。这直接影响工具被调用的成功率。3.3 文件工具实现笔记读写场景为了让教程更实用我再加一个本地笔记读写工具模拟“MCP连接数据源”的场景import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { z } from zod; import { readFile, writeFile, mkdir } from fs/promises; import path from path; const NOTES_DIR process.env.NOTES_DIR || path.join(process.cwd(), notes); export function registerNotesTool(server: McpServer) { server.tool( read_note, 读取指定路径的本地笔记内容, { name: z.string().describe(笔记文件名例如meeting-20250601.md), }, async ({ name }) { const fullPath path.join(NOTES_DIR, name); try { const content await readFile(fullPath, utf-8); return { content: [{ type: text, text: content }] }; } catch (err: any) { return { content: [{ type: text, text: 读取失败${err.message} }] }; } } ); }这个工具验证了双模模式下同样的业务逻辑可以无缝复用。不管客户端是通过Stdio还是HTTP进来read_note的读写逻辑完全一样。在后续测试中我会用这两组工具分别验证两种传输模式。4. 双模传输层实现Stdio 与 Streamable HTTP4.1 Stdio传输的启动实现在transports/stdio.ts中核心逻辑很简洁import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { createServer } from ../server.js; export async function startStdioServer() { const server createServer(); const transport new StdioServerTransport(); await server.connect(transport); console.error(Dual-mode MCP server (stdio) started); }有几个细节要特别提醒第一不要在业务逻辑里用console.log输出内容。Stdio模式中stdout是MCP通信通道任何无关输出都会污染协议消息流导致客户端解析失败。我建议所有日志都通过console.error输出到stderr或者接入专门的日志库。这是新手最容易踩的坑表现形式是“服务明明起来了但客户端一直报解析错误”。第二Stdio模式没有网络端口概念启动后进程会保持监听stdin。这意味着如果你在本地调试需要确保没有其他进程占用当前终端会话。第三server.connect(transport)这一步是异步的要注意启动顺序等待连接完成后再执行后续逻辑。4.2 Streamable HTTP传输的启动实现HTTP模式的实现稍微复杂一些涉及会话、校验头和SSE流import { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; import express from express; import { createServer } from ../server.js; export function startHttpServer() { const app express(); app.use(express.json()); app.post(/mcp, async (req, res) { const server createServer(); const transport new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // 让SDK自动生成 onsessioninitialized: (sessionId) { console.error([http] session initialized: ${sessionId}); }, }); res.on(close, () { transport.close(); }); await server.connect(transport); await transport.handleRequest(req, res); }); const port process.env.PORT || 3456; app.listen(port, () { console.error([http] MCP server listening on http://localhost:${port}/mcp); }); }这里有几个关键决策点需要展开讲为何使用express而不是SDK自带示例的http模块官方示例推荐用极简方式但真实项目中多半需要中间件处理认证比如校验API Key、日志、静态资源配置。用express扩展起来更省事不用自己封装中间件逻辑。StreamableHTTPTransport的handleRequest时机核心在于await server.connect(transport)先行再调用handleRequest。如果顺序颠倒SDK会认为连接尚未建立请求直接404或报错。res.on(close)清理逻辑客户端断开连接时会触发此时必须关闭transport否则进程会残留僵尸连接在压力测试下表现非常明显。我在早期版本遗漏了这个资源清理结果并发测试超过200个请求时出现了内存占用持续上涨。会话管理问题Streamable HTTP支持两种请求方式单次交互模式每次request/response独立不维持会话持久化模式初始化请求返回Mcp-Session-Id响应头客户端后续请求带上该头服务端维持会话状态和SSE通道我在上面的实现中未自定义sessionIdGenerator让SDK自动生成这适合大多数场景。但如果要做多实例水平扩展就需要自定义会话存储例如存Redis否则负载均衡下会话会漂移。这一块我在第6章会详细说。4.3 入口整合通过环境变量切换模式在index.ts中做最终整合import { startStdioServer } from ./transports/stdio.js; import { startHttpServer } from ./transports/http.js; const mode process.env.MCP_TRANSPORT || stdio; if (mode http) { startHttpServer(); } else { startStdioServer(); }用环境变量MCP_TRANSPORT控制启动模式好处是同一份部署产物可以在两种场景复用本地开发用Stdio云服务器直接换个环境变量跑HTTP。不需要维护两套代码分支。package.json中配置启动脚本{ scripts: { dev:stdio: tsx src/index.ts, dev:http: MCP_TRANSPORThttp PORT3456 tsx src/index.ts, build: tsc, start:stdio: node dist/index.js, start:http: MCP_TRANSPORThttp PORT3456 node dist/index.js } }5. 双模服务的测试策略与工具链5.1 使用MCP Inspector做可视化验证官方提供了modelcontextprotocol/inspector这个可视化调试面板强烈建议装到全局npm install -g modelcontextprotocol/inspector针对Stdio模式启动Inspectormcp-inspector -- node dist/index.jsInspector会在浏览器打开一个管理界面你能直观看到服务端声明了哪些工具每个工具的输入schema实际调用工具的响应内容连接建立时的initialize握手信息这里要提醒Inspector自己就是一个MCP client所以它验证的是服务端是否符合协议规范。如果Inspector能正常列出工具并调用基本可以证明Stdio模式实现没问题。5.2 HTTP模式下手动curl验证无状态单次请求模式HTTP模式不依赖SSE会话最简单的验证方式是这个curl -X POST http://localhost:3456/mcp \ -H Content-Type: application/json \ -d {jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2025-06-18,capabilities:{},clientInfo:{name:test-client,version:1.0.0}}}正常的初始化响应会返回服务端信息类似{ jsonrpc: 2.0, id: 1, result: { protocolVersion: 2025-06-18, capabilities: { tools: { listChanged: false } }, serverInfo: { name: dual-mode-demo-server, version: 1.0.0 } } }注意要使用SDK支持的protocolVersion。不同版本的SDK支持的协议版本列表不一样如果客户端和服务端版本不一致会握手失败。调试时先打开Inspector看官方示例用的哪个版本再去curl里对齐。然后调用工具需要先发tools/list获取工具列表再发tools/callcurl -X POST http://localhost:3456/mcp \ -H Content-Type: application/json \ -d {jsonrpc:2.0,id:2,method:tools/call,params:{name:get_weather,arguments:{city:北京}}}如果走持久化会话模式需要捕获初始化响应头中的Mcp-Session-Id后续请求带上curl -X POST http://localhost:3456/mcp \ -H Content-Type: application/json \ -H Mcp-Session-Id: 你的会话ID \ -d ...不带头会创建新会话带头但服务端不存在该会话时部分客户端会返回SESSION_NOT_FOUND错误这是正常现象属于安全设计防止无效会话占用资源。5.3 自动化集成测试一套用例测双模我建议把测试写成脚本双模各跑一遍避免手动验证漏项。可以用Node内置的node:test也可以用Vitest。下面是一个精简的HTTP模式测试示例// tests/http.test.ts import { describe, it, expect } from vitest; import { spawn } from child_process; describe(HTTP mode integration, () { it(should list tools, async () { const proc spawn(npm, [run, dev:http], { shell: true }); await new Promise((r) setTimeout(r, 2000)); const resp await fetch(http://localhost:3456/mcp, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ jsonrpc: 2.0, id: 1, method: tools/list, params: {}, }), }).then((r) r.json()); expect(resp.result.tools.length).toBeGreaterThan(0); proc.kill(); }); });这种集成测试能持续保护双模功能不回归CI/CD里跑一遍心里踏实很多。测试要点就是启动进程后按端口/进程就绪状态做轮询而不是固定sleep否则机器慢时会误报。6. 双模式的关键差异与避坑指南6.1 相异之处的对照表前面实现的同一份业务代码底层传输逻辑却完全不同。把这两者彻底搞清楚你才算真正掌握了双模实现对比维度Stdio 模式Streamable HTTP 模式通信通道标准输入/输出HTTP请求 / SSE流部署形态子进程本地启动常驻HTTP服务并发支持单进程单客户端多客户端并发会话管理无需显式管理需要sessionId管理认证方式依赖宿主进程权限可在HTTP层做token校验日志限制stdout严禁输出stdout无此约束但仍建议统一日志规范适用场景本地CLI、桌面客户端云端、Web、多用户接入资源清理进程退出自动回收需要处理连接关闭、超时这张表不是随手整理的是我在多个实际项目中踩出来的总结。比如Stdio模式默认就用本机文件权限不需显式认证但HTTP模式一上公网就必须考虑认证否则任何人带上工具参数就能调用你的数据接口。6.2 会话管理的坑与对策HTTP模式的会话问题是最常被问到的。聊天类客户端如Claude Desktop配置远程MCP通常走持久化会话模式做法是客户端首先发送不带Mcp-Session-Id的initialize请求服务端返回的响应头会带上Mcp-Session-Id后续所有请求都带这个头会话结束或收到DELETE请求时关闭。踩过的坑会话未初始化导致load-tool失败。服务端在会话初始化前就接收tools/list会返回错误。正确处理是先initialize再访问工具。如果客户端框架没有先发initialize通常意味着配置有问题检查客户端的MCP连接配置。自动嗅探/预检请求导致会话数爆炸。不少HTTP框架如部分浏览器、网关会预请求OPTIONS如果不处理OPTIONSSDK可能返回404或CORS错误。在express里加中间件统一处理OPTIONSapp.options(/mcp, (req, res) { res.setHeader(Access-Control-Allow-Origin, *); res.setHeader(Access-Control-Allow-Headers, Content-Type, Mcp-Session-Id, Authorization); res.setHeader(Access-Control-Allow-Methods, POST, GET, DELETE, OPTIONS); res.status(204).end(); });6.3 日志规范与stdout污染再强调一次Stdio模式下的stdout问题排查思路。如果你发现客户端能连上但消息解析崩溃优先怀疑服务端有没有在stdout输出非协议内容。定位方法很简单node dist/index.js /tmp/mcp-stdout.log 21单独跑起来后随便往stdin输入一个合法的JSON-RPC请求然后检查/tmp/mcp-stdout.log看里面是否只有协议消息。如果混入了业务日志一律把日志切到console.error或专用日志文件。对于HTTP模式虽然stdout没有协议污染风险但也别把日志打进stdout因为生产环境往往用systemd或Docker管理进程日志规范统一有利于集中采集。一个更稳妥的做法是使用pino日志库配置不同传输模式下输出到不同目的地const logger pino( mode stdio ? { transport: { target: pino/file, options: { destination: /var/log/mcp-server.log } } } : {} );6.4 双模下的超时配置HTTP模式下客户端发起长时间运行的工具调用时如果在代理层nginx、云负载均衡超时时间设置太短即使服务端逻辑正常也会被网关断掉连接。实测中本地开发不需要额外配置经nginx代理时proxy_read_timeout建议设置为300s以上若通过SSE推送中间事件需确保代理支持X-Accel-Buffering: no否则nginx会缓冲SSE消息造成推送延迟或卡断7. 生产化部署与真实落地的经验之谈7.1 用Docker同时跑两种模式双模服务在生产环境中本质上就是“按需切换启动模式”Docker化后两种模式用同一镜像就能覆盖FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --omitdev COPY dist/ ./dist/ ENV NODE_ENVproduction EXPOSE 3456 CMD [node, dist/index.js]Stdio模式部署直接把容器作为客户端下的子进程使用不需要暴露端口容器启动后保持stdin/stdout开启。不过更常见的做法是在宿主机直接跑Node进程省掉一层容器开销。HTTP模式部署映射端口并设定环境变量docker run -d --name mcp-http \ -e MCP_TRANSPORThttp \ -e PORT3456 \ -p 3456:3456 \ mcp-dual-server镜像层做一层就够了没必要为两种模式分别构建镜像。7.2 认证与授权策略HTTP模式上线后认证是第一要务。我见过好几个团队的反面教材MCP服务开了公网工具里包含生产数据库查询能力结果任何人调用query_database都能拉数据。绝对要加认证。推荐的轻量方案是API Key中间件const API_KEY process.env.MCP_API_KEY; app.use(/mcp, (req, res, next) { if (req.method OPTIONS) return next(); const auth req.headers.authorization; if (auth ! Bearer ${API_KEY}) { res.status(401).json({ error: unauthorized }); return; } next(); });更复杂的场景可以对接OAuth2/OIDC但MCP早期的客户端生态对OAuth支持参差不齐API Key是最兼容、最务实的方案。如果你想严格控制每个用户能调用哪些工具可以在会话层做能力声明过滤不过这要改SDK的server.tool注册逻辑属于进阶话题后面有机会单独写一篇。7.3 能力声明与客户端差异适配不同MCP客户端对服务端能力声明的解释略有差异。Cursor的远程MCP、Claude Desktop、自研客户端在同一份initialize响应中可能有不同的兼容行为。举例来说Streamable HTTP模式下有些客户端必须看到protocolVersion完全匹配才继续后续交互有些客户端只要兼容前缀如2025-06-18与2025-03-26这种大版本演进就会尝试通信。如果握手失败优先看两边的协议版本是否都在SDK支持的列表内。我在项目里用了2025-06-18作为服务端宣告版本同时要求SDK不低于1.12.0大部分主流客户端都能兼容。另一个容易忽略的点是capabilities字段。如果你声明了resources能力但实际没实现任何资源端点部分客户端会在启动时主动拉取资源列表然后报空。建议只声明实际实现的能力比如只做工具调用就只声明tools不声明resources和prompts。7.4 日志监控与性能基准服务跑起来以后没有监控就等于盲飞。至少需要关注三个指标请求量/调用量每个工具一天被调多少次错误率JSON-RPC错误响应占比延迟分位数工具调用的p95/p99延迟用prom-client暴露Metrics端点接Grafana是最快成型的方式。我只加两个自定义指标做演示import client from prom-client; const toolCalls new client.Counter({ name: mcp_tool_calls_total, help: Number of tool calls, labelNames: [tool], });在工具调用函数里toolCalls.inc({ tool: get_weather })然后/metrics端点暴露。性能方面纯工具转发无业务IO在HTTP模式下单请求延迟大约在1-3ms内Stdio模式还要再减去网络开销。如果发现HTTP请求延迟超过50ms优先检查业务逻辑里的同步阻塞调用和数据库查询而不是怀疑MCP协议本身。7.5 客户端接入配置示例把服务部署好之后本地工具接入的配置也顺手列出来。Stdio模式在Claude Desktop中配置示例{ mcpServers: { dual-mode-demo: { command: node, args: [/path/to/dist/index.js], env: { MCP_TRANSPORT: stdio } } } }HTTP模式配置示例{ mcpServers: { dual-mode-demo: { url: http://your-server:3456/mcp, headers: { Authorization: Bearer your-api-key } } } }同一个工具本地调试用Stdio上云后用HTTP配置文件就改一两行这正是双模架构带来的直接收益。8. 回顾与个人经验总结项目走到这里双模MCP服务已经完整落地模块划分清晰服务端定义在server.ts传输层在transports/工具在tools/环境变量一键切换模式。整个项目从初始化到生产可用大约只需要一个工作日的开发量但排查那些隐蔽坑的时间往往远超预期。我个人的体会是MCP最大的价值在于它把“工具接入”这件事标准化了。以前给每个AI工具做集成几乎等于为每个平台单独写一套对接代码现在只需要面向一份协议开发剩下的由双模传输层解决客户端差异。尤其是当你的工具集开始从两三个扩展到几十个时这种架构收益会指数级放大。最后再分享几个从实际项目中沉淀的经验第一工具描述文案值得花时间打磨。AI模型对工具描述的敏感度远超预期描述中带上典型的输入示例如“城市名称例如北京、上海”能明显提高模型调用的准确性这是个投入产出比极高的优化项。第二不要一开始就追求“支持所有能力”。先只声明tools把工具调用链路跑通再逐步扩展resources和prompts。每一步加上新的能力声明都要回到Inspector里重新核对一遍否则排错难度会成倍增加。第三生产环境优先用HTTP模式给每个客户端独立会话日志清晰可控本地快速验证优先用Stdio模式零配置直接跑。两只脚走路省心。如果你照这个流程把服务搭起来了再去接触x32dbg的MCP插件、IDA MCP这类偏逆向工具生态时你会发现核心原理完全一样无非是把特有的工具能力封装成MCP协议标准然后对外提供一种或多种传输通道。希望这份实战记录能帮你少踩几个坑。