ARTICLE DETAIL

资讯详情

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

双模 MCP 服务实战:打通 Stdio 与 Streamable HTTP 传输

双模 MCP 服务实战:打通 Stdio 与 Streamable HTTP 传输 1. 项目整体设计与思路拆解1.1 为什么你需要的不是一个“单模”MCP服务先说下背景。这几年做 AI Coding Agent 相关的工具链MCPModel Context Protocol基本是绕不开的协议了。它解决的问题很直接让 Claude、Cursor、Codex 这类 AI 应用通过一套标准化的 JSON-RPC 接口调用外部工具、读取数据库、操作文件而不是各家自己搞一套 API互不兼容。我在实际项目里遇到的情况很典型写完一个工具服务本地调试用着挺好但同事在另一台机器上想连过来跑或者我想把一个内网数据查询的能力挂到远程 Agent 上用结果发现服务只支持 Stdio 模式只能喝自己的子进程绑定在一起远端根本用不了反过来如果一个服务只支持 HTTP本地 CLI 调试又不够方便每次都要起服务、配端口。于是就有了“双模”的刚性需求。所谓双模就是让同一个 MCP 服务既能通过 Stdio标准输入输出服务以子进程方式由客户端拉起工作也能通过 Streamable HTTP服务独立监听端口客户端通过 HTTP 请求访问支持 SSE 流式返回工作。标题里这两个关键词——Stdio 和 Streamable HTTP——是当前 MCP 协议里最主流的两套传输方式。前者适合本地验证、CLI 工具、和编辑器插件配对后者适合远程协作、网关接入、以及通过 HTTP 做统一的工具暴露。我见过不少项目一上来就只奔着 HTTP 去写结果开发效率很低。因为本地调试时候HTTP 模式要管理端口、CORS、网络权限一旦断点打起来排查链路又长。而 Stdio 模式加上 MCP Inspector两分钟就能把一个工具从注册到调用串起来。因此成熟的做法是“一个核心逻辑两种传输出口”这既是这篇文章要讲的架构思路也是实际工程里性价比最高的选择。1.2 Stdio 与 Streamable HTTP 的传输差异对比这一小节不打算粘贴 RFC直接用大白话拆开讲。Stdio 的传输方式和传统的命令行程序一样客户端比如 Cursor 或 Claude Code作为父进程拉起你的服务进程然后通过 stdin 写入协议消息从 stdout 读取服务端返回。每条消息都是 JSON-RPC 格式简单干净。不需要开端口、不需要管网络协议只要进程能跑就能通信非常适合本地安全环境。缺点是边界很死只能由一台机器上的进程使用远程没法直接连。Streamable HTTP 则是把 MCP 服务变成一个常规 HTTP 服务客户端向某个 URL 发送 JSON-RPC 请求。它的名字里有“Streamable”是因为服务端可以返回 SSE 流把多次异步结果推给客户端这对长时间的 Tool Call比如查大数据集、跑脚本很友好。和早期 MCP 的 HTTPSSE 方案不同Streamable HTTP 把服务端点统一成一个不再拆成 HTTP 的 POST 和独立的 SSE endpoint 两段逻辑交互更干净也更适合现代网关、负载均衡、容器化部署。可以这么理解Stdio 是“前台直连”两个窗口对着喊话Streamable HTTP 是“营业厅窗口”客户取号排队窗口按规则处理。前者近距离安全快捷后者适合多人、跨域。对比项StdioStreamable HTTP通信载体进程标准输入/输出HTTP 请求 SSE部署形态子进程随客户端启动独立服务可容器化适用场景本地开发、CLI 工具、编辑器内嵌远程协作、webhook 处理、跨平台服务端配置无端口无网络需配置 Host、Port、CORS调试便利性配合 Inspector 直接拉起来配合 Inspector 用 URL 连接协议消息JSON-RPC over stdioJSON-RPC over HTTP在双模架构里这两者最终都能调用同一批 Tool Handler这就是设计上的核心收益。1.3 双模架构到底在做什么我常在技术分享里说一句话传输层不是业务层。写 MCP 服务时最容易犯的错就是把“工具具体做什么”和“消息怎么传输”耦合在一起。双模架构的目标是把这两层像插线板一样分开——上面插 Stdio 插头下面插 HTTP 插头中间处理业务逻辑的那部分完全不变。这里可以给一个简单的心智模型。最底层是 Tool 注册中心定义了工具名字、入参 schema、处理函数。往上走一层是 MCP 消息层负责 init、list tools、call tool 这些语义的解析和响应。再往上是传输适配层处理 stdio 流拆包、HTTP 路由、CORS、SSE 编码等。如果架构安排好双模只意味着在最顶层写两个“入口”下面所有代码都是同一套。这也是下面所有代码示例都围绕这一条主线展开的原因。现在有不少人直接说“MCP 成熟了”但也别忽视它仍在快速变化。在做双模服务之前先选定编程语言和 SDK是决定后续能否省力的关键路口。下面进入实操准备阶段。2. 环境准备与基础工具选型2.1 为什么我把 TypeScript 和官方 SDK 作为主选项做这种工具型服务我最常用的栈是 TypeScript Node.js 官方modelcontextprotocol/sdk。选择理由可以展开成三条。第一MCP 协议本身是按 TypeScript 客户端/服务端的参考实现设计的官方 SDK 更新频率最快网上搜 MCP 相关的 demo、issues 也绝大多数是 TS更容易踩对路。第二TypeScript 的静态类型对 JSON-RPC 这种结构化消息很友好工具输入输出 schema 几乎可以做到和代码定义一对一对上。第三Node.js 对子进程child_process和 HTTP 服务两种模式的支持都很成熟同一套代码里切来切去没有障碍。当然不是只有 TS 这一条路。Python 生态里近期也涌现了像fastmcp这样的库写起来代码更少适合快速原型。如果你本来就以 Python 为主完全可以用 Python 复刻这篇文章的核心思路。不过考虑到大部分 Coding Agent 插件生态、编辑器侧的 MCP 客户端都偏 Node 栈用 TS 做演示的通用性更高。选择没有对错关键是别中途换写起来最别扭的往往是“一个服务里两种语言混着搞”。2.2 初始化项目的完整步骤第一步装环境。需要 Node.js 18 或更高版本npm 9。环境里有旧版本也不慌用 nvm 做一个局部切换就行。然后按下面顺序初始化项目mkdir mcp-dual-mode cd mcp-dual-mode npm init -y npm install modelcontextprotocol/sdk zod npm install -D typescript types/node tsx这里多装了一个zod在做工具入参校验时非常有用。MCP 官方 SDK 也已经把 zod 作为一个重要依赖导出所以这个安装不会浪费。如果网络环境受限也可以在项目里用 tsup 或者 esbuild 打包成单文件部署时少很多依赖问题。接下来创建tsconfig.json{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, skipLibCheck: true }, include: [src/**/*] }有一点提醒module: NodeNext意味着你在 TS 文件里 import 本地文件时需要写.js后缀比如./tools/registry.js这是 ESM 的硬规则。第一次用的人容易在这里卡住报错“Cannot find module”。现在我们提前打了预防针后面就不会被绊倒。再配置package.json的两个字段一个是 build 脚本一个是 bin 入口{ scripts: { dev: tsx src/index.ts, build: tsc, start: node dist/index.js }, bin: { mcp-dual: ./dist/index.js } }到这里项目壳子已经搭好。下一步要把 MCP 服务和业务工具定义区分开做出一套可维护的目录结构。2.3 推荐的项目目录结构接下来这步是我这类项目里最受益的“解耦”操作。很多教程会把所有代码扔在一个index.ts里几百行摊开刚开始很爽一旦要加五六个工具就开始乱了。我建议至少建下面几层src/ index.ts # 入口负责读取运行模式并启动 server.ts # 创建 McpServer注册工具 tools/ storage.ts # 文件存储类工具读/写 system.ts # 系统信息工具 transports/ stdio.ts # 启动 Stdio 传输 http.ts # 启动 Streamable HTTP 传输 config.ts # 环境变量、常量统一管理目录结构的意义在于每个文件只干一件事传输出入口之间的公共代码自然沉淀。实际工程里一个 MCP 服务往往不只服务于“演示”还会被业务方反复改需求这时候目录清晰就是救命稻草。后面加工具时只需在tools目录动手传输出入口完全不用碰。顺带说一句config.ts我在写 MCP 服务时不喜欢把端口、host、超时时间散落在各处。集中管理的好处是别人接手项目时扫一眼就知道有哪些可配置项。实名推荐。3. 核心服务实现与双模传输打通3.1 先写两个有实际意义的业务工具为了不落进“hello world 式示例”的坑我设计了三个工具读取文件内容、写入文件内容、获取系统基本信息。这三个工具覆盖了几个常见工具类型被动查询型read_file、副作用型write_file、环境观测型system_info。做 MCP 服务的 Tool Handler重点不只在于逻辑本身还在于参数 schema 的定义是否严谨。下面先把tools/storage.ts写出来。先用 zod 定义 schema再实现处理器import { z } from zod; import { readFile, writeFile } from node:fs/promises; import { resolve, normalize } from node:path; const SAFE_ROOT process.env.MCP_FILE_ROOT || process.cwd(); function safeResolve(inputPath: string) { const full resolve(SAFE_ROOT, inputPath); const normalized normalize(full); if (!normalized.startsWith(resolve(SAFE_ROOT))) { throw new Error(路径越界禁止访问指定根目录之外的资源); } return normalized; } export const readFileSchema { path: z.string().describe(相对于 MCP_FILE_ROOT 的文件路径), }; export async function readFileHandler({ path }: { path: string }) { try { const target safeResolve(path); const content await readFile(target, utf-8); return { content: [{ type: text, text: content }], }; } catch (err: any) { return { isError: true, content: [{ type: text, text: 读取失败: ${err.message} }], }; } } export const writeFileSchema { path: z.string().describe(相对于 MCP_FILE_ROOT 的文件路径), content: z.string().describe(要写入的文件内容), overwrite: z.boolean().default(false).describe(是否允许覆盖已有文件), }; export async function writeFileHandler(args: { path: string; content: string; overwrite: boolean; }) { const target safeResolve(args.path); try { await writeFile(target, args.content, { encoding: utf-8, flag: args.overwrite ? w : wx }); return { content: [{ type: text, text: 已写入: ${target} }], }; } catch (err: any) { return { isError: true, content: [{ type: text, text: 写入失败: ${err.message} }], }; } }这里有两个非常关键的细节。第一个是“安全边界”MCP 工具一旦被 Agent 远程调用就相当于把权力交给了模型和用户。如果路径可以随便指那么一个提示词注入就可能读写服务器上的任意文件这是绝对不可以接受的。代码里的safeResolve做了根目录限定所有路径都必须在MCP_FILE_ROOT前缀下。第二个是错误返回格式Tool Handler 的内部异常不要直接抛给上层而是通过isError: true标识返回给客户端。这样 Agent 能看到“可理解的错误文本”而不是一串堆栈。system.ts就简单直接了import { z } from zod; import { cpus, totalmem, freemem, hostname, uptime } from node:os; export const systemInfoSchema {}; export async function systemInfoHandler() { const cpu cpus(); const load cpu.length; return { content: [ { type: text, text: JSON.stringify( { hostname: hostname(), cpuCount: load, totalMemMB: Math.round(totalmem() / 1024 / 1024), freeMemMB: Math.round(freemem() / 1024 / 1024), uptimeSeconds: Math.floor(uptime()), }, null, 2 ), }, ], }; }3.2 组装 McpServer 并注册工具MCP 服务端最核心的类是McpServer。它帮助你把“工具逻辑”和“协议语义”之间做了一层封装你给它工具名、schema、handler它就负责处理tools/list、tools/call这些 MCP 消息。server.ts核心代码如下import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { readFileHandler, readFileSchema, writeFileHandler, writeFileSchema, } from ./tools/storage.js; import { systemInfoHandler, systemInfoSchema } from ./tools/system.js; export function createMcpServer() { const server new McpServer({ name: dual-mode-demo-server, version: 1.0.0, }); server.registerTool(read_file, readFileSchema, readFileHandler); server.registerTool(write_file, writeFileSchema, writeFileHandler); server.registerTool(system_info, systemInfoSchema, systemInfoHandler); return server; }这里很多人会忽略的一步是给工具名字做命名规划。MCP 工具最终会出现在 Agent 的工具列表里命名应当遵循“动词_名词”的格式比如read_file、search_users、create_ticket。如果你随手写file、doSomething模型在调用时很容易混淆甚至不知道什么时候该用这个工具。工具描述也要尽量具体模型只会根据名字和描述来选择工具这两者的质量直接影响 Agent 调用准确率。systemInfoSchema给了一个空 schema。这是有意为之因为它代表“无参工具”。在注册时要把 schema 对象传给 SDK便于协议层知道这个工具不需要参数。不要省略。3.3 Stdio 传输怎么接Stdio 的接入短到让人觉得是不是漏了什么。实际上Stdio 传输的精髓就是协议消息从 stdin 来、结果往 stdout 去MCP SDK 已经把所有拆包粘包、消息顺序、错误处理都做了。你只需要一小段启动逻辑。transports/stdio.tsimport { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import type { Server } from modelcontextprotocol/sdk/server/index.js; export async function startStdio(server: Server) { const transport new StdioServerTransport(); await server.connect(transport); console.error(MCP 服务已通过 Stdio 模式启动); }有一个地方必须特别强调不要用console.log输出任何调试信息到 stdout。因为 stdout 是协议通道任何非 JSON-RPC 的内容都会污染数据流导致客户端解析失败。这也是我在项目里一直用console.error的原因。很多新手调试的时候顺手在 Handler 里打了一个console.log(JSON.stringify(result))然后会百思不得其解觉得“服务端明明输出了为什么客户端收不到”。3.4 Streamable HTTP 传输怎么接Streamable HTTP 的接入比 Stdio 要“豪华”一些因为它本质上是起了一个 HTTP 服务。这里涉及端点设计、CORS、端口、请求会话管理几个点。transports/http.tsimport { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; import type { Server } from modelcontextprotocol/sdk/server/index.js; import express from express; import { createServer } from node:http; export function startHttp(server: Server, port: number) { const app express(); app.use(express.json()); app.post(/mcp, async (req, res) { const transport new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); res.on(close, () { transport.close(); }); await server.connect(transport); await transport.handleRequest(req, res); }); app.get(/health, (_req, res) { res.json({ status: ok }); }); const httpServer createServer(app); httpServer.listen(port, () { console.error(MCP Streamable HTTP 模式已启动端口: ${port}); }); }读到这里你可能会有一个疑问每次 POST 都 new 一个 transport然后 connect 一次会不会太浪费其实在 Streamable HTTP 的模型下一次 HTTP 请求就对应一个 session 的 begin如果未携带 session id服务端会把 transport 的生命周期绑定到该次会话上。这个模式能跑但不是最优。更好的做法是维护一个transport 的 Map通过Mcp-Session-Id请求头去复用同一个 transport从而维持会话状态。下面这段代码展示带 session 复用的做法更贴近生产const transports new Mapstring, StreamableHTTPServerTransport(); app.post(/mcp, async (req, res) { const sessionId req.headers[mcp-session-id] as string | undefined; if (sessionId transports.has(sessionId)) { const transport transports.get(sessionId)!; await transport.handleRequest(req, res); return; } const transport new StreamableHTTPServerTransport({ sessionIdGenerator: () ${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}, }); transport.onclose () { if (transport.sessionId) transports.delete(transport.sessionId); }; res.on(close, () { // 这里不直接调 close避免响应结束后立刻销毁留给客户端后续请求机会 }); await server.connect(transport); await transport.handleRequest(req, res); if (transport.sessionId) { transports.set(transport.sessionId, transport); } });关于 CORS如果你希望浏览器里的 Agent 客户端直接访问还需要给 express 加上cors中间件npm install cors npm install -D types/corsimport cors from cors; app.use(cors({ origin: process.env.MCP_ALLOW_ORIGIN?.split(,) || * }));CORS 配置最好用一个环境变量来限定允许的来源而不是无条件全放行。因为 MCP 服务一旦暴露公网就相当于把工具能力开放给所有能访问到该地址的第三方站点。如果服务只在局域网内用*问题不大如果有公网访问需求建议明确 origin 白名单。3.5 统一入口把“双模”变成用户角度的“单开关”入口文件index.ts是整个项目的指挥中心。它只需做一件事读配置按模式拉起对应 transport。#!/usr/bin/env node import { createMcpServer } from ./server.js; import { startStdio } from ./transports/stdio.js; import { startHttp } from ./transports/http.js; const mode process.env.MCP_TRANSPORT || process.argv[2] || stdio; const port Number(process.env.PORT || 3000); async function main() { const server createMcpServer(); if (mode http) { startHttp(server, port); } else { await startStdio(server); } } main().catch((err) { console.error(MCP 服务启动失败:, err); process.exit(1); });这里有个小设计启动模式可以通过环境变量MCP_TRANSPORT指定也可以通过命令行参数node dist/index.js http指定。两种方式都保留是因为实际使用场景有差异编辑器插件一般通过无参数的 subprocess 方式调用此时只能用环境变量而手动测试命令行场景直接参数更顺手。注意使用 Stdio 模式时不要向 stdout 打印任何无关信息包括启动横幅和版本号。启动横幅可以用console.error打到 stderrMCP Inspector 能显示出来又不会破坏协议。到这里一个支持双模的 MCP 服务已经完整跑通。它能做的事还不多但管线已经全通任意一个工具在本地用 Stdio 调、在网络里用 HTTP 调行为完全一致。接下来最值得聊的就是实际调试和上线过程中躲不开的坑。4. 调试实践与高频问题排查4.1 用 MCP Inspector 验证两种模式MCP 官方提供了一个可视化调试工具叫做 Inspector。它既能连接 Stdio 模式也能连接 Streamable HTTP 模式。这是我强烈推荐优先掌握的调试方式。启动 Stdio 模式npx modelcontextprotocol/inspector node dist/index.js启动 HTTP 模式npx modelcontextprotocol/inspector --url http://localhost:3000/mcp在 Inspector 界面里做三件事基本就能确认服务没问题查看 Tools 列表检查工具名字和描述是否清晰。直接调用system_info看返回的 JSON 是否包含预期的 content 数组。调用write_file写入一个临时文件再调用read_file读回形成数据闭环。Inspector 的价值不只是验证功能还能帮你观察 MCP 的“原貌”——协议消息如何发送、响应如何回来、有没有isError的字段。如果对协议本身不太熟多开几轮 Inspector 比对着文档记格式更快。这也是我给所有新接触 MCP 的同事第一个建议。4.2 最常见问题Stdio 通道被无用日志污染这条我在前面已经提示过但值得单独作为一个小节来讲。MCP 的 Stdio 模式下stdout 只能传输 JSON-RPC 消息。任何第三方库如果直接往 stdout 打印内容比如某些包会默认输出版本信息、欢迎语都会把协议流搅乱。表现是客户端报类似 “parse error” 或者拿到不完整的 JSON。排查方法其实很简单先看进程输出的原始字节。在终端里直接手动运行服务node dist/index.js然后手动往 stdin 输入一行 JSON-RPC 请求观察 stdout 反馈。如果你看到服务端输出的第一行不是{jsonrpc:...}而是任何纯文本就说明有日志污染。预防方案有三个层面。第一业务代码一律console.error或使用专门写 stderr 的日志库。第二在启动子进程时让客户端侧把 stdout 定义为纯管道stderr 定义为继承或落到日志文件。第三排查第三库是否默认输出标准输出必要时重定向。这不是高深技术但几乎是所以 MCP 服务上线第一周最容易踩的坑。4.3 会话状态与请求超时的边界问题HTTP 模式下的会话管理比 Stdio 模式要复杂一个量级。因为 Stdio 的生命周期基本绑在进程生命周期上进程死了连接就挂了HTTP 模式则要处理多次请求之间的状态延续。你留意StreamableHTTPServerTransport实现后会发现它把两次 HTTP 请求通过 sessionId 关联起来。这个 session 的存在时间默认情况下取决于服务端持续多久没有收到后续请求。如果客户端在做长思考、长工具调用时超过了空闲阈值session 过期再回发请求就找不到 transport 了。实践中我的排查思路是在服务端给传输对象加上访问日志记录每次请求的 sessionId、耗时、错误码这样能快速找出“客户端在什么时候丢失了会话”。至于超时不建议单纯调长服务端空闲回收时间因为长连接会占资源。更好的方式是把 MCP 服务设计成无状态工具每次调用都自行携带所需上下文避免强依赖一个 session 内的状态。工具还能额外提供一个reset方法允许客户端主动重置会话。4.4 从客户端视角看“超时”“重试”“幂等”在真正接入 Coding Agent 时你会发现一个规律模型是在“试探性”地调用工具的。它可能连续调用三次read_file也可能在调用write_file之后立刻又调read_file验证写入结果。这个过程中任何一次网络超时都可能打断 Agent 的思考链导致它“迷茫”。所以服务端的工具 Handler 一定要做到三点响应足够快、失败时返回明确信息、副作用尽量幂等。尤其是write_file这类带副作用的工具如果客户端因为超时重发同一条请求服务端不应该追加写入两次。上面的实现里故意用了flag: wx没有overwrite参数时只在文件不存在时创建这个细节就是在为幂等性做准备。如果真的要覆盖必须显式传overwrite: true。附带一个调试经验在生产里给每个 Tool Call 加上简单的 traceId 日志方便串联客户端请求和服务端处理链路。有些大模型服务很擅长“重新描述错误”但如果服务端日志里什么都看不到你就只能靠猜。我的习惯是每一行日志都带上[toolName]和时间戳用它来定位“是不是根本没进入到 Handler”。4.5 常见问题速查表现象可能原因解决方案Stdio 连接后客户端报解析失败stdout 被日志污染将 debug 信息改用 stderr排查依赖库输出工具调用后返回空内容Handler 返回结构不符合 MCP content 格式检查返回对象是否包含content数组元素带type: textHTTP 模式返回 404POST 的 URL 路径与服务端注册的路径不一致默认统一暴露/mcp代理或网关时保持路径不变浏览器直接 POST 报 CORS服务端未配置 CORS 中间件给 express 增加cors配置 origin 白名单连续请求后客户端失去关联sessionId 丢失或者 session 过期服务端维护 transport Map客户端保持相同的 sessionId 头文件路径越界被拒工具收到路径包含..或绝对路径用resolvenormalize 前缀校验锁定根目录这张表不追求穷尽但覆盖了双模 MCP 服务落地时出现频次最高的几个问题。真遇到没有列出的情况优先打开 Inspector看协议层发生了什么比盲猜配置有效十倍。5. 实战扩展把双模服务接到真实业务场景5.1 数据库查询工具从 PostgreSQL 拿数据搜索引擎里“PostgreSQL 好用的 skill 或者 MCP”出现频率很高说明数据库接入是 MCP 服务的刚需。如果想把当前这个双模服务扩展到 PostgreSQL思路和文件工具完全一致加一个 Tool Handler专门执行只读 SQL返回结果集。// tools/database.ts import { z } from zod; import pg from pg; const pool new pg.Pool({ connectionString: process.env.DATABASE_URL }); export const querySchema { sql: z.string().describe(只读 SQL 查询语句), }; const READONLY_RE /^\s*(select|show|describe|explain)\s/i; export async function queryHandler({ sql }: { sql: string }) { if (!READONLY_RE.test(sql)) { return { isError: true, content: [{ type: text, text: 仅允许执行只读 SQL }], }; } try { const result await pool.query(sql); return { content: [{ type: text, text: JSON.stringify(result.rows) }], }; } catch (err: any) { return { isError: true, content: [{ type: text, text: 查询失败: ${err.message} }], }; } }这里透传 SQL 给模型是风险极高的行为。很多实战教程只是简单演示一下但我在生产里一定会加三层防护第一层校验 SQL 前缀只允许select/shows/describe第二层用专门的低权限数据库账号它的权限就是SELECT没有 DML 权限第三层在数据库连接池层面设置statement_timeout防止慢查询拖垮服务。处理好这三层数据库工具才敢给 Agent 用。这背后的逻辑很简单Agent 是个很强大的执行者但它也可能因为提示词注入而被引导去执行危险操作工具侧必须像门卫一样兜底。5.2 调用开放地图 API把位置能力接入 Agent同花顺、百度地图的 MCP 出现打开了“MCP 可以做融合业务”的想象空间。实际上把一些开放地图能力封装成 MCP 工具是很多人用来验证双模服务扩展性的首选。一个典型的地图服务无非是“输入地址返回坐标”和“输入坐标返回周边 POI”。它的 Handler 内部会请求外部 HTTP API再拼接一份可阅读的文本结果。这种工具的好处是对安全性要求低不涉及用户私有数据且模型调用它的频率高。它在双模服务里能很自然地测试“Stdio 本地调用 vs HTTP 远程调用”的表现差异。如果外部 API 响应变慢SSE 流式返回就有意义了。我自己用这类工具踩过一个小坑很多地图 API 的配额是按 QPS每秒请求数计算的而 Agent 一旦认为这个工具有用会在一次会话里疯狂调用十几次很容易把配额耗尽。备选方案是在服务端做一层简单的令牌桶限流或者强制设置“工具冷却时间”并在返回文本里提示“调用频率超限请稍后再试”。这些细节文档里不会写但生产环境很有价值。5.3 对接项目管理体统禅道、Jira 的 MCP 实践你如果搜过“禅道MCP”会发现国内团队已经有人在做了。坦白讲把项目管理系统封装成 MCP 工具和把文件系统封装成 MCP 工具底层逻辑一样但是业务层面要复杂得多。因为这涉及到写操作创建需求、修改任务状态、指派负责人这些操作一旦被 Agent 误触发影响就大了。我的建议是分两步走。第一步先只开放读接口查询我的待办、搜索缺陷、查看项目进度。这些工具风险低能快速让模型帮助人工做信息聚合使用体验非常炸裂。第二步再谨慎开放写接口并且写接口要加“确认参数”。比如 Agent 想要创建一条需求服务端返回结果之前让它带一个dryRun: true先预览人工确认后再真正写入。这在 MCP 协议层不需要特殊支持无非是 Handler 内部做分支。5.4 安全领域的 MCP 插件与双模服务的取舍最近几个热搜词里还出现了 IDA MCP、x32dbg 的 MCP 插件这说明安全分析工具也在拥抱 MCP。对二进制分析、逆向调试这类场景而言它的核心价值是让大模型可以直接查询反汇编结果、控制调试器、分析调用栈从而实现人机协同分析。从双模服务的角度看安全工具对传输方式的选择更敏感。本地调试器控制只能走 Stdio——你不可能把一个调试器的进程控制暴露到一个公网 HTTP 端口上这是灾难性的。反过来如果你只是想通过远程 Agent 查询一份分析报告那用 Streamable HTTP 就很合适。做好双模的意义也在于“同一个分析能力库可以按需暴露不同传输方式”。安全场景下建议在 HTTP 模式的入口前加一层鉴权中间件比如简单的 Bearer Token或者更正规的 OAuth2 代理。不要裸奔。5.5 容器化部署与多工具服务治理如果决定把双模服务里的 HTTP 模式部署到云服务器容器化几乎是必选项。Dockerfile 可以写得非常简单FROM node:20-slim WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build ENV MCP_TRANSPORThttp ENV PORT3000 EXPOSE 3000 CMD [node, dist/index.js]敲黑板容器里默认跑的是 HTTP 模式如果你需要把 Stdio 模式也容器化比如作为 sidecar 供本地工具调用只要覆盖环境变量MCP_TRANSPORTstdio即可。一个镜像同时支持两种启动方式这在交付时能极大降低运维沟通成本。更大型的团队还会引入服务注册与发现把多个 MCP 工具服务作为一个工具网关统一暴露让 Agent 只连接一个入口内部再由网关路由到不同业务服务的/mcp端点。这个设计其实和微服务网关同构只是协议换成了 JSON-RPC 和 SSE。如果业务越来越多这是值得考虑的方向。6. 一点个人体会这个项目从零到双模跑通我最大的感受不是“MCP 很神奇”而是“工具抽象比传输方式更值得花时间设计”。Stdio 和 Streamable HTTP 的接入代码量其实很小SDK 做得足够顺手真正决定一个 MCP 服务能用好久的是工具边界是否清晰、参数描述是否准确、错误信息是否可读、副作用是否可控。我自己在这类项目上吃过亏一开始只用 Stdio一个文件读写工具本地调得飞快后来一个远程同事说想连我临时加了 HTTP 模式却发现自己的工具 Handler 里写死了很多本地绝对路径远程一跑就崩。后来才把“环境相关变量”全部收敛到config.ts用环境变量注入路径、端口、根目录。再回头看提前做好“传输无关”和“环境无关”这两件事是双模服务真正省心的关键。另外有一个小细节值得认真做无论用哪种模式给每个工具 Handler 的关键路径埋一点正确格式的日志。协议层的错误往往最难排查日志就是你回头看案发现场的唯一线索。把日志打到 stderr把协议消息留在 stdout这算是我最想传递给后来者的心法。如果后续你准备在这个项目上加更多工具建议从只读类工具开始逐步扩展到写操作。每一步都确认 Agent 的实际调用效果而不是只满足于“工具能返回结果”。MCP 让模型的调用链路标准化了但工具本身的质量仍然要靠人来保证。
返回列表