ARTICLE DETAIL

资讯详情

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

从零构建双模 MCP 服务:Stdio 与 Streamable HTTP 实战

从零构建双模 MCP 服务:Stdio 与 Streamable HTTP 实战 1. 为什么我要自己动手写一个双模 MCP 服务MCP 这个词最近在开发者圈子里出现的频率越来越高但很多人第一次听到它的时候脑子里冒出来的第一个问题往往是这到底是个什么东西。简单说MCPModel Context Protocol是一套让应用程序和外部工具、数据源之间建立标准化连接的协议。你可以把它理解成AI 应用世界的 USB 接口——以前每个工具都要为每个应用单独写一套对接代码现在只要大家都遵循 MCP 这套规范就能即插即用。我在实际项目里踩过一个很典型的坑团队内部有好几个小工具有的是本地跑的脚本有的是部署在内网的服务想让它们被不同的客户端调用每次都要重新写适配层。后来接触到 MCP 之后我发现它正好能解决这个重复造轮子的问题。但市面上的教程大多只讲单一传输模式要么只讲 Stdio要么只讲 HTTP真正把两种模式整合到一个服务里的完整案例少得可怜。这篇内容就是把我从零构建一个同时支持 Stdio 与 Streamable HTTP 双模传输的 MCP 服务的完整过程记录下来。涉及的技术栈主要是Node.js 和官方 SDK适合已经对 MCP 有基本概念、想动手做一个能实际跑起来的服务的开发者。如果你还完全没接触过 MCP建议先花十分钟了解一下它的基本概念再回来看这篇会更顺。我会把每个设计决策背后的为什么讲清楚包括为什么选 Node.js、为什么传输层要抽象、双模切换时有哪些坑以及我在调试过程中遇到的那些文档里不会写的细节。目标很明确你看完之后能照着把代码跑起来并且知道每一行在干什么。2. 先把 MCP 和两种传输模式讲透2.1 MCP 到底解决了什么问题在没有 MCP 之前如果你想让一个 AI 应用调用外部能力通常的做法是写一个插件或者适配器。问题是每个应用都有自己的插件规范你为 A 应用写的工具搬到 B 应用就得重写一遍。MCP 的出现把这件事标准化了服务端只需要按照协议暴露能力客户端按照协议去发现和调用中间的对接成本被大幅降低。MCP 服务端能暴露的能力大致分三类工具Tools也就是可以被调用的函数资源Resources类似可以被读取的数据提示模板Prompts预定义的交互模板。这三类能力构成了 MCP 的核心语义层。而传输层就是这些语义如何在网络上跑起来的方式。2.2 Stdio 模式简单直接但有限制Stdio 模式是 MCP 最早、也是最容易理解的一种传输方式。它的原理非常朴素客户端启动服务端进程双方通过标准输入stdin和标准输出stdout交换 JSON-RPC 消息。服务端从 stdin 读请求把响应写到 stdout就这么简单。这种模式的优势很明显。第一是零网络配置不需要开端口、不需要处理跨域、不需要证书。第二是生命周期绑定客户端启动服务端客户端退出服务端也跟着退出不会留下孤儿进程。第三是天然的安全边界因为进程是本地启动的不暴露任何网络接口。但它的限制同样明显。Stdio 模式要求客户端和服务端在同一台机器上无法跨网络调用。而且一个服务端进程通常只服务一个客户端没法做多路复用。如果你的工具需要被多个客户端共享或者需要部署在远程服务器上Stdio 就力不从心了。2.3 Streamable HTTP 模式为远程和并发而生Streamable HTTP 是 MCP 为了支持远程调用和流式响应设计的传输模式。它基于 HTTP 协议客户端通过 POST 请求发送消息服务端可以用普通的 JSON 响应也可以用 SSEServer-Sent Events流式返回。这种设计让服务端可以在一个请求里持续推送多条消息非常适合那些需要长时间运行、分阶段返回结果的工具。和 Stdio 相比Streamable HTTP 的优势在于跨网络、可并发、可水平扩展。你可以把 MCP 服务部署在一台服务器上多个客户端同时连接。服务端也可以根据负载做多实例部署前面挂个负载均衡。代价是配置复杂度上升要处理端口、会话管理、CORS、认证等一系列网络层的问题。2.4 为什么要把两种模式做进同一个服务看到这里你可能会问既然两种模式各有适用场景为什么不干脆写两个服务我的考虑是这样的核心的业务逻辑工具、资源、提示模板是完全一样的只有传输层不同。如果写两个服务业务逻辑就要维护两份改一个工具要改两处迟早会不一致。正确的做法是把传输层抽象出来业务逻辑只写一遍。服务启动时根据配置或者命令行参数决定用哪种传输模式业务代码完全无感知。这样既保证了逻辑统一又保留了部署的灵活性。这也是我这次构建的核心思路。3. 环境准备与 SDK 选型的关键考量3.1 Node.js 版本选择与安装MCP 官方 SDK 对 Node.js 版本有要求我实测下来建议用 Node.js 20 LTS 或更高版本。原因有几个SDK 内部用到了较新的 ESM 特性和一些 Node 20 才稳定的 API用旧版本会遇到各种奇怪的兼容问题。另外 Node 20 的 LTS 支持周期长生产环境用着放心。安装方式我推荐用版本管理工具而不是直接装系统包。在 Ubuntu 上可以用 NodeSource 的源或者用 nvm 这类版本管理器。用 nvm 的好处是可以在不同项目间切换 Node 版本不会污染系统环境。安装完之后用node -v确认版本看到 v20 以上就对了。注意如果你之前装过旧版本 Node切换版本后记得重新全局安装一遍 CLI 工具否则可能出现命令找不到或者版本错乱的情况。3.2 为什么选官方 SDK 而不是自己撸协议MCP 的协议本身是基于 JSON-RPC 2.0 的理论上你完全可以自己手写消息解析和分发。但我不建议这么做原因很实在协议里有大量细节比如初始化握手、能力协商、错误码规范、流式消息的分帧自己实现很容易漏掉边界情况。官方 SDK 把这些都封装好了你只需要关注业务逻辑。官方 SDK 提供了 TypeScript 类型定义写代码的时候有自动补全和类型检查能提前发现很多低级错误。而且 SDK 会跟着协议版本更新你不用自己去追协议变更。选 SDK 就是选省心除非你有非常特殊的定制需求否则没必要重复造轮子。3.3 项目初始化与依赖清单初始化项目用标准的 npm 流程就行。核心依赖其实很少官方 SDK 是必须的如果要用 Streamable HTTP 模式还需要一个 HTTP 框架来承载服务。我选的是 Express因为它生态成熟、中间件丰富处理 CORS、日志、错误这些都很方便。除了运行时依赖开发依赖里我强烈建议加上 TypeScript 和 tsx。TypeScript 提供类型安全tsx 让你在开发时直接跑 TS 文件不用先编译改完代码立刻能看到效果开发体验提升非常明显。构建产物用 tsc 编译成 JS生产环境跑编译后的版本。依赖类型包名用途运行时modelcontextprotocol/sdkMCP 协议实现运行时expressHTTP 服务承载运行时cors处理跨域请求开发typescript类型检查与编译开发tsx开发时直接运行 TS开发types/nodeNode 类型定义开发types/expressExpress 类型定义4. 核心架构设计传输层与业务层解耦4.1 整体分层思路我在设计这个服务的时候脑子里一直有一条清晰的分界线上面是业务层下面是传输层中间用 MCP Server 实例作为桥梁。业务层负责定义有哪些工具、每个工具干什么、返回什么数据传输层负责决定这些能力通过什么方式暴露出去。具体来说我会先创建一个 MCP Server 实例把所有工具、资源、提示模板都注册到这个实例上。这个实例是传输无关的它不知道自己是跑在 Stdio 上还是 HTTP 上。然后根据启动参数选择把不同的传输对象连接到这个实例上。Stdio 模式用 StdioServerTransportHTTP 模式用 StreamableHTTPServerTransport。这种设计的好处是业务代码零改动就能切换传输模式。今天本地调试用 Stdio明天部署到服务器改成 HTTP业务逻辑一行都不用动。这也是我坚持要做双模的根本原因。4.2 工具注册的统一入口为了让代码结构清晰我把所有工具的注册逻辑集中在一个函数里。这个函数接收 MCP Server 实例然后依次注册每个工具。每个工具的定义包含三部分名称和描述让客户端知道这个工具是干什么的、输入参数的 schema用 Zod 定义SDK 会自动转成 JSON Schema、执行函数真正干活的逻辑。用 Zod 定义参数 schema 是个很舒服的选择因为它既能做运行时校验又能推导出 TypeScript 类型。你在执行函数里拿到的参数是类型安全的不用手动去解析和转换。SDK 会在调用工具前自动校验参数参数不合法直接返回错误你的执行函数根本不会被调用省去了大量防御性代码。4.3 传输层的抽象与切换逻辑传输层的切换逻辑我放在入口文件里。启动时读取环境变量或者命令行参数判断用哪种模式。如果是 Stdio就创建 StdioServerTransport 并连接如果是 HTTP就启动 Express 服务在特定路由上挂载 StreamableHTTPServerTransport。这里有个细节值得说HTTP 模式下每个会话应该对应一个独立的 transport 实例。因为 Streamable HTTP 是有状态的一个会话里的初始化握手、能力协商结果需要被记住。如果所有请求共用一个 transport会话之间会互相干扰。SDK 提供了会话管理的机制我按照官方推荐的方式用会话 ID 来区分不同的 transport 实例。5. 业务层实现把工具写扎实5.1 定义一个能实际干活的工具光有框架没有工具是跑不起来的我定义了几个有代表性的工具来演示。第一个是文本处理工具接收一段文本和一个操作类型比如转大写、统计字数、反转返回处理结果。这个工具足够简单但能完整展示参数定义、校验、执行的整个流程。第二个是时间工具返回当前服务器时间支持指定时区。这个工具演示了如何处理可选参数和默认值。第三个是计算工具接收一个数学表达式并返回计算结果。这个稍微复杂一点因为要处理表达式解析和错误情况。每个工具的执行函数我都会返回一个标准格式的结果对象里面包含 content 数组。content 里可以是文本也可以是其他类型。如果执行出错我会返回 isError 标记为 true 的结果而不是直接抛异常。这样客户端能拿到结构化的错误信息而不是一个笼统的失败。5.2 参数校验与错误处理参数校验这块Zod schema 是第一道防线。但有些校验 Zod 做不了比如这个字符串必须是一个合法的数学表达式这种就得在执行函数里手动检查。我的原则是能在 schema 层拦住的就在 schema 层拦拦不住的尽早检查错误信息要具体。错误处理我遵循一个原则对客户端友好对日志详细。返回给客户端的错误信息要简洁明了告诉它哪里错了、怎么改写到日志里的错误信息要包含堆栈、上下文、输入参数方便我排查问题。这两者不能混为一谈把堆栈直接返回给客户端既不安全也不友好。实操心得工具执行函数里尽量不要用 console.log 输出调试信息因为 Stdio 模式下 stdout 是协议通道你往 stdout 写任何非协议内容都会破坏通信。调试信息一律走 stderr或者用专门的日志库写到文件。5.3 资源与提示模板的注册除了工具MCP 还支持资源和提示模板。资源适合暴露那些可读取的数据比如配置文件、文档、数据库查询结果。我注册了一个资源返回服务的版本信息和可用工具列表客户端可以读取它来了解服务能力。提示模板适合预定义一些常用的交互模式。我注册了一个代码审查的提示模板接收代码片段和语言类型生成一个结构化的审查请求。提示模板的价值在于把怎么问这件事标准化让不同客户端得到一致的交互体验。6. Stdio 模式的完整实现与调试6.1 Stdio 传输的接入方式Stdio 模式的实现其实非常简洁。创建 StdioServerTransport 实例然后调用 server.connect(transport) 就完成了。之后 SDK 会自动从 stdin 读取消息、分发到对应的处理器、把响应写到 stdout。你不需要手动处理任何读写逻辑。但简洁不代表没有坑。最大的坑就是前面提到的stdout 污染问题。任何你不小心写到 stdout 的内容哪怕是一个多余的换行都会被客户端当成协议消息去解析导致解析失败。所以 Stdio 模式下所有日志必须走 stderr。我在项目里统一用了一个 logger 封装确保不会误用 console.log。6.2 本地调试的实用技巧调试 Stdio 服务有个很实用的方法用 echo 管道手动喂消息。你可以构造一个 JSON-RPC 格式的初始化请求通过管道传给服务进程观察它的输出。这样能快速验证服务是否正常启动、协议握手是否成功。更高效的方式是用官方的 Inspector 工具。它能以图形界面连接你的 Stdio 服务列出所有工具让你手动调用并查看结果。调试工具定义的时候Inspector 能省下大量时间。我一般在写完一个工具后先用 Inspector 验证一遍确认没问题再集成到客户端里。6.3 进程生命周期管理Stdio 模式下服务进程的生命周期由客户端控制。客户端启动进程进程运行客户端关闭连接进程应该优雅退出。我在代码里监听了 stdin 的 end 事件和进程的 SIGINT、SIGTERM 信号确保收到退出信号时能清理资源、关闭连接。这里有个容易忽略的点如果服务里有定时器或者长连接退出前一定要清理掉否则进程可能挂住不退出。我在实际项目里遇到过因为一个没清理的 setInterval 导致进程无法正常退出的情况排查了半天才发现。所以现在写任何异步资源我都会配套写好清理逻辑。7. Streamable HTTP 模式的完整实现7.1 HTTP 服务的搭建与会话管理HTTP 模式的搭建比 Stdio 复杂不少。首先要起一个 Express 服务监听指定端口。然后要处理 MCP 的特定路由。Streamable HTTP 模式下客户端会向一个端点发送 POST 请求服务端根据请求头里的会话 ID 来决定用哪个 transport 处理。会话管理的核心逻辑是第一次请求初始化请求没有会话 ID服务端创建一个新的 transport 和会话把会话 ID 通过响应头返回给客户端后续请求带上这个会话 ID服务端找到对应的 transport 继续处理。会话要有超时机制长时间不活动的会话要清理掉否则内存会一直涨。7.2 SSE 流式响应的处理Streamable HTTP 的一个亮点是支持 SSE 流式响应。当工具执行时间较长、需要分阶段返回结果时服务端可以先返回一个 SSE 流然后陆续推送消息。客户端收到流之后逐条处理不用等所有结果都准备好。实现流式响应要注意几点响应头要正确设置 Content-Type 为 text/event-stream每条消息要以特定格式发送连接要保持打开直到所有消息推送完毕。SDK 的 StreamableHTTPServerTransport 已经封装了这些细节你只需要在工具执行函数里通过 transport 发送消息即可。7.3 CORS 与安全配置HTTP 模式暴露在网络里安全配置不能马虎。CORS 要按需配置不要图省事用通配符允许所有来源。如果服务只给特定客户端用就把允许的来源限制死。生产环境还应该加上认证比如要求请求头里带 API Key验证通过才处理。另外要注意请求体大小限制。MCP 消息一般不大但如果不限制恶意请求可能发一个巨大的 body 把服务打挂。Express 的 json 中间件可以设置 limit我一般设成 1MB 左右足够正常使用又不会太危险。端口选择上开发环境随便用生产环境要避开常用端口并且配合防火墙规则。配置项开发环境建议生产环境建议CORS 来源允许 localhost限制为具体域名认证可省略必须加 API Key请求体限制1MB1MB 或更小会话超时30 分钟5-10 分钟日志级别debuginfo 或 warn8. 双模切换与配置管理8.1 用环境变量控制启动模式双模切换我通过环境变量来实现。启动时读取 MCP_TRANSPORT 变量值是 stdio 就走 Stdio 分支值是 http 就走 HTTP 分支。端口、主机、会话超时这些也都可以通过环境变量配置。这样做的好处是同一份代码不同环境用不同的启动配置不需要改代码。为了让配置管理更规范我写了一个配置加载模块集中读取所有环境变量提供默认值并做基本的合法性校验。比如端口必须是数字且在有效范围内传输模式必须是已知的值。配置有问题就在启动时报错退出而不是运行到一半才出问题。8.2 两种模式的测试策略测试双模服务要分两层。业务逻辑层用单元测试直接调用工具的执行函数验证输入输出是否符合预期这层测试不涉及传输跑得快。传输层用集成测试Stdio 模式通过管道喂消息验证HTTP 模式用 HTTP 客户端发请求验证。我特别建议给两种模式都写一个冒烟测试启动服务发一个初始化请求列一下工具调用一个简单工具确认返回正常。这个测试能在几秒内跑完但能覆盖最核心的链路。每次改完传输相关代码先跑冒烟测试能快速发现回归问题。8.3 部署时的注意事项部署 Stdio 模式的服务其实就是把可执行文件放到目标机器上配置好客户端的启动命令。要注意的是路径问题客户端启动服务时的工作目录可能和你预期的不一样所以代码里读取文件要用绝对路径或者基于 __dirname 解析。部署 HTTP 模式要考虑的就多了进程管理用 systemd 或者 pm2 保证挂了能自动重启、日志收集stdout 和 stderr 重定向到日志文件或者日志系统、健康检查加一个 /health 端点供监控探活、反向代理如果前面有 Nginx要注意 SSE 需要关闭缓冲。这些每一项都有坑建议部署前先在测试环境完整走一遍。9. 常见问题排查与避坑实录9.1 启动就报错怎么办最常见的问题是依赖没装全或者版本不对。如果报错提到某个模块找不到先检查 package.json 里的依赖是否都装了。如果报错提到语法或者 API 不存在大概率是 Node 版本太低。我遇到过有人用 Node 16 跑结果 SDK 里的某个 API 不存在升级到 20 就好了。另一个常见问题是端口被占用。HTTP 模式启动时报 EADDRINUSE说明端口已经被别的进程占了。用 lsof 或者 netstat 查一下是谁占的要么杀掉那个进程要么换个端口。开发环境我习惯用一个不常用的高位端口避免和系统服务冲突。9.2 客户端连不上服务Stdio 模式下客户端连不上先检查启动命令的路径对不对。客户端执行启动命令时的工作目录往往不是项目目录所以命令里的相对路径会失效。解决办法是用绝对路径或者在启动脚本里先 cd 到正确目录。HTTP 模式下连不上按顺序排查服务是否真的起来了看日志、端口是否监听正确netstat 确认、防火墙是否放行、客户端配置的地址和端口是否和服务一致。如果服务在本机但客户端在容器里还要注意容器网络的问题localhost 在容器里指向的是容器自己不是宿主机。9.3 工具调用返回异常工具调用返回异常先看参数是否符合 schema。SDK 会在调用前校验参数如果参数不合法执行函数根本不会被调用返回的是校验错误。这种情况检查客户端传的参数类型和格式对照工具定义的 schema 看哪里不匹配。如果参数没问题但执行函数报错就要看执行函数内部的逻辑了。我建议在执行函数里加详细的日志记录输入参数和关键步骤。有时候问题出在外部依赖上比如调用的 API 超时、读取的文件不存在。这类问题日志里一般能看到线索。现象可能原因排查方向启动报模块找不到依赖未安装重新 npm install启动报 API 不存在Node 版本过低升级到 Node 20端口占用端口冲突换端口或杀进程Stdio 无响应stdout 被污染检查日志输出位置HTTP 连不上网络或配置问题逐层排查网络工具返回校验错误参数不符合 schema对照 schema 检查参数会话丢失会话超时或未带 ID检查会话管理逻辑9.4 性能与稳定性优化服务跑起来之后如果发现响应慢或者内存涨可以从几个方向优化。工具执行函数里避免阻塞操作耗时的计算或者 IO 要异步处理不要卡住事件循环。会话要及时清理HTTP 模式下不活动的会话占着内存设置合理的超时时间。日志量大的时候要注意日志级别控制生产环境不要开 debug 级别否则日志文件会迅速膨胀。我一般生产环境用 info 级别只在排查特定问题时临时调到 debug。另外可以给日志加上轮转避免单个文件过大。10. 我在这套方案里踩过的坑和总结的经验回过头看这个双模 MCP 服务从设计到跑通花的时间比预想的多主要卡在几个地方。第一个是传输层的会话管理一开始我没理解 Streamable HTTP 的有状态特性所有请求共用一个 transport结果多个客户端连上来之后互相干扰排查了很久才意识到问题。后来改成按会话 ID 隔离问题就解决了。第二个坑是Stdio 模式的 stdout 污染。我在一个工具里顺手写了 console.log 调试本地测试的时候没发现异常因为我是直接看终端输出的。结果集成到客户端之后客户端解析协议消息失败报了一堆莫名其妙的错。后来把所有 console.log 换成 stderr 输出才恢复正常。这个教训让我养成了 Stdio 模式下绝不用 stdout 的习惯。第三个是配置管理。一开始我把端口、模式这些硬编码在代码里每次切换都要改代码重新编译很烦。后来改成环境变量驱动配合一个配置校验模块切换模式只需要改一个环境变量体验好了很多。这个改动虽然小但对日常开发和部署效率的提升很明显。如果你也要做类似的双模服务我的建议是先把业务逻辑和传输层彻底分开再分别实现两种传输。不要一开始就想着两种模式一起写那样容易乱。先把 Stdio 跑通因为简单能快速验证业务逻辑然后再加 HTTP这时候业务逻辑已经稳定了你只需要专注传输层的问题。分阶段推进每一步都有可验证的成果比一锅端要靠谱得多。另外测试一定要覆盖两种模式。我见过有人只测了 Stdio部署到 HTTP 环境才发现一堆问题。两种模式的差异主要在传输层但恰恰是传输层最容易出问题。冒烟测试花不了多少时间但能帮你挡住大部分低级错误。
返回列表