ARTICLE DETAIL

资讯详情

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

MCP Server 进阶指南:传输选型、错误处理、流式输出与部署实践

MCP Server 进阶指南:传输选型、错误处理、流式输出与部署实践 很多人第一次写完 MCP server都是照着 cookbook 搭一个 hello world本地跑通了就觉得自己会了。但等到真把工具丢给 agent 用、部署到服务器上问题才冒出来工具一报错客户端根本看不懂、跑了几十秒的长任务让 agent 等到超时、TypeScript 里any泛滥结果运行期直接翻车、好不容易写完代码又不知道怎么部署。这篇我不重复入门教程只聊真正进阶的四个点传输方式选型、错误处理、流式输出、TypeScript 类型安全最后再给一套能落地的部署方案。全文基于modelcontextprotocol/sdk TypeScript代码可以直接抄适合已经写过至少一个 MCP server 的开发者。1. 先选对传输方式再写代码stdio、Streamable HTTP 与 SSE 的取舍1.1 stdio 和 HTTP 各自解决什么问题MCP 官方 SDK 里最常见的两种传输是 stdio 和 Streamable HTTP。很多教程一上来就让你复制一个 stdio server确实stdio 方案很优雅进程间通过标准输入输出传 JSON-RPC 消息没有端口冲突、没有网络权限问题本地调试极其顺手。但它的主要使用场景是本机单客户端——由 agent 客户端拉起子进程走完生命周期就结束。你想把它放到一台服务器上让多个 agent、多个团队共享stdio 会非常别扭因为你得在每个客户端环境里配置启动命令、管理进程状态。HTTP 传输解决的是跨机器调用的问题。服务独立运行监听一个端口任何地方的客户端都能通过 HTTP 来调用。Streamable HTTP 是当前推荐方向它把 JSON-RPC 消息包装成 HTTP 请求同时用 SSEServer-Sent Events做服务端到客户端的持续推送。注意它不是 WebSocket是单向长连接用来主动推送事件非常合适。维度stdioStreamable HTTP适用场景本机、单客户端远程、多客户端、容器化部署进程模型父进程拉起子进程独立常驻服务鉴权基本无继承父进程权限可加 Bearer Token、OAuth网络要求不需要网络端口需要监听 HTTP 端口调试方式看 stdout 日志curl 直接发请求多客户端共享不合适天然支持这俩不是替代关系是互补关系。我自己本机调试和给个人 Desktop 客户端用就走 stdio一旦要部署到服务器、接入团队内部的智能体平台就走 HTTP。1.2 项目入口怎么同时支持两种传输模式实操里最省心的做法不是维护两个项目而是写一个入口用环境变量控制使用哪种 transport。下面这段代码是行为示意不同 SDK 版本的 HTTP transport 初始化参数会变最终以你安装的版本文档为准。import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; import express from express; const server new McpServer({ name: my-ops-assistant, version: 1.0.0, }); // 在这里批量注册 tools、resources、prompts registerTools(server); async function main() { const transportType process.env.TRANSPORT ?? stdio; if (transportType http) { const app express(); app.use(express.json()); app.post(/mcp, async (req, res) { // 每次 POST 实际是一次 JSON-RPC 消息交换 // 具体写法看 SDK 版本关键是管理好 session const transport new StreamableHTTPServerTransport(); await server.connect(transport); // 将 req/res 交给 transport 处理 // ... }); const port Number(process.env.PORT ?? 3001); app.listen(port, () { console.log(MCP HTTP server listening on ${port}); }); } else { const transport new StdioServerTransport(); await server.connect(transport); } } main().catch((err) { console.error(fatal error, err); process.exit(1); });这里有个很容易踩的坑不要试图在同一个进程里同时 connect 两个 transport。SDK 内部是按 transport 管理 session 状态的混用会导致消息莫名其妙丢失。我一开始图省事把两种 transport 全部初始化了结果 HTTP 客户端连上来能握手但收不到工具结果浪费了半天排查。1.3 选型判断清单我后来把选型收敛成几个问题照着判断就不会纠结客户端是运行在用户本机的桌面 Agent 吗是 → stdio 优先配置简单。服务需要部署到 Docker、K8s 或虚拟机吗是 → HTTP。服务会被多个团队、多个 Agent 同时调用吗是 → HTTP。需要做细粒度鉴权、访问控制、审计日志吗是 → HTTP 或至少前面挂一层网关。只是自己写脚本测试是 → stdio最快。不要因为HTTP 更高级就强行上 HTTP本机调试用 stdio 能省掉大量端口、鉴权、session 的麻烦。反过来服务一旦要长期运维就别用 stdio 硬撑进程没人拉起、日志没人收集、崩溃没人知道那才是灾难。2. 错误处理如何把异常翻译成 MCP 协议能听懂的语言2.1 协议层错误和业务层错误是两码事MCP 底层是 JSON-RPC 2.0协议层的错误码是固定的-32700解析错误、-32600无效请求、-32601方法不存在、-32602参数无效、-32603内部错误。这些错误表示请求在这个协议层面就没被正确理解或执行SDK 会在底层捕获并返回 error response。但业务层错误是另一回事。比如用户查一个不存在的订单号、调用第三方 API 返回 401、生成报告超时。这些请求本身是合法的 JSON-RPC 请求协议层不会报错只有业务逻辑里知道这个订单号不存在。很多开发者把这两类混在一起全部 throw 一个Error(something went wrong)客户端只能看到一个模糊的 internal error根本没法判断是参数问题、权限问题还是服务端炸了。2.2 用 McpError 抛协议层错误对于协议层的参数校验失败、请求格式错误建议直接用 SDK 导出的McpError不要自己拼一个普通 Error。import { McpError, ErrorCode } from modelcontextprotocol/sdk/types.js; server.tool( get_order, { orderId: z.string() }, async ({ orderId }) { if (!orderId) { throw new McpError( ErrorCode.InvalidParams, orderId cannot be empty ); } // ... } );这样客户端能拿到标准 JSON-RPC 错误结构而不是一段被包装得面目全非的堆栈。SDK 在工具 handler 内部抛出的普通 Error最终会被转成-32603internal error虽然不会让连接断开但错误信息里往往是堆栈对客户端来说没有可读性。想让客户端根据错误码做分支判断就用规范的McpError。2.3 业务错误放进结果而不是异常真正业务上的失败不要抛异常而是作为工具返回值返回并在结果里标记isError: true。server.tool( get_order, { orderId: z.string() }, async ({ orderId }) { const order await db.orders.find(orderId); if (!order) { return { content: [{ type: text, text: 订单 ${orderId} 不存在 }], isError: true, }; } return { content: [{ type: text, text: JSON.stringify(order) }], }; } );为什么这样做因为 agent 拿到工具返回值后会把它当作执行结果来理解你返回订单不存在agent 就知道要换一个参数或者直接告诉用户。但如果你抛一个协议错误很多客户端会把这次调用标记为链路故障agent 可能反复重试同一个请求体验非常差。简单记协议错误表示请求无法处理业务错误表示请求处理了但结果不满足。前者用McpError后者返回值加isError。2.4 兜底异常处理与日志关联不管你写得再小心总会有没预料到的异常。我在项目里会为 HTTP transport 加一层全局兜底app.post(/mcp, async (req, res) { try { // 交给 MCP transport 处理 } catch (err) { const errBody { jsonrpc: 2.0, id: req.body?.id ?? null, error: { code: ErrorCode.InternalError, message: internal server error, data: { requestId: randomUUID(), }, }, }; logger.error({ msg: mcp request failed, requestId: req.body?.id, stack: err instanceof Error ? err.stack : String(err), }); res.status(500).json(errBody); } });重点在于日志里带上requestId这样用户反馈问题后你能根据一个 id 快速定位那一次请求发生了什么。日志建议输出成 JSON方便接入日志平台别用一行行拼字符串。很多线上事故排查慢不是因为代码复杂而是日志里搜不到关联字段。3. 流式输出让耗时工具不再是黑盒等待3.1 先破一个误解工具结果不是逐字流式的很多人在社区里问MCP 工具能不能像 ChatGPT 那样一个字一个字吐出来答案非常直接不能。MCP 里一个 tool call 的最终响应仍然是一个完整的 JSON-RPC 响应不存在响应先发一半再追加另一半这种机制。你看到 agent 打字机一样的效果那是 LLM 生成的 token 在流式输出不是工具结果在流式输出。工具结果对 LLM 来说是一个整体一次性进入上下文。所以不要一门心思想着改造传输层让工具结果流式化方向就错了。HTTP 的 SSE 确实提供了流式通道但那是为了服务端主动推送事件、资源变更通知不是为了把 tool result 切成碎片。3.2 用进度通知让客户端知道还在跑MCP 协议支持$/progress通知。如果你的工具执行时间达到几秒以上可以通过服务端发进度通知客户端就能渲染正在生成报告 45%这类反馈。核心代码如下SDK 不同版本 API 略有差异思路一致server.tool( generate_report, { topic: z.string() }, async ({ topic }, extra) { const steps 10; for (let i 1; i steps; i) { // 模拟耗时步骤 await sleep(500); // 发送进度通知total 表示总步数progress 表示当前进度 extra.server.emitNotification?.({ jsonrpc: 2.0, method: notifications/progress, params: { progress: i, total: steps, progressToken: extra.progressToken, }, }); } return { content: [{ type: text, text: report done }], }; } );要注意进度通知必须发生在 handler 返回之前。因为 handler 一旦返回这次请求的上下文就结束了你再想通过同一个连接发消息SDK 不保证能发出去。实际跑下来这个机制对客户端体验提升很大agent 不会把一个没有任何反馈的长请求误判为卡死。3.3 更稳的长任务模式异步提交 状态查询如果任务超过 10 秒光靠进度通知还不够因为客户端在等待单次 tool call 响应时往往有自己的超时阈值。我在实际项目里推荐拆成两个工具一个提交任务一个查询结果。const jobs new Mapstring, Job(); server.tool( submit_report, { topic: z.string() }, async ({ topic }) { const taskId crypto.randomUUID(); jobs.set(taskId, { status: running, progress: 0, createdAt: Date.now() }); // 后台异步执行不阻塞当前响应 startBackgroundJob(taskId, topic); return { content: [{ type: text, text: JSON.stringify({ taskId }) }], }; } ); server.tool( get_report, { taskId: z.string() }, async ({ taskId }) { const job jobs.get(taskId); if (!job) { throw new McpError(ErrorCode.InvalidParams, 任务不存在或已过期); } return { content: [{ type: text, text: JSON.stringify(job) }], }; } );这个模式不新鲜但放到 MCP 场景里很多人想不到。它把一个长任务拆成了多个短请求agent 拿到 taskId 后可以轮询等状态变成succeeded再拿结果。对 agent 来说每次调用都是毫秒级响应不会超时对服务端来说后台任务可以丢到队列里慢慢执行还能支持任务取消、重试。3.4 传输层 SSE 到底什么时候才会用上Streamable HTTP 里的 SSE 流服务端在收到客户端请求后建立一条长连接之后可以持续推送消息。这个能力适合哪些场景资源变化通知、日志实时推送、任务完成事件。比如你有一个监控服务器状态的工具工具本身不返回最终结果而是注册一个持续推送的通道让客户端能在状态变化时收到通知——这时候 SSE 才真正派上用场。所以我的建议是做长任务先上进度通知再上任务队列 轮询。跑通了这两步再去研究传输层 SSE 的高级玩法。不要一开始就改造传输层否则问题会非常复杂。4. TypeScript 开发中的类型安全细节4.1 让 zod 成为参数 schema 的唯一来源写 MCP server 最爽的事是 SDK 原生支持 zod。你用 zod 定义一个对象既能作为工具参数 schema 自动生成 JSON Schema又能推导出 TS 类型传给 handler。关键是一定要只维护一份定义不要手写 interface 和 zod 两套东西。import { z } from zod; const SearchInput z.object({ keyword: z.string().min(1).describe(搜索关键词), limit: z.number().int().min(1).max(50).default(10).describe(返回数量), }); server.tool(search_faq, SearchInput.shape, async ({ keyword, limit }) { // 这里 keyword 和 limit 的类型已经由 zod 推导出来了 const results await searchFaq(keyword, limit); return { content: [{ type: text, text: JSON.stringify(results) }] }; });我见过不少项目SDK 的参数类型写了一个 interfacezod 又写一遍结果改字段的时候漏改一边工具调用时参数校验不过。单一数据源能直接消掉这类问题。4.2 外部数据进来先 parse不要直接 as调用外部 API 返回的数据千万不要用as SomeType强转。那只是骗 TypeScript 编译器运行期该炸还是炸。正确做法是定义 zod schema用parse做运行时校验const ExternalResponseSchema z.object({ code: z.number(), data: z.array(z.object({ id: z.string(), title: z.string(), })), }); const raw: unknown await fetch(url).then(r r.json()); const parsed ExternalResponseSchema.parse(raw);这样一旦外部接口结构变了你的 MCP server 会立刻抛错而不是把脏数据发给 agent。尤其在 MCP 场景里agent 会根据工具返回内容生成下一步动作数据格式一旦漂移后果会被放大。4.3 共享 context 不要用可选参数一个个传MCP server 里很多工具都需要数据库连接、缓存、日志器这类共享依赖。新手容易在 zod 输入里加一堆工具永远不该接收的参数或者每个 handler 都从闭包变量里拿。我的做法是定义一个AppContext在服务启动时创建一次注册工具时用闭包把 context 注入。export interface AppContext { db: Database; logger: Logger; config: Config; } export function createServer(ctx: AppContext) { const server new McpServer({ name: my-app, version: 1.0.0 }); server.tool(get_user, { userId: z.string() }, async ({ userId }) { ctx.logger.info({ userId }, get_user called); const user await ctx.db.users.find(userId); return { content: [{ type: text, text: JSON.stringify(user) }] }; }); return server; }这样的好处是 context 类型明确测试的时候可以很方便传入 mock 的 db 和 logger不需要真的连数据库。这个习惯在项目变复杂之后价值非常大。4.4 tsconfig 不要图省事MCP SDK 现在以 ESM 为主tsconfig 里module和moduleResolution建议直接上NodeNext同时开strict。下面是常用配置{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, strict: true, outDir: dist, rootDir: src, sourceMap: true, esModuleInterop: true, skipLibCheck: true }, include: [src] }CI 里至少跑一次tsc --noEmit很多低级类型错误在合并前就能拦下来。我见过有人在代码里用require结果 ESM 项目里运行时直接报require is not defined这类问题提前做类型检查多少能注意到一些。5. 从本机到生产部署 Docker 化、进程管理与安全配置5.1 多阶段构建一个尽量小的镜像如果你的 MCP server 走 HTTPDocker 化部署是最省心的方式。下面这个 Dockerfile 是多阶段构建第一阶段用来编译 TypeScript第二阶段只保留运行所需文件和生产依赖。FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY tsconfig.json ./ COPY src ./src RUN npm run build FROM node:20-alpine WORKDIR /app ENV NODE_ENVproduction COPY package*.json ./ RUN npm ci --omitdev COPY --frombuild /app/dist ./dist EXPOSE 3001 CMD [node, dist/index.js]为什么用npm ci而不是npm install因为npm ci严格按照 lockfile 安装能保证本地和生产依赖版本完全一致。运行阶段不装 devDependencies镜像能小不少。如果你是 stdio 类型的 server容器化意义不大我会更推荐 systemd 直接跑进程。5.2 进程守护别裸跑 node在裸机或虚拟机上部署 HTTP server最忌讳的命令就是nohup node dist/index.js 。进程挂了没人拉起来机器重启了服务不自启。用 systemd 是最原生的方式[Unit] DescriptionMCP HTTP Server Afternetwork.target [Service] Typesimple Usermcp EnvironmentNODE_ENVproduction EnvironmentFile/etc/mcp-server.env ExecStart/usr/bin/node /opt/mcp-server/dist/index.js Restartalways RestartSec3 LimitNOFILE65536 [Install] WantedBymulti-user.targetRestartalways保证异常退出后 3 秒拉起EnvironmentFile把密钥这类敏感配置外置不要直接写死在 unit 文件里LimitNOFILE调高文件描述符上限避免高并发时出现EMFILE错误。如果你已经用了 Docker也可以直接在容器里用 pm2-runtime 做进程守护但大多数情况下 node 单进程 Docker 自带的 restart policy 就够了不用再叠一层 pm2。5.3 鉴权与网络安全远程部署的 MCP server 默认暴露在网络上鉴权是必须做的最简单的方式是 Bearer Token。下面是一个 Express 中间件的思路const AUTH_TOKEN process.env.MCP_API_TOKEN; app.use(/mcp, (req, res, next) { const auth req.headers.authorization ?? ; if (auth ! Bearer ${AUTH_TOKEN}) { res.status(401).json({ error: unauthorized }); return; } next(); });有几点生产经验分享不要在代码里写死 Token用环境变量或密钥管理服务。生产环境建议把 MCP server 放在 API 网关后面由网关统一做 TLS、限流、审计MCP server 只在内网监听。没有 TLS 的话Token 等于是明文走在网络上私有网络可能还能接受公网绝对不能裸奔。更换 Token 时要考虑客户端重连机制很多桌面 Agent 会缓存配置改完 Token 不重载配置就一直 401。5.4 客户端配置与 HTTP transport 的坑MCP 客户端侧的配置结构大同小异很多桌面 Agent 遵循mcpServers这个字段{ mcpServers: { my-mcp-server: { url: https://api.example.com/mcp, headers: { Authorization: Bearer xxxxx } } } }本地测试时用http://localhost:3001/mcp一般没问题但如果在本地用自签证书的 HTTPS很多客户端会直接校验失败所以本地开发没必要上 HTTPS放到生产环境再交给网关处理。最后分享一个我在 Streamable HTTP transport 上踩过的坑工具执行时客户端秒断服务端日志却显示任务执行完成了。排查了很久发现是 HTTP 路由没有区分处理 GET、POST、DELETE 三类请求。Streamable HTTP 的约定里GET用于建立 SSE 流POST用于消息交换DELETE用于关闭会话。我当时的实现把所有请求都当成普通 POST 处理初始化请求一进来就被当成消息交换上下文结束了客户端自然收不到后续结果。解决办法很简单在一个路由里先判断请求方法分三支处理。这个细节比任何最佳实践都值钱。
返回列表