news 2026/10/7 12:35:25

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 + 官方 SDK

2.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.js,20.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 nodejs

Windows 和 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("要计算的表达式,如 (1+2)*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跑 Stdio,npm 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是 undefined,handleRequest拿不到数据。加上中间件就好了。

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.error
HTTP 请求返回 400Accept 头缺失或不完整加上 application/json 和 text/event-stream
HTTP 请求返回 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 时返回额外的执行信息。这样不用改代码就能看到内部状态,比反复加日志再删日志高效得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 12:35:16

Agent-Reach:多Agent协作的轻量触达层实践

最近在做一条跨业务的智能化流程改造,最开始只是让几个独立的Agent各管一段流程,跑着跑着发现不对劲:单看每个Agent都挺聪明,但只要涉及接力协作,就全靠人肉中转——把上一个Agent的输出复制粘贴到下一个Agent的输入框…

作者头像 李华
网站建设 2026/10/7 12:35:08

AI 重构 UI 工作流:从手搓像素到意图生成与 prefab 批量落地

1. 从手搓像素到对话生成:UI 工作流的真实转折点“自从有了 AI,我就再也不想拼 UI 了”——这句话我第一次在团队群里看到时,正对着一个 200 多个 prefab 的 Unity 工程发呆。那天下午的任务是把一套活动弹窗从旧版视觉规范迁移到新版&#x…

作者头像 李华
网站建设 2026/10/7 12:34:34

若依分离版集成ECharts:可视化看板组件化实践与踩坑实录

最近在做若依分离版的一个二次开发项目,需求是在生产管理模块里加几个可视化看板,把设备状态、订单进度、质量合格率这些数据用图表展示出来。折腾了一周多,把ECharts在若依分离版框架里跑通了,顺带把图表组件化的封装思路也整理了…

作者头像 李华
网站建设 2026/10/7 12:34:21

AI辅助UI工作流:从拼UI到生成UI的实操指南

先讲个真实感受。自从我把AI接进日常开发流程,我基本告别了“拼UI”这件事。所谓的拼UI,最苦的部分不是写代码,而是对着设计稿把一个个按钮、卡片、列表搬到页面上,反复调间距、对颜色、抠切图,做完一个弹窗还有另一个…

作者头像 李华
网站建设 2026/10/7 12:32:25

chrome-devtools-mcp:让AI编码助手通过MCP协议直接读取浏览器运行时状态

1. 为什么需要让 AI 编码助手“看见”浏览器1.1 一个真实到让人抓狂的场景前端开发里有一类问题,光看代码是永远看不出来的。比如你写了一个下拉菜单,代码逻辑完全正确,单元测试全绿,但实际在浏览器里点开的时候,菜单被…

作者头像 李华