news 2026/10/9 7:17:46

双模MCP服务实战:Stdio本地调试与Streamable HTTP远程部署

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
双模MCP服务实战:Stdio本地调试与Streamable HTTP远程部署

第一次把MCP服务跑通的时候,我用的是Stdio传输:客户端直接拉起一个子进程,消息从标准输入进去,结果从标准输出出来,整个过程像两个人通过一根管子递纸条。这套流程本地开发确实很爽,配置简单、没有网络端口、不用考虑鉴权,但一旦你想把同一个服务部署到远程、供多个MCP客户端共用,Stdio立刻捉襟见肘——你不可能让每个远端调用都临时spawn一个进程。这时候就该Streamable HTTP出场了。

这篇教程就是围绕“同一套工具逻辑,同时支持Stdio和Streamable HTTP两种传输入口”展开的。适合两类人:一是写过MCP服务但只在本地Stdio里跑通、想把它搬到服务器上的开发者;二是刚接触MCP、想搞清楚两种传输模式到底怎么选怎么写的朋友。我会从工程结构讲起,把核心代码、验证链路、真实踩坑全部拆开讲,保证你照着做也能从零构建一个双模MCP服务。

1. 先搞清楚双模到底解决什么问题

1.1 两种传输的定位差异

MCP(Model Context Protocol)解决的核心问题是:让AI模型通过一套标准协议去调用外部工具和数据源。而“传输层”解决的是更底层的问题——协议消息怎么从客户端跑到服务端。

Stdio传输的本质是进程间通信。MCP客户端(比如桌面应用、IDE插件)在你的机器上直接启动一个子进程,把MCP服务跑起来,然后通过进程的标准输入和标准输出传递JSON-RPC消息。它的特点是:

  • 零网络配置,没有端口、没有IP、没有防火墙
  • 进程生命周期由客户端管理,关掉客户端进程自然结束
  • 天然安全,文件系统权限、网络权限都局限于当前用户环境
  • 一个服务进程只能服务一个客户端,因为stdin/stdout是一对一的管子

Streamable HTTP传输则是把MCP服务做成一个常驻的HTTP端点。客户端通过HTTP POST发送请求,服务端除了在POST响应里返回结果,还可以通过SSE(Server-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官方SDK

MCP官方提供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_MODE=stdio 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版本决定,一般不用手动干预。

你只需要知道握手过程长什么样,方便你调试时对号入座。标准流程是:

  1. 客户端发送initialize请求
  2. 服务端返回sokuyo协议版本、服务端能力列表(支持哪些工具/资源)、服务端信息
  3. 客户端发送notifications/initialized通知,表示握手完成
  4. 客户端可以发送tools/list获取工具列表
  5. 客户端发送tools/call调用具体工具

在HTTP模式下,第1步的响应头里会带MCP-Session-Id,客户端拿到这个值后,后续请求都要在请求头里带上它,服务端才能识别是同一个会话。

4. 验证链路:从本地Inspector到远端curl

4.1 用官方调试面板验证Stdio模式

MCP官方提供了一个图形化调试工具,叫Inspector,跑起来非常简单:

npx @modelcontextprotocol/inspector

启动后会看到面板地址。在配置页里选择STDIO传输类型,填上命令和参数,比如Command填npx,Arguments填tsx src/index.ts,Environment变量里加上MCP_MODE=stdio。点击连接后,左侧会显示整个交互过程:

  • initialize的请求和响应内容
  • tools/list返回的工具列表
  • 调用工具后返回的完整消息

我的习惯是连接后先看一眼tools/list的JSON结构,确认工具描述和参数Schema都正确传上去了,再逐个调用工具验证返回结果。如果你在Inspector里看到工具列表出现乱码,多半是第5章要讲的stdout污染问题。

4.2 用curl手动验证HTTP模式

启动HTTP模式后:

MCP_MODE=http PORT=3000 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/list:

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":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过一遍Stdio,curl过一遍HTTP,再找个真实客户端验证接入配置,三轮走完基本不会出大问题。至于远程部署后的监控,重点关注HTTP模式的session数量变化、内存增长曲线和错误率这几个指标,比盯着日志文件名有用得多。

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

Vue3 + TypeScript 项目实战:类型设计、组件通信与后台系统实践

前几天有个后端转前端的朋友问我&#xff1a;“现在搞 Vue3 是不是必须用 TypeScript&#xff1f;” 我反问他&#xff1a;“你写 Java 的时候会故意不写类型吗&#xff1f;” 他笑了笑。实际开发里&#xff0c;TypeScript 确实不是 Vue3 的强制选项&#xff0c;但只要你打开 V…

作者头像 李华
网站建设 2026/10/9 7:17:03

DRV8701电流闭环驱动空心杯电机实战指南

简介&#xff1a;本资源是面向智能车竞赛&#xff08;如C车、F车&#xff09;开发者的DRV8701电机驱动器工程实践套件&#xff0c;聚焦直流无刷与步进电机的高效、安全驱动需求&#xff0c;适用于嵌入式硬件工程师及高校智能车参赛学生。压缩包共6个文件&#xff0c;含3个JSON格…

作者头像 李华
网站建设 2026/10/9 7:16:55

不锈钢水管十大品牌榜单解析:秦西盟第三名背后的选型硬指标

这两年不锈钢水管行业最不缺的就是各种榜单&#xff0c;今天一个“十大品牌”&#xff0c;明天一个“行业标杆”&#xff0c;说实话圈内人多少有些审美疲劳。但最近这份“全国不锈钢水管行业十大品牌”名单出来之后&#xff0c;好几个做工程的老朋友都跑来问我同一个问题&#…

作者头像 李华
网站建设 2026/10/9 7:16:14

虚幻引擎Boss战特效实战:用Niagara拆解雷兽元素爆发

画面里漫天雷光、元素爆破、地面碎裂的瞬间&#xff0c;很多玩家的第一反应是“帅”&#xff0c;而做游戏特效的人第一反应往往是另一个问题&#xff1a;这一整套Boss战特效&#xff0c;到底由多少个粒子、多少张Flipbook贴图、多少个Draw Call堆出来的&#xff1f;它的性能预算…

作者头像 李华
网站建设 2026/10/9 7:15:45

CH340、CP2102、FT232 USB转串口芯片深度横评与选型避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/9 7:15:45

ESP32-P4裸机跑LLM:RISC-V端侧推理实战与tok/s优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华