尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MCP服务器进阶开发:错误处理、流式输出与生产部署实践

MCP服务器进阶开发:错误处理、流式输出与生产部署实践 做一个自定义 MCP 服务器最难的往往不是“把工具跑通”而是把错误处理、流式输出、部署链路这些“第二层问题”想清楚。我花了两周时间用 TypeScript 从零写了一个面向团队内部数据查询场景的 MCP 服务器过程中把协议层、SDK 层、部署层的坑基本踩了一遍。这篇文章不打算讲“MCP 是什么”这种入门概念而是直接围绕自定义服务器的进阶开发把我在实际项目中验证过的设计思路、代码结构和部署方案整理出来给正在写或者准备写 MCP server 的同学做个参考。1. 动手前先想清楚你的 MCP 服务器类型与协议选型1.1 MCP 到底解决什么问题——先别急着写代码MCPModel Context Protocol本质上是给大模型和外部工具之间定的一套标准化接口协议。没有它之前每个 Agent 应用都要自己定义一套工具调用协议模型厂商一套、开源框架一套、企业内部系统再一套集成成本极高。MCP 的价值在于把“模型如何发现工具、如何调用工具、工具结果如何返回给模型”这条链路统一了。但很多人一上来就写 server容易忽略一个问题你的 MCP 服务器到底是给谁用的是给 Claude Desktop 这种本地客户端用还是给部署在云端的 Agent 服务用还是给 IDE 插件用这直接决定了传输层选型。MCP 官方定义了两种传输方式一种是基于标准输入输出的 stdio另一种是基于 HTTP 的 SSE 方式现在也在推 Streamable HTTP。两种方式的适用场景完全不同后面细说。1.2 服务器形态选择stdio 与 HTTP/SSE 的取舍这是我调试时体会最深的一点。stdio 模式的 MCP server 本质上是一个子进程由 MCP 客户端比如 Claude Desktop直接拉起通过 stdin/stdout 通信。这个模式的好处是零网络开销不需要管端口、鉴权、跨域本地调试非常顺手。但它有个硬限制只能被单机上的单个客户端进程使用没法跨网络调用。如果你的服务器要部署在远端给多个客户端或云端 Agent 共享就必须走 SSE 或 Streamable HTTP。我当时先用了 SSE 模式后来发现官方 SDK 的 API 演进很快Streamable HTTP 正在成为推荐的 HTTP 形态。它兼容了 SSE 的双向流式通信又支持无状态的普通请求整体更接近现代 Web 服务的调用方式。我列一个对比表方便你按场景直接选维度stdioSSE / Streamable HTTP启动方式客户端拉起子进程独立 HTTP 服务网络要求仅本机可跨网络多客户端支持单客户端多客户端鉴权需求基本不需要需要 Token 或认证日志输出注意不能污染 stdout自由打日志运维复杂度低中高需管端口、进程、反向代理典型场景本地开发、IDE 插件、桌面客户端云端 Agent、多人共享服务我的建议是开发初期先用 stdio配合官方 Inspector 调试工具把协议交互摸清楚等逻辑稳定了再包一层 HTTP 部署。不要一上来就搞分布式架构大部分 MCP server 的工具逻辑复杂度远没到需要分布式的地步。1.3 为什么我用 TypeScript 而不是 Python虽然 Python 在 AI 生态里有天然优势但 MCP 服务器本身是个典型的服务端程序TypeScript 在这块的优势很明显类型安全。工具的输入输出结构复杂TS 的强类型能在编译期拦截掉一大批低级错误而 Python 只能靠运行时校验。SDK 维护活跃。官方 TypeScript SDKmodelcontextprotocol/sdk迭代速度很快类型定义完整社区示例也多。生态成熟。无论是接内部 API 还是数据库Node.js 生态都有成熟的客户端库。与前端/IDE 生态天然亲近。MCP 最常见的落地场景就是 Claude Desktop、Cursor 这类工具它们的宿主本身就是 Electron/Node 环境。当然不是否定 Python如果你团队的技术栈是 Python 为主或者工具逻辑大量依赖 Python 的 AI 库那选 Python 也合理。但如果你想写一个长期维护、容易被团队其他人接手完善的服务端项目TypeScript 会是更稳妥的选择。2. 错误处理MCP 交互中的错误传播与恢复机制2.1 MCP 协议层的错误模型MCP 是基于 JSON-RPC 2.0 的所以错误处理的第一层就是 JSON-RPC 的错误结构。一个标准的错误响应长这样{ jsonrpc: 2.0, id: 1, error: { code: -32602, message: Invalid params: name is required } }JSON-RPC 预定义了几种标准错误码比如解析错误、无效请求、方法不存在、无效参数。MCP 在这个基础上定义了自己的方法比如tools/call、resources/read这些方法调用失败时同样走 JSON-RPC 错误结构。但这里有一个容易混淆的点工具的“业务逻辑”错误和协议层的“调用”错误是两回事。比如你有一个查天气的工具用户传了一个不存在的城市名这是业务错误不应该返回 JSON-RPC error而应该正常返回一个 tool result在结果里标记isError: true并给出可读的错误描述。只有当工具本身不存在、参数格式不对、或者服务器内部出现未捕获异常时才应该返回协议层错误。2.2 工具内部异常的类型化封装写 TypeScript 服务器时我喜欢给每个工具定义一个统一的错误返回结构而不是直接在callTool里乱抛异常。MCP SDK 中toolCall的返回内容是一个结构化的content数组其中每一项可以是文本、图像或资源链接。对于错误情况你需要设置isError: true同时给出一段模型能直接理解的文本内容。我的做法是封装一个safeExecute帮助函数把工具的真实逻辑包裹起来捕获所有异常并转换成结构化的错误返回import { McpError, ErrorCode } from modelcontextprotocol/sdk/types.js; export async function safeToolCallT( fn: () PromiseT, errorMessagePrefix: string ): Promise{ content: Array{ type: text; text: string }; isError?: boolean } { try { const data await fn(); return { content: [{ type: text, text: JSON.stringify(data, null, 2) }], }; } catch (err) { const msg err instanceof Error ? err.message : String(err); return { content: [{ type: text, text: ${errorMessagePrefix}: ${msg} }], isError: true, }; } }这样做的好处是第一工具逻辑里不需要到处写 try/catch第二错误信息格式统一模型能稳定地从返回文本里读懂错误原因第三isError标记让客户端明确知道这次调用没成功同时又不破坏 JSON-RPC 的通信状态。2.3 我踩过的错误处理坑超时、重试与幂等超时是我上线后遇到的第一个大坑。我的一个工具会去调内部的一个数据分析接口这个接口偶尔会跑 3 分钟以上。结果客户端那边默认超时时间只有 60 秒超时后客户端直接把连接断了但服务器这边的任务还在继续跑造成资源浪费。后来我做了两件事一是给所有可能慢的工具增加“预估耗时”提示在工具描述里明确写“此操作可能需要 1-2 分钟”这样模型在规划时会提前评估二是对于真正长任务不要让工具调用本身阻塞那么久而是改成“提交任务 返回任务 ID 提供查询任务状态的工具”这个模式把同步等待改成异步轮询。幂等也是个容易忽略的问题。MCP 客户端超时重试时如果服务器收到两次相同的请求而工具不是幂等的比如“创建订单”“发送通知”就会出事故。我当时的做法是在服务器端维护一个请求 ID 去重根据requestId判断是否处理过如果处理过直接返回缓存结果。这个模式尤其适用于大模型 Agent 场景。模型本来就有重试倾向你再不做好幂等系统就会被重复调用打爆。2.4 错误信息设计的边界——对模型友好才是真的友好这是我觉得最有价值的一条经验MCP 工具的错误信息是写给模型看的不是写给程序员看的。很多人在工具里直接 throw 一个底层数据库的报错像ER_PARSE_ERROR: You have an error in your SQL syntax...模型拿到这种信息根本不知道怎么处理。我后来把错误信息分成三层设计第一层给模型看的友好摘要。比如“查询失败数据库连接超时请稍后重试”或“用户不存在请检查用户ID”。第二层给开发者看的排查信息。比如带上 requestId、具体的服务名、耗时。第三层完整堆栈写入服务器日志不返回给模型。再配合前面的类型化错误返回模型在拿到isError: true后可以尝试修正参数重新调用或者向用户解释发生了什么。如果你的 Agent 编排比较高级还能根据错误类型自动决策重试还是放弃。好的错误信息设计能直接提升 Agent 的自主完成任务的成功率。3. 流式输出从阻塞式工具到真正的 Agent 体验3.1 为什么普通 tool call 需要流式输出默认的 MCP 工具调用是“全有或全无”的模型调用工具服务器开始执行执行完一次性返回完整结果。这在工具执行时间很短比如查个数据库、算个简单结果时没问题。但一旦工具执行需要几十秒甚至几分钟问题就出来了模型侧长时间等不到响应容易触发超时。用户界面上一片空白体验极差用户不知道到底有没有在处理。如果中途出错前面所有计算都白费重来成本高。流式输出的价值就是让“过程”可见。这和大模型本身的流式输出是一个道理——用户看到一个个 token 蹦出来感受到的是“它在工作”而不是“它卡了”。MCP 服务器也应该给工具提供这种过程反馈能力。3.2 流式输出在 MCP 协议中的实现思路MCP 协议本身通过几种机制支持流式体验。最核心的是 server 主动向 client 发送notifications/message通知旧版叫 logging/message这个通知可以在工具执行过程中随时发送内容是一个带level字段的日志结构。实现方式比较直接在你的工具执行函数里拿到一个“通知发送器”然后分阶段向外发进度import { Server } from modelcontextprotocol/sdk/server/index.js; import type { CallToolRequest } from modelcontextprotocol/sdk/types.js; export async function handleStreamingTool( server: Server, request: CallToolRequest ) { let progress 0; // 阶段1: 启动 await server.sendLoggingMessage({ level: info, data: 开始处理数据预计需要30秒, }); // 模拟分阶段的处理 for (const step of steps) { await processStep(step); progress Math.round(100 / steps.length); await server.sendLoggingMessage({ level: info, data: 处理进度: ${progress}%, }); } return { content: [{ type: text, text: finalResult }], }; }这里要注意通知是“单向”的客户端不需要为每个通知回复响应。这类通知可以在工具执行期间与最终的 tool result 同时存在它们是不同通道不冲突。客户端会在等待最终结果的过程中持续收到进度通知。3.3 渐进式结果重复调用与进度查询模式除了用协议通知模拟流式还有另一种更可靠的流式思路——把一个大任务拆成多次工具调用通过任务状态来渐进式获取结果。我在这类长任务上最终采用的是“任务式”模式它在实践中最稳定而且实现简单模型先调用start_long_task服务器创建一个任务记录立即返回一个taskId和初始状态。模型循环调用get_task_status查询进度直到状态变为completed。任务完成后再调用get_task_result获取最终结果。这个模式的好处是不依赖客户端对协议通知的支持程度兼容性更好每次调用都是短平快的普通请求不会触发超时天然支持断点续查——即使模型中途切换了对话上下文只要 taskId 还在随时能回来继续查。代价是增加了模型侧的调用次数。但以目前主流模型的多步调用能力这点代价完全可以接受。我在生产环境就是这么做的跑了一个多月的长任务没有一次因为超时出问题。3.4 客户端侧如何处理流式内容如果你不只是写服务器还要写配套的 MCP 客户端那流式内容的处理就更直观。SDK 客户端里监听通知事件把进度展示到 UI 或者日志上import { Client } from modelcontextprotocol/sdk/client/index.js; import { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; const client new Client({ name: my-client, version: 1.0.0 }); // 监听服务器推送的通知消息 client.on(notifications/message, (notification) { const params notification.params as { level?: string; data?: unknown }; const { level, data } params || {}; if (level data) { console.log([MCP-${level}], data); } }); // 调用工具 const result await client.callTool({ name: streaming_tool, arguments: { query: ... }, });一个细节notifications/message这个事件名在 SDK 不同版本里略有差异建议看你使用的 SDK 版本的类型导出以类型定义为准不要硬编码事件名。4. 用 TypeScript 写出可维护的 MCP 服务器4.1 SDK 的类型模型与工具注册方式官方 TypeScript SDK 的核心对象是Server。它接收一个ServerInfo和一组能力声明。工具、资源、提示词三类能力在注册方式上风格统一但细节不同。以工具注册为例典型代码结构import { Server } from modelcontextprotocol/sdk/server/index.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { CallToolRequestSchema } from modelcontextprotocol/sdk/types.js; const server new Server( { name: my-mcp-server, version: 1.0.0, }, { capabilities: { tools: {}, logging: {}, }, } ); server.setRequestHandler(CallToolRequestSchema, async (request) { const { name, arguments: args } request.params; switch (name) { case query_data: return handleQueryData(args); case run_analysis: return handleRunAnalysis(args); default: throw new McpError(ErrorCode.MethodNotFound, Unknown tool: ${name}); } }); const transport new StdioServerTransport(); await server.connect(transport);SDK 的类型模型非常完整。请求的arguments字段被定义为对象字典你需要自己根据name做类型收窄。这一步如果用any偷懒后面的类型安全就全废了。我的习惯是每个工具定义自己的参数类型用类型守卫来断言。4.2 Zod Schema运行时校验与类型推导这是 TypeScript 写 MCP 服务器最值得推荐的姿势用 Zod 校验入参。Zod 的z.object({...})既能做运行时校验又能通过z.infertypeof schema反推出 TS 类型一套 schema 两处用途。例如定义一个查询工具import { z } from zod; const QueryDataArgsSchema z.object({ table: z.string().min(1).describe(要查询的数据表名), filters: z.record(z.string(), z.unknown()).optional().describe(过滤条件), limit: z.number().int().min(1).max(100).default(10).describe(返回条数上限), }); type QueryDataArgs z.infertypeof QueryDataArgsSchema; export function parseQueryDataArgs(rawArgs: unknown): QueryDataArgs { const result QueryDataArgsSchema.safeParse(rawArgs); if (!result.success) { const detail result.error.issues .map((i) ${i.path.join(.)}: ${i.message}) .join(; ); throw new McpError(ErrorCode.InvalidParams, 参数校验失败: ${detail}); } return result.data; }.describe()方法在 Zod 里可能被忽略但它在 MCP 生态里价值很大。配合一些代码生成工具可以把 schema 直接转成工具描述让模型更清楚每个参数的语义。运行时校验则是必要的最后一道防线因为无论前端怎么处理工具的调用最终是通过 JSON 字符串传输的你拿到的原始输入类型永远是unknown。4.3 测试策略模拟 MCP 客户端做端到端验证MCP 服务器的测试和普通 HTTP 服务不太一样因为它不走 REST 接口走的是协议消息。我建议至少分两层测试第一层是工具逻辑的单元测试。把handleQueryData这种纯逻辑函数导出来直接传参数、断言返回结构不做协议层模拟。这一层跑得最快覆盖大部分业务逻辑分支。第二层是端到端协议测试。SDK 自带InMemoryTransport可以让你在同一进程里把 server 和 client 连起来模拟真实的协议交互import { InMemoryTransport } from modelcontextprotocol/sdk/inMemory.js; import { Client } from modelcontextprotocol/sdk/client/index.js; // 创建一对互联的管道 const [clientTransport, serverTransport] InMemoryTransport.createLinkedPair(); const client new Client({ name: test-client, version: 1.0.0 }); const server createServer(); // 你封装的初始化函数 await Promise.all([ client.connect(clientTransport), server.connect(serverTransport), ]); // 列出工具 const tools await client.listTools(); assert(tools.tools.some((t) t.name query_data)); // 调用工具 const result await client.callTool({ name: query_data, arguments: { table: users, limit: 5 }, });用InMemoryTransport测出来的结果和真实 stdout/SSE 基本一致因为你走的是完整的协议栈。我自己项目里大约三分之一的测试都是这一类既能验证协议正确性又能作为回归测试防止改挂。4.4 项目结构约定MCP 服务器项目不大但如果不规划好结构几十个工具塞进去后会非常痛苦。我最终采用的结构是src/ index.ts # 服务入口负责启动和绑定 transport server.ts # 创建 Server 实例注册所有 handlers tools/ index.ts # 工具注册表汇总所有工具定义与 handler query-data.ts # 单个工具的实现包含 schema、handler、业务逻辑 run-analysis.ts utils/ errors.ts # 错误封装、safeToolCall logger.ts # 结构化日志 types/ index.ts # 共享类型定义每个工具文件独立导出三样东西工具描述、参数 schema、执行函数。注册表文件统一汇总新增工具只加一个文件再在注册表里挂一行。这样做的好处是多人协作互不冲突单个文件体量小容易 review以后要自动生成工具列表也很简单。一个实用的约定是“schema 所属权”原则谁能描述清楚一个工具的参数谁就拥有这个工具的 schema 定义权。否则模型拿到一个空泛的参数描述根本不知道该怎么填最后还是落回“人肉调工具”的老路。5. 部署到真实环境从本地调试到生产可用的完整链路5.1 本地开发与调试MCP Inspector 的用法MCP Inspector 是官方提供的可视化调试工具强烈建议在开发阶段就配上。它本质上是一个本地 Web 应用把打包好的 server 跑起来在浏览器里手动执行工具调用、查看协议流量。用法很简单用npx启动 Inspector然后让它连接你的 server。我平时直接npx modelcontextprotocol/inspector node dist/index.jsInspector 会给你一个本地地址打开后可以查看工具列表、参数 schema手动填写参数调用工具看返回内容和错误结构查看完整的 JSON-RPC 请求/响应日志测试资源读取和提示词模板调试 stdout 模式时有个忠告日志永远不要往 stdout 写因为 stdout 是协议通道。要输出调试日志用console.error、写文件、或通过 logging 通知发出去。我第一次调试时因为一句console.log把协议流打乱了客户端直接解析报错排查了半天。5.2 用 Docker 容器化 MCP 服务器生产部署我用的 Docker。MCP 服务器本质是个 Node 服务Dockerfile 写法和其他 Node 服务类似但要特别注意构建缓存和镜像体积减少不必要的依赖FROM node:20-alpine AS builder 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 npm cache clean --force COPY --frombuilder /app/dist ./dist EXPOSE 3000 USER node CMD [node, dist/index.js]生产基础镜像一定不要带构建工具链alpine 只拷贝产物是最省事的。npm ci --omitdev这一步能省掉 devDependencies体积能小一大截。USER node 这句也别漏容器里用 root 跑服务始终是不规范的做法。如果是 HTTP 模式别忘了容器里要暴露端口同时设置健康检查。健康检查端点可以做一个简单的/healthz返回 200方便 Kubernetes 或 Docker Compose 判定存活状态。5.3 配置管理与安全实践Token、密钥与最小权限MCP 服务器的配置管理和普通后端服务一样核心原则是配置不进代码。用环境变量管理配置用.env文件在本地开发时加载用部署平台的 secret 管理在生产时注入。我在项目里封了个简单的配置读取模块import dotenv/config; function requireEnv(name: string): string { const value process.env[name]; if (!value) { throw new Error(Missing required environment variable: ${name}); } return value; } export const config { port: parseInt(process.env.PORT || 3000, 10), apiBaseUrl: requireEnv(API_BASE_URL), apiToken: requireEnv(API_TOKEN), logLevel: process.env.LOG_LEVEL || info, };安全方面有几个细节值得注意如果你的 MCP 服务器部署成 HTTP 服务一定不要裸奔到公网。至少加一层 Token 鉴权最好放在网关后面由反向代理统一处理认证。工具对底层系统的访问遵循最小权限。比如只提供查询能力的工具底层数据库账号就只给 SELECT 权限不要给读写权限。这样即使工具被误调用或者受到提示注入攻击影响面也可控。不要因为省事就把公司内部 API 的完整权限暴露给 MCP 工具。每个工具在实现时都要自问模型是必要的访问入口吗它能访问的最小边界是什么5.4 部署后的监控与日志部署上线只是开始真正重要的是可观测性。MCP 服务器的监控相比普通 API 服务多一个特殊的点你没办法从客户端轻易看到服务器内部发生了什么协议通信又是加密的走 HTTPS所以日志和指标必须主动埋。我用的是结构化 JSON 日志配合集中式日志组件。每个工具调用打一条关键日志包含 requestId、工具名、参数摘要、耗时、是否成功错误日志额外带上堆栈和上下文。数据指标至少要关注工具调用成功率按工具维度拆分平均耗时与 P95 耗时请求错误码分布流式通知发送频率部署初期最容易忽视的是“模型行为异常的上报”。举个例子如果某个工具突然被高频调用有可能是模型循环了也有可能是被恶意刷接口。这个信号要和正常业务波动区分开我自己的经验是建立“工具调用分布报表”每天看一眼异常模式很容易暴露出来。5.5 多客户端共享部署时的稳定性考虑最后说一个多人共享同一个 MCP 服务器时的稳定性问题。当你的服务器不再只服务一个 Claude Desktop 客户端而是同时服务团队里 20 个人的 Agent 工具时会出现一些单机模式下碰不到的问题第一个是并发。Node.js 单线程处理后的任务如果有多个长时间运行的工具同时执行事件循环可能会被长时间任务阻塞。我的处理是把耗时任务丢给 worker thread 或者独立子进程执行主进程只负责转发请求和返回结果。如果你的工具主要是 I/O 密集型查数据库、调 HTTP 接口Node 异步模型基本够用但如果涉及 CPU 密集型计算一定想办法隔离。第二个是内存管理。长连接 大内容返回容易堆积内存。我踩过一次内存泄漏后来排查是返回的 content 数组里不断累积大对象。解决方式是定期检查堆内存设置合理的内存上限超出就自动重启。第三个是优雅重启。HTTP 模式的服务更新版本时不能直接 kill 进程会中断正在执行的长任务。我做了 SIGTERM 信号处理收到信号后先停止接收新请求等正在执行的工具调用执行完或超时再平滑退出。实现起来就是几十行代码但对线上稳定性的提升非常明显。第四个是上游系统故障隔离。一个工具依赖的 API 挂掉不应该拖垮整个服务器。我给每个工具加了熔断器连续失败超过阈值就快速失败并返回清晰错误而不是每次都傻等超时。模型拿到快速失败的错误后会理解“这个工具当前不可用”从而换个策略而不是继续死磕。这些稳定性考虑在原型阶段完全不用管但一旦服务器开始被多个客户端稳定消费它们就是生产可用和“玩具项目”的分界线。我后来把所有长耗时操作统一收敛到任务队列模式后整个服务器的行为变得非常可预测再没出现过半夜被模型循环调用打爆的情况。
返回列表