ARTICLE DETAIL

资讯详情

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

Node.js 双模 MCP 服务实战:同时支持 Stdio 与 Streamable HTTP

Node.js 双模 MCP 服务实战:同时支持 Stdio 与 Streamable HTTP 1. 为什么我要自己动手写一个双模 MCP 服务最早接触 MCP 是在给一个内部工具链做 AI 能力接入的时候。当时的需求很朴素让本地的脚本、数据库查询、文件操作能被大模型直接调用而不是每次都在对话框里复制粘贴。翻了一圈资料发现 MCP 这个协议的设计思路确实对味——它把“模型能调用的工具”抽象成标准接口客户端和服务端各司其职工具提供方只需要实现协议不用关心上层是哪个模型、哪个客户端。但真正动手的时候问题就来了。官方和社区给的示例大多是单模的要么只跑 Stdio要么只跑 HTTP。Stdio 模式适合本地进程客户端拉起一个子进程通过标准输入输出通信简单直接HTTP 模式适合远程部署多个客户端可以连同一个服务。可实际项目里这两种场景经常同时存在——本地开发调试用 Stdio 最省事部署到服务器上又必须走 HTTP。于是我就想能不能写一个服务同时支持这两种模式根据启动参数或者环境变量切换代码复用最大化。这个想法落地之后就有了今天要聊的这个项目一个基于 Node.js 和官方 SDK 构建的 MCP 服务同时支持 Stdio 和 Streamable HTTP 两种传输模式。它解决的问题很具体——让你不用维护两套代码一套逻辑同时适配本地和远程场景。适合谁看如果你已经听说过 MCP 但还没动手写过或者写过单模服务想升级成双模再或者你是个 Node.js 开发者想了解 MCP 的工程化落地这篇内容应该都能给你一些可以直接抄的参考。我下面会从整体设计思路讲起然后拆核心细节、实操步骤、踩坑记录最后给一份常见问题速查表。全程按我实际写代码的顺序来不跳步参数和配置都会给全。2. 整体设计与技术选型为什么是 Node.js 官方 SDK2.1 双模架构的核心思路双模的本质是“一套业务逻辑两种传输层”。MCP 协议本身把传输层和业务层做了分离服务端注册的工具、资源、提示词这些能力是跟传输方式无关的。所以架构上我把它分成三层最底下是传输层负责 Stdio 和 HTTP 两种通信方式中间是协议层由 SDK 处理 JSON-RPC 消息的编解码和会话管理最上面是能力层也就是我实际要暴露给模型的工具函数。这样分层的好处是能力层完全不用关心当前跑在哪种模式下。我写一个queryDatabase工具它在 Stdio 下被本地客户端调用在 HTTP 下被远程客户端调用代码一模一样。切换模式只需要在启动入口处判断一下走不同的传输初始化分支就行。为什么不用两套代码分别维护我试过成本太高。工具逻辑一旦有改动两处都要改还容易漏。而且 Stdio 和 HTTP 的差异其实只在传输层那几十行代码业务逻辑占了大头复用收益非常明显。2.2 为什么选 Node.js 和官方 SDK选 Node.js 有几个现实原因。第一MCP 的官方 SDK 对 TypeScript/JavaScript 的支持最完整类型定义齐全写起来有自动补全不容易出错。第二Node.js 的异步模型天然适合这种 IO 密集型的服务——工具调用大多是查数据库、读文件、发请求异步处理不会阻塞。第三部署简单一个node命令就能跑不需要额外的运行时环境。官方 SDK 我选的是modelcontextprotocol/sdk这是目前维护最活跃、文档最全的实现。它提供了McpServer类来注册能力提供了StdioServerTransport和StreamableHTTPServerTransport两个传输实现。用官方 SDK 而不是自己撸协议的好处是JSON-RPC 的消息格式、初始化握手、能力协商这些细节它都处理好了我只需要关注业务。版本上我建议用当前 LTS 的 Node.js20.x 或更高。低版本在 ESM 模块加载和某些流处理 API 上会有兼容问题我一开始用 18.x 踩过坑升级到 20 之后顺畅很多。2.3 两种传输模式的适用场景对比在动手之前先把两种模式的定位理清楚这决定了后面代码怎么组织。对比维度Stdio 模式Streamable HTTP 模式通信方式标准输入输出流HTTP 请求 流式响应进程模型客户端拉起子进程独立服务进程部署位置本地本地或远程服务器多客户端不支持一对一支持一对多调试难度较低日志直接打印中等需要看 HTTP 层适用场景本地开发、桌面客户端远程部署、Web 客户端Stdio 模式下客户端比如某个支持 MCP 的编辑器或桌面应用会以子进程的方式启动我的服务然后通过 stdin 发消息、stdout 收消息。这种模式的好处是零网络配置进程生命周期由客户端管理客户端退出服务就结束。缺点是只能一对一而且服务必须和客户端在同一台机器上。Streamable HTTP 模式下我的服务是一个独立的 HTTP 服务器监听某个端口。客户端通过 HTTP 请求发消息服务端用流式响应返回结果。这种模式支持多个客户端同时连接服务可以部署在远程客户端只要能发 HTTP 请求就行。缺点是多了网络层配置和调试都复杂一些。理解了这些差异双模的设计就很自然了入口处根据启动参数决定用哪种传输业务逻辑完全共享。3. 核心细节拆解SDK 的关键 API 与双模实现要点3.1 McpServer 的初始化与能力注册McpServer是整个服务的核心对象。初始化的时候需要传两个东西服务的名称和版本号。这两个信息会在客户端连接时的握手阶段返回客户端用它来识别服务。import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; const server new McpServer({ name: dual-mode-mcp-server, version: 1.0.0 });能力注册用的是server.tool()方法。它接收工具名称、描述、参数 schema 和一个处理函数。参数 schema 我推荐用 Zod 来定义SDK 内置了对 Zod 的支持能自动生成 JSON Schema 给客户端同时做运行时校验。import { z } from zod; server.tool( read_file, 读取指定路径的文件内容, { path: z.string().describe(文件的绝对路径), encoding: z.enum([utf-8, base64]).default(utf-8).describe(编码格式) }, async ({ path, encoding }) { const content await fs.readFile(path, encoding); return { content: [{ type: text, text: content }] }; } );这里有个细节值得说处理函数的返回值必须是{ content: [...] }这种结构content 是一个数组每个元素有 type 和对应的字段。文本用type: text图片用type: image资源引用用type: resource。我一开始直接返回字符串客户端解析不了排查了半天才发现是格式问题。3.2 Stdio 传输的接入方式Stdio 传输的接入非常简单SDK 把标准输入输出流封装好了直接实例化然后连接就行。import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; const transport new StdioServerTransport(); await server.connect(transport);这里有个关键点Stdio 模式下绝对不能用console.log打印调试信息。因为 stdout 是协议通信的通道你打印的任何东西都会被客户端当成协议消息去解析轻则报错重则整个连接断掉。调试信息必须走console.error它输出到 stderr不会干扰协议。我一开始不知道这个在工具函数里加了几行console.log想看执行流程结果客户端直接报“无效的 JSON-RPC 消息”。后来把所有日志改成console.error才恢复正常。这个坑很典型新手几乎必踩。3.3 Streamable HTTP 传输的接入方式HTTP 传输稍微复杂一些因为涉及到会话管理和请求路由。SDK 提供了StreamableHTTPServerTransport但它需要配合一个 HTTP 服务器框架来用。我选的是 Express生态成熟中间件丰富。核心逻辑是这样的每个客户端会话对应一个 transport 实例用一个 Map 来管理 sessionId 到 transport 的映射。客户端首次请求时创建新会话后续请求带上 sessionId 复用已有会话。import express from express; import { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; import { randomUUID } from crypto; const app express(); app.use(express.json()); const transports {}; app.post(/mcp, async (req, res) { const sessionId req.headers[mcp-session-id]; let transport; if (sessionId transports[sessionId]) { transport transports[sessionId]; } else { transport new StreamableHTTPServerTransport({ sessionIdGenerator: () randomUUID(), onsessioninitialized: (sid) { transports[sid] transport; } }); await server.connect(transport); } await transport.handleRequest(req, res, req.body); });这段代码有几个要点。第一sessionIdGenerator负责生成会话 ID我用的是 UUID。第二onsessioninitialized回调在会话建立时触发这时候把 transport 存进 Map后续请求才能找到。第三handleRequest是真正处理请求的方法它接收原始的 req、res 和已经解析好的 body。还有一个容易忽略的点HTTP 模式下需要处理 GET 和 DELETE 请求。GET 用于客户端建立 SSE 流接收服务端推送DELETE 用于客户端主动关闭会话。如果只处理 POST某些客户端会报错。app.get(/mcp, async (req, res) { const sessionId req.headers[mcp-session-id]; const transport transports[sessionId]; if (!transport) { res.status(400).send(Invalid session); return; } await transport.handleRequest(req, res); }); app.delete(/mcp, async (req, res) { const sessionId req.headers[mcp-session-id]; const transport transports[sessionId]; if (transport) { await transport.close(); delete transports[sessionId]; } res.status(200).send(Session closed); });3.4 双模切换的入口设计入口文件根据启动参数决定走哪条分支。我用的是最简单的判断命令行参数里有--http就走 HTTP否则默认 Stdio。const useHttp process.argv.includes(--http); if (useHttp) { const port process.env.MCP_PORT || 3000; app.listen(port, () { console.error(MCP HTTP server listening on port ${port}); }); } else { const transport new StdioServerTransport(); await server.connect(transport); console.error(MCP Stdio server started); }这里注意HTTP 模式下日志可以正常用console.error或console.log因为不涉及协议通道。但为了统一我还是全用console.error避免混淆。4. 实操过程从零搭建的完整步骤4.1 环境准备与依赖安装先确认 Node.js 版本。打开终端执行node -v如果低于 20.x建议升级。Ubuntu 上可以用 NodeSource 的源来装curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt-get install -y nodejsWindows 和 macOS 直接去官网下 LTS 安装包就行。装完再node -v确认一下。然后初始化项目mkdir dual-mode-mcp-server cd dual-mode-mcp-server npm init -y安装依赖npm install modelcontextprotocol/sdk zod express如果要用 TypeScript再加npm install -D typescript types/node types/express tsx我为了演示方便下面用 JavaScript 写但逻辑跟 TypeScript 一样。4.2 项目结构规划我习惯把代码分成几个文件职责清晰dual-mode-mcp-server/ ├── src/ │ ├── server.js # McpServer 初始化和工具注册 │ ├── tools.js # 工具函数的具体实现 │ ├── stdio.js # Stdio 传输启动逻辑 │ ├── http.js # HTTP 传输启动逻辑 │ └── index.js # 入口根据参数分发 ├── package.json └── README.md这样分的好处是工具逻辑独立在tools.js里测试和复用都方便。传输层的代码各自独立互不干扰。4.3 工具注册与业务逻辑实现先写tools.js定义几个实用的工具。我选了三个有代表性的读文件、查系统信息、执行简单计算。这三个覆盖了 IO、系统调用和纯计算三种类型。import fs from fs/promises; import os from os; export async function readFile({ path, encoding }) { try { const content await fs.readFile(path, encoding); return { content: [{ type: text, text: content }] }; } catch (err) { return { content: [{ type: text, text: 读取失败: ${err.message} }], isError: true }; } } export async function getSystemInfo() { const info { platform: os.platform(), arch: os.arch(), cpus: os.cpus().length, totalMemory: ${(os.totalmem() / 1024 / 1024 / 1024).toFixed(2)} GB, freeMemory: ${(os.freemem() / 1024 / 1024 / 1024).toFixed(2)} GB, uptime: ${(os.uptime() / 3600).toFixed(2)} hours }; return { content: [{ type: text, text: JSON.stringify(info, null, 2) }] }; } export async function calculate({ expression }) { try { const result Function(use strict; return (${expression}))(); return { content: [{ type: text, text: ${expression} ${result} }] }; } catch (err) { return { content: [{ type: text, text: 计算错误: ${err.message} }], isError: true }; } }注意calculate里用了Function构造器这在生产环境有安全风险因为可以执行任意代码。我这里只是为了演示实际项目里应该用专门的表达式解析库比如mathjs。这个点后面在避坑部分会再提。然后写server.js把工具注册到 McpServer 上import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { z } from zod; import { readFile, getSystemInfo, calculate } from ./tools.js; export function createServer() { const server new McpServer({ name: dual-mode-mcp-server, version: 1.0.0 }); server.tool( read_file, 读取指定路径的文件内容, { path: z.string().describe(文件的绝对路径), encoding: z.enum([utf-8, base64]).default(utf-8).describe(编码格式) }, readFile ); server.tool( get_system_info, 获取当前系统的硬件和运行信息, {}, getSystemInfo ); server.tool( calculate, 计算一个数学表达式支持加减乘除和括号, { expression: z.string().describe(要计算的表达式如 (12)*3) }, calculate ); return server; }4.4 Stdio 模式启动与测试stdio.js很简单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); console.error(MCP Stdio server started); }测试 Stdio 模式最直接的方法是用 SDK 提供的客户端。写一个测试脚本import { Client } from modelcontextprotocol/sdk/client/index.js; import { StdioClientTransport } from modelcontextprotocol/sdk/client/stdio.js; const transport new StdioClientTransport({ command: node, args: [src/index.js] }); const client new Client({ name: test-client, version: 1.0.0 }); await client.connect(transport); const tools await client.listTools(); console.log(可用工具:, tools.tools.map(t t.name)); const result await client.callTool({ name: get_system_info, arguments: {} }); console.log(系统信息:, result.content[0].text);跑这个脚本如果能看到工具列表和系统信息输出说明 Stdio 模式通了。4.5 HTTP 模式启动与测试http.js就是前面 3.3 节那段代码的完整版。启动之后用 curl 测试curl -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: 2024-11-05, capabilities: {}, clientInfo: { name: curl-test, version: 1.0.0 } } }如果返回了服务端的能力信息说明 HTTP 模式也通了。注意Accept头必须同时包含application/json和text/event-stream否则 SDK 会拒绝请求。这个细节文档里写得不明显我试了好几次才找到原因。4.6 入口文件与双模切换index.js把两条分支合起来import { startStdio } from ./stdio.js; import { startHttp } from ./http.js; const useHttp process.argv.includes(--http); if (useHttp) { await startHttp(); } else { await startStdio(); }package.json里加两个脚本方便启动{ scripts: { start: node src/index.js, start:http: node src/index.js --http } }这样npm start跑 Stdionpm run start:http跑 HTTP切换成本几乎为零。5. 常见问题与排查技巧实录5.1 Stdio 模式下客户端连不上最常见的原因是 stdout 被污染。检查代码里有没有console.log全部改成console.error。另一个原因是启动命令的路径不对客户端找不到入口文件。用绝对路径或者确认工作目录正确。还有一种情况是 Node.js 版本太低ESM 模块加载失败。服务启动时如果报Cannot use import statement outside a module说明package.json里没加type: module。加上就行。5.2 HTTP 模式下 400 错误400 错误通常有几个来源。一是Accept头不对必须同时包含application/json和text/event-stream。二是请求体不是合法的 JSON-RPC 格式检查jsonrpc、method、id这几个字段。三是 sessionId 无效客户端带了一个服务端不认识的 sessionId这时候服务端应该返回 404 让客户端重新初始化。我遇到过一次 400排查半天发现是 Express 的express.json()中间件没加req.body是 undefinedhandleRequest拿不到数据。加上中间件就好了。5.3 工具调用返回格式错误如果客户端报“无法解析工具返回结果”大概率是返回结构不对。必须是{ content: [{ type: text, text: ... }] }这种格式。我见过有人直接返回字符串或者返回{ result: ... }都不行。SDK 对返回结构有严格校验格式不对直接抛错。另外如果工具执行出错不要直接抛异常而是返回isError: true加上错误信息。这样客户端能拿到结构化的错误而不是连接断掉。5.4 会话管理导致的内存泄漏HTTP 模式下如果客户端异常断开而没有发 DELETE 请求transport 会一直留在 Map 里时间长了内存就涨上去了。解决办法是加一个定时清理任务定期检查 transport 的最后活跃时间超过阈值就关闭并删除。setInterval(() { const now Date.now(); for (const [sid, transport] of Object.entries(transports)) { if (now - transport.lastActivity 30 * 60 * 1000) { transport.close(); delete transports[sid]; } } }, 5 * 60 * 1000);lastActivity需要在每次handleRequest时更新这个要自己在 transport 外面包一层。5.5 常见问题速查表问题现象可能原因解决方法Stdio 客户端报无效 JSON-RPCstdout 被 console.log 污染改用 console.errorHTTP 请求返回 400Accept 头缺失或不完整加上 application/json 和 text/event-streamHTTP 请求返回 400express.json() 未启用添加中间件工具返回无法解析返回结构不符合规范用 { content: [...] } 格式服务启动报 ESM 错误package.json 缺 type: module添加 type: module内存持续增长会话未清理加定时清理任务客户端连不上 HTTP端口被占用或防火墙换端口或检查防火墙规则5.6 几个我踩过的坑和独家技巧第一个坑是 Zod schema 的默认值。我在read_file的 encoding 参数上设了.default(utf-8)但客户端不传这个参数时SDK 传给我的处理函数里 encoding 是 undefined不是 utf-8。后来发现需要在处理函数里自己兜底或者用.optional().default()的组合。这个行为跟 Zod 的版本有关建议实测确认。第二个坑是 HTTP 模式下的 CORS。如果客户端是浏览器里的 Web 应用跨域请求会被拦。需要加 CORS 中间件并且要允许mcp-session-id这个自定义头。app.use((req, res, next) { res.header(Access-Control-Allow-Origin, *); res.header(Access-Control-Allow-Headers, Content-Type, mcp-session-id); res.header(Access-Control-Expose-Headers, mcp-session-id); if (req.method OPTIONS) { return res.sendStatus(200); } next(); });注意Access-Control-Expose-Headers也要加上mcp-session-id否则浏览器端的 JavaScript 读不到这个响应头后续请求就带不上 sessionId。第三个技巧是关于日志的。Stdio 模式下日志走 stderr但很多客户端会把 stderr 也捕获显示。为了区分正常日志和错误我在日志前面加了级别标记比如[INFO]、[ERROR]这样在客户端的日志面板里一眼就能看出问题。第四个技巧是工具描述要写清楚。MCP 的工具描述是给模型看的模型根据描述决定调不调用这个工具。描述写得太模糊模型可能该调的时候不调或者不该调的时候乱调。我一般会写清楚工具做什么、参数是什么含义、什么场景下用。比如read_file的描述我会写成“读取本地文件系统的指定文件返回文本内容。适用于需要查看配置文件、日志、代码等场景”。6. 双模服务的扩展方向与个人体会这套双模框架搭好之后扩展新工具就是往tools.js里加函数、在server.js里注册传输层完全不用动。我后来陆续加了数据库查询、HTTP 请求转发、目录列表这几个工具每个都是十几行代码的事。如果要做更复杂的场景比如工具需要访问共享状态可以在createServer的时候把状态对象传进去工具函数通过闭包访问。但要注意 HTTP 模式下多个会话共享同一个 server 实例状态是全局的如果每个会话需要独立状态就得把状态挂在 transport 上而不是 server 上。还有一个值得尝试的方向是给 HTTP 模式加上认证。目前是裸奔的任何人都能连。可以在 Express 层加一个中间件校验 token校验通过才放行到 MCP 处理逻辑。这样部署到公网也安心一些。我个人在实际操作中的体会是MCP 的工程化落地没有想象中那么复杂官方 SDK 已经把大部分脏活累活干了。真正的难点在于理解协议的分层设计以及两种传输模式各自的约束。一旦把这两点搞清楚剩下的就是写业务逻辑。我建议新手先从 Stdio 模式入手跑通一个最简单的工具然后再加 HTTP 支持。这样每一步都有正反馈不容易卡住。最后再分享一个小技巧调试 MCP 服务的时候可以在工具函数里加一个debug参数默认 false为 true 时返回额外的执行信息。这样不用改代码就能看到内部状态比反复加日志再删日志高效得多。
返回列表