十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

MCP自定义服务器进阶:错误处理、流式输出与TypeScript部署实践

MCP自定义服务器进阶:错误处理、流式输出与TypeScript部署实践 MCP 自定义服务器开发进阶指南 —— 错误处理、流式输出、TypeScript 与部署MCPModel Context Protocol最近在开发者圈子里热度一直居高不下从蓝湖 MCP 到 Figma MCP再到 Codex MCP 这类工具链集成本质上都是把“外部能力”标准化地接入 AI 应用。如果你已经在本地跑通过一个最简的 MCP server那这篇内容应该正好是你要的下一站把错误处理做扎实、把流式输出调顺、用 TypeScript 把类型边界管住最后让服务安稳地上线跑在生产环境里。这篇文章我不会去重复官方文档里那些 Hello World也不会只停留在“MCP 是什么”的基础概念上。我默认你已经知道 MCP 有三种传输方式stdio、SSE、Streamable HTTP也大概了解 client-server-tool 的基本角色划分。我们直接聊那些真正影响开发效率和生产稳定性的细节异常怎么分类、错误码怎么定义、流式输出怎么在 SDK 层落地、TypeScript 工程化怎么配置不会踩版本坑、部署到远程环境之后怎么调试和守护进程。整个过程我尽量按我实际踩过的坑来讲该贴代码贴代码该给参数给参数。如果你是刚接触 MCP 的读者建议先把官方 TypeScript SDK 的最小示例跑一遍再回来看这篇不然某些章节的上下文会缺失。而如果你已经完成过一两个自定义服务器但没有系统整理过错误边界和部署流程那这篇文章就是给你准备的。1. 整体设计与思路拆解1.1 为什么要用 TypeScript 写 MCP 服务器MCP 协议的官方 SDK 目前最成熟的就是 TypeScript SDK 和 Python SDK 两套。Python 生态在 AI 领域确实强势但如果你要对接的是前端工具链、Figma 插件、本地编辑器或者任何跑在 Node 运行时里的场景TypeScript 是更顺的选择。我自己选 TypeScript 还有一个很实际的理由MCP 协议本身是一套严格的 JSON-RPC 2.0 消息规范类型定义复杂且嵌套很深用 JavaScript 写很容易在 message shape 上翻车而 TypeScript 的类型系统能把这个层面的错误直接拦截在编译期。TypeScript 另外一个隐形的优势是部署形态灵活。同样是写一个 MCP server用 TypeScript 编译后既可以直接跑 Node 标准输入输出模式也可以打包成单文件用容器跑远程服务还能在一个项目里同时维护 client 端的类型定义。回头维护的时候一个仓库一套类型省掉很多心智负担。npm create vitelatest mcp-advanced-server -- --template vanilla-ts cd mcp-advanced-server npm install modelcontextprotocol/sdk国外开发者用npx modelcontextprotocol/server-*这类现成包比较多但自定义服务器最好还是基于官方 SDK 自己封装。原因是现成包往往绑定特定场景比如文件系统、数据库或者 HTTP 抓取你真正要接的内部系统大概率不在这个列表里。1.2 MCP 服务器里“错误处理”不是一个可选模块很多初写 MCP server 的人会把错误处理当成 JavaScript 里的 try/catch 随手一包。这放在普通 HTTP API 里勉强能用但放在 MCP 场景里是远远不够的。原因在于 MCP 的调用链通常是这样你的 MCP server 被 client 连接client 可能是 Claude Desktop、Codex、Cursor 这类编辑器或智能体应用终端用户最终看到的是“这个工具执行失败”这样一句模糊的提示。如果错误信息里没有结构化的 code 和清晰的 message用户和上层 Agent 都无法判断问题是出在参数、权限、外部依赖还是服务本身。所以错误处理在这里不只是“不让进程崩溃”而是要把错误变成协议层面的可理解数据。MCP 基于 JSON-RPC 2.0错误对象里必须带code、message和可选的data字段。你写的每一条工具逻辑都要把自己会抛出的错误归入几个有限的类别并且保证每个类别都返回合理状态给上层。1.3 流式输出解决的是“AI 应用里的等待感”MCP 服务器对外暴露的工具并不都是“一次性返回结果”的。比如一个代码审查工具可能需要分析整个仓库再返回报告一个数据查询工具可能要跑一个耗时的 SQL 聚合一个文档生成工具可能要调外部大模型逐步产出内容。如果这些都等到全部完成才返回使用者体验会非常糟糕终端长时间空白用户以为服务挂了Agent 也可能因为等待超时直接判定工具失效。流式输出的本质是把“结果”拆成多个阶段性消息。MCP 协议里内置了notifications/progress这类通知机制SDK 也在较新版本里支持了 streamable 响应。你需要在设计阶段就想清楚哪些工具适合流式、流式消息按什么粒度切分、最终结果和中间过程如何区分。后半部分我会给出一个完整的实现示例。2. 错误处理架构与规范2.1 错误码怎么选MCP 的错误码不是随意定的。它沿用 JSON-RPC 2.0 的标准码段同时也引入了自己的扩展码。我在实际项目里一般把错误分为三层第一层是传输层错误比如连接断开、消息超时、JSON 解析失败。这一层的错误码由 SDK 处理我基本不干预但需要在全局兜底 catch 里记录日志。第二层是协议层错误比如方法不存在-32601、非法参数-32602、内部错误-32603。这些是 JSON-RPC 标准码只要你的服务器遵守协议SDK 会自动帮你映射你只需要确保业务代码不要吞掉这些错误。第三层才是业务错误码也是最容易被忽视的。业务错误码通常从-30000开始往下排每个编号对应一类业务异常。这样设计的好处是上层 Agent 可以通过 code 直接判断该走什么兜底策略而不是去解析自然语言错误消息。我自己的服务器通常把业务异常封装成这样一个枚举export enum McpBusinessErrorCode { INVALID_INPUT -30001, PERMISSION_DENIED -30002, EXTERNAL_API_FAILED -30003, RESOURCE_NOT_FOUND -30004, RATE_LIMITED -30005, TIMEOUT -30006, INTERNAL_STATE_ERROR -30007, }每个工具在实现时只允许抛McpError或者它子类的实例禁止直接throw new Error(something wrong)。因为在 MCP 的响应结构里message字段最终会被 LLM 读取并用于决策如果你抛出的错误语焉不详模型就会开始猜结果往往跑偏。2.2 工具内的异常捕获与统一包装在实际写代码时我建议在server.tool()的回调函数里做一层统一的异常包装而不是让每个工具自己去写 try/catch。这样一方面可以减少重复代码另一方面能保证错误格式绝对一致。import { McpError, ErrorCode } from modelcontextprotocol/sdk/types.js; function wrapToolError(fn: (args: any) Promiseany) { return async (args: any) { try { return await fn(args); } catch (err) { if (err instanceof McpError) { throw err; } if (err instanceof HttpError) { throw new McpError( ErrorCode.InternalError, 上游接口调用失败: ${err.message}, { code: McpBusinessErrorCode.EXTERNAL_API_FAILED } ); } throw new McpError( ErrorCode.InternalError, 工具执行异常: ${err instanceof Error ? err.message : String(err)}, { code: McpBusinessErrorCode.INTERNAL_STATE_ERROR } ); } }; }然后每个工具注册时这样用server.tool( fetch_user_profile, { userId: z.string() }, wrapToolError(async ({ userId }) { const profile await db.user.findUnique({ where: { id: userId } }); if (!profile) { throw new McpError( ErrorCode.InvalidParams, 用户不存在: ${userId}, { code: McpBusinessErrorCode.RESOURCE_NOT_FOUND } ); } return { content: [{ type: text, text: JSON.stringify(profile) }] }; }) );这个模式看起来简单但它扛住了我线上百分之八十的异常场景。还有一点容易被忽略MCP SDK 里server.tool()的第二个参数是 zod schema参数校验失败时 SDK 会抛ErrorCode.InvalidParams但是错误消息有时候不够友好。比如枚举值传错了默认消息可能是一长串 zod 内部描述。这种时候我会给zod的每个字段加上.describe()再把错误消息做一个翻译映射让 LLM 能看懂到底是什么参数不合法。2.3 日志、可观测性与错误追踪MCP 服务器的日志比普通后端服务更特殊因为很多部署形态是 stdio 模式——即它是被父进程拉起的子进程标准输出是协议通道你绝不能在控制台乱打日志否则会污染协议流。这个细节极其重要我见过不止一个人因为在服务里写了console.log调试结果 client 端直接解析失败。正确做法是把业务日志全部走 stderr或者在进程内做结构化日志收集。function log(level: info | warn | error, message: string, meta?: Recordstring, unknown) { process.stderr.write(JSON.stringify({ timestamp: new Date().toISOString(), level, message, meta: meta ?? {}, }) \n); }线上排查时我通常配合jq过滤结构化日志node dist/server.js 21 | jq select(.level error)这样既不影响协议流又能快速定位问题。错误追踪方面我建议至少把以下信息固化到日志里请求 IDrequestId或 JSON-RPC 的id、工具名、传入参数注意脱敏、错误码、耗时。有了这些不管后面是接 Sentry 还是自建 ELK你都有现成的数据源。而且 MCP 工具的参数经常包含用户输入脱敏一定要做否则日志本身就成了数据泄露点。3. 流式输出设计与实现3.1 流式信息的几种形态MCP 里的流式输出并没有像 SSE 那样单独定义一套标准而是依托现有的 JSON-RPC 消息做组合。我实践中主要用到三种形态进度通知Progress Notificationnotifications/progress适合任务耗时较长但不必实时输出内容的场景比如批量处理文件、大数据量聚合查询。客户端能拿到进度百分比和当前阶段描述体验上是一个明确的“正在处理”状态。增量结果Partial Result / Streaming Response某些 SDK 版本支持在工具调用尚未完成时先把中间结果片段返回给客户端。适合生成型任务比如文本写作、代码生成、报告生成。这个对上层 Agent 的提示词设计有要求不然后续片段会和最终结果混淆。结构化事件流你可以在工具返回的文本内容里塞入自定义事件标记比如[EVENT]stageanalysis、[EVENT]stagereview让 client 侧按标记解析。这种方式最灵活但要求 client 配合否则就是普通文本。三种形态里进度通知是最通用的兼容性最好增量结果的开发体验最自然但要看 client 是否真正支持结构化事件流则适合确定性的机器解析。3.2 基于 TypeScript SDK 的进度通知实现官方 SDK 从某个版本开始给server.tool()的回调暴露了一个extra参数里面带有sendProgress之类的能力具体方法名请以你安装版本的类型定义为准。我用一个“多步骤数据分析工具”来演示server.tool( deep_analysis, { datasetId: z.string(), metrics: z.array(z.string()) }, async ({ datasetId, metrics }, extra) { const totalSteps 4; // 阶段 1: 校验数据源 await extra.sendProgress({ progress: 1, total: totalSteps, message: 正在校验数据源完整性... }); const dataset await loadDataset(datasetId); if (!dataset) { throw new McpError(ErrorCode.InvalidParams, 数据集不存在, { code: McpBusinessErrorCode.RESOURCE_NOT_FOUND, }); } // 阶段 2: 清洗与聚合 await extra.sendProgress({ progress: 2, total: totalSteps, message: 正在进行数据清洗与聚合... }); // 阶段 3: 指标计算 await extra.sendProgress({ progress: 3, total: totalSteps, message: 正在计算指标: ${metrics.join(, )} }); // 阶段 4: 生成报告 await extra.sendProgress({ progress: 4, total: totalSteps, message: 报告生成中... }); return { content: [{ type: text, text: generateReport(dataset, metrics) }], }; } );进度通知的最大好处是上层 Agent 可以在长时间等待时给用户阶段性反馈避免用户反复问“还在跑吗”。代价是你要在业务代码里手动切分阶段定义总步数和每一步的文案。这个切分的粒度很关键太粗用户感知不到进度太细又会刷屏浪费上下文我的经验是一般 3 到 6 步比较合适。3.3 真正意义上的 Token 级流式输出如果说进度通知是“阶段级”的那么 Token 级流式输出就是“字符级”的。这类需求通常出现在你的 MCP 工具本身会调用 LLM 的场景比如做一个文档润色工具、一个智能标签生成器。你希望把大模型的内容像打字机一样推给 client而不是等全文生成完一次性返回。官方 TypeScript SDK 在这块的处理一直在演进。较新的版本里server.tool的回调可以返回一个异步可迭代对象或者你手动管理 response stream。我项目里用的是“基于 Streamable HTTP 传输”的远程 MCP server这样 client 和 server 之间的通信天然是 HTTP SSE流式转发很自然。核心思路是你的业务代码内部调用下游大模型 API 时拿到对方的流式响应然后逐块转发到 MCP 的响应通道里。伪代码如下server.tool( chat_document, { document: z.string(), question: z.string() }, async ({ document, question }) { const stream await llmClient.streamCompletion({ prompt: 基于以下文档回答问题...\n\n${document}\n\n问题: ${question}, stream: true, }); let fullAnswer ; // 注意: SDK 具体 API 以你安装版本为准 for await (const chunk of stream) { fullAnswer chunk.text; // 将增量推送给 client await sendStreamingDelta({ delta: chunk.text, done: false }); } await sendStreamingDelta({ delta: , done: true }); return { content: [{ type: text, text: fullAnswer }], }; } );但这里有个绕不开的坑不是所有 MCP client 都支持流式响应。如果你用 Claude Desktop 这类相对保守的客户端它可能只认最终结果中间推送的流式 delta 反而会带来兼容性问题。所以我现在的做法是做一个配置开关默认走普通返回只有在 client 声明支持流式时才切换为流式模式。这个能力一般通过 capabilities 协商来探测。3.4 流式场景下的取消与超时流式输出不是单向通道客户端随时可能因为用户停止而中断请求。如果你不及时取消下游任务底层大模型 API 还在继续扣费数据库的慢查询还在占用连接这是很实际的成本问题。我通常给每个流式工具传入一个AbortController并监听 client 断开信号const controller new AbortController(); // 具体断开事件名称以 SDK 版本为准 transport.onClose(() controller.abort()); llmClient.streamCompletion({ prompt: ..., signal: controller.signal, });同时流式任务要设置两级超时第一级是“首块超时”比如 15 秒内如果下游没返回第一块数据直接判定失败第二级是“总时长超时”如果整个流式过程超过比如 5 分钟强制中断。这两个超时的参数要根据你的工具实际耗时来调不能拍脑袋设一个值。4. TypeScript 工程化与类型安全4.1 依赖版本与 SDK 选择TypeScript 开发 MCP 服务器最大的痛点是版本。modelcontextprotocol/sdk的版本迭代很快很多 API 在不同版本里命名不同。网上大量教程用的还是老版本 API你照着抄很容易发现类型对不上。我自己现在固定用 1.x 版本并且在package.json里锁死精确版本号不用^范围{ dependencies: { modelcontextprotocol/sdk: 1.12.1 } }锁版本的原因很直接这个 SDK 的破坏性更新比大多数库都频繁。今天能编译通过的代码一个月后升级小版本可能就编译不过了。如果你是做内部工具没必要追新稳定第一。4.2 tsconfig 的关键配置MCP 服务器通常跑在 Node 环境tsconfig.json不能照搬前端项目的配置。我用的是这样一组核心配置{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: ./dist, rootDir: ./src, strict: true, declaration: true, sourceMap: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, types: [node] }, include: [src/**/*] }重点解释几个容易被忽略的项module: NodeNext和moduleResolution: NodeNext是配套的这决定了你在 ESM 项目里引入本地文件时必须写.js后缀TS 5.x 要求。这个规则新手很容易困惑但它是 Node 原生 ESM 的硬性要求。如果你不想写后缀也可以退回到module: CommonJS但那样动态 import 和顶层 await 都会受限。strict: true在 MCP 开发里特别重要。因为 MCP 的消息结构嵌套很深关闭 strict 意味着大量隐式any协议字段拼错很难发现。sourceMap: true要开线上排查时 stack trace 能映射到 TS 源码节省大量时间。4.3 用类型建模“工具输入输出”MCP SDK 允许你为每个工具声明输入输出类型。我在项目里不会直接手写 JSON Schema而是用 zod 推导类型。这样做的好处是运行时校验和编译期类型永远一致不会出现“schema 改了但业务代码类型没更新”的问题。import { z } from zod; const SearchOrderSchema z.object({ orderId: z.string().min(1).describe(订单号必填), includeItems: z.boolean().optional().default(false).describe(是否包含明细), }); type SearchOrderInput z.infertypeof SearchOrderSchema; server.tool(search_order, SearchOrderSchema.shape, async (args: SearchOrderInput) { // 到这里args 的类型已经被严格推导 });但这里有个容易误解的地方第三方 MCP client 拿到的是 JSON Schema而不是直接拿你的 TS 类型。所以字段的可读性非常重要。zod 的.describe()方法会被转换进 JSON Schema 的描述字段里LLM 就是靠这个描述来理解参数含义的。描述写得含糊模型就传错参数。4.4 命名空间与全局类型的正确姿势热词里提到的declare global在 MCP 开发里也有用武之地。当你需要给globalThis挂自定义属性时比如缓存一个数据库连接池实例、存一个全局 logger就需要声明全局类型。但这个功能不是让你乱用的我一般只用于“进程级单例”的场景// src/types/global.d.ts import { Logger } from ./logger; import { Database } from ./db; declare global { var appLogger: Logger; var appDb: Database | null; } export {};然后在实际代码里用globalThis读写globalThis.appLogger new Logger();为什么不用模块级变量因为 MCP 服务器进程通常会同时创建多个传输实例模块级变量在 ESM 里是共享的反而容易造成冲突。挂到globalThis上语义更清晰也方便调试时在 REPL 里访问。但要注意declare global只能在模块文件里使用而且必须有export {}把它变成模块这个细节新手经常踩。5. 部署与生产环境运维5.1 stdio 模式与远程模式的选型MCP server 的部署形态和选型强相关。如果你的使用场景是“本地编辑器 本地工具”stdio 模式是最简单的client 直接 spawn 你的 Node 进程通过标准输入输出通信。这种模式天然安全不需要鉴权也不会暴露网络端口。缺点是每个 client 都要单独拉起一个进程资源消耗较高。如果你需要把 MCP server 部署在服务器上供多个远程 client 调用那就必须走网络传输。目前主流的两种是 SSE 和 Streamable HTTP。Streamable HTTP 是更新的方案基于 HTTP天然兼容流式响应和鉴权我现在的主力部署就是这种。远程部署有个核心问题不能忽略鉴权。MCP 协议本身没有定义鉴权机制你需要自己在 HTTP 层解决。最简单的做法是给 server 加一个静态 token 校验client 请求时放在 Authorization header 里。secret 由环境变量注入不要硬编码进代码。5.2 用 Docker 部署 MCP 服务器的完整方案我习惯用多阶段构建来减小镜像体积同时也避免把源码和 node_modules 暴露进生产镜像。# 构建阶段 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 CMD [node, dist/index.js]这里有几个点我反复踩过npm ci要求package-lock.json必须存在如果你用的 npm 版本较老建议先删掉node_modules重新生成锁文件再构建。另外运行阶段再次npm ci --omitdev是为了只装生产依赖避免把 typescript 和 zod 的类型定义打进去。但有个前提modelcontextprotocol/sdk本身是否包含在 dependencies 里如果它被写在 devDependencies那运行阶段的安装就会缺失。docker-compose 配置里我一般会加健康检查。虽然 MCP server 没有标准 health endpoint但我会在自己的 HTTP server 上加一个/healthz路由返回 200 空内容。这样编排平台可以准确判断实例是否存活。services: mcp-server: build: . ports: - 3000:3000 environment: - MCP_API_KEY${MCP_API_KEY} - DATABASE_URL${DATABASE_URL} healthcheck: test: [CMD, wget, --spider, -q, http://localhost:3000/healthz] interval: 30s timeout: 5s retries: 3 start_period: 10s restart: unless-stopped5.3 进程守护与日志轮转即使你不用容器直接用 Node 进程跑 MCP server也强烈建议用 PM2 这类进程守护工具来管理。PM2 能自动重启崩溃的进程也方便查看日志。我在生产环境里的启动配置是这样pm2 start dist/index.js --name mcp-server --max-memory-restart 512M pm2 logs mcp-server --timestamp日志轮转是很多人忽略的坑。MCP server 长时间运行stderr 日志文件会膨胀到几个 G拖垮磁盘。安装 PM2 的日志分割模块pm2 install pm2-logrotate pm2 set pm2-logrotate:max_size 50M pm2 set pm2-logrotate:retain 7 pm2 set pm2-logrotate:compress true这组配置的含义是单个日志文件超过 50M 就切割保留最近 7 份切割后压缩。一般内部服务这个量级完全够用。5.4 环境变量管理与配置隔离MCP server 的配置通常包含 API key、数据库连接串、上游服务的 endpoint。直接在代码里写这些值是绝对的灾难。我在项目里统一用.env加一个轻量的加载器不依赖重型的配置中心。注意不要在代码里显式依赖dotenv的运行时导入顺序问题。我习惯在入口文件顶部第一时间加载import dotenv/config; const apiKey process.env.MCP_API_KEY; if (!apiKey) { throw new Error(MCP_API_KEY is required); }启动即校验关键环境变量缺一个就直接退出不要让服务带着错误的配置跑起来否则上线后你会接到一堆莫名其妙的问题反馈。环境隔离上我至少分三套development、staging、production。每套的.env文件不同文件名比如.env.production部署时通过--env-file指定。CI/CD 流程里把密钥放在平台的 Secret 管理里构建时注入而不是把.env文件提交进 Git 仓库。5.5 部署后的连通性验证清单每次部署完成我都建议跑一遍下面这个 checklist而不只是看进程还在不在本地手测用npx modelcontextprotocol/inspector连接远程 server确认能列出工具列表。跑通核心工具实际调用一次最核心的工具确认返回结果符合预期。验证错误分支故意传一个非法参数确认返回的是结构化错误码而不是堆栈信息。验证流式能力如果做了流式输出确认 client 能看到增量内容。检查日志确认没有把协议字节打印到 stdout。检查资源配置内存占用是否在预期内CPU 使用率是否平稳。这六条我每一条都遇到过翻车的情况。尤其是第 5 条很多“为什么 client 连不上”的诡异问题最后都是因为代码里留了一个调试用的console.log。6. 常见问题与排查技巧实录6.1 常见问题速查表现象可能原因排查方法Client 连接后工具列表为空server 启动失败或 tool 注册时报错查看 stderr 日志检查 zod schema 是否有非法定义工具调用超时业务逻辑执行时间过长检查是否手动切分了进度通知善用超时参数错误信息里没有业务 code未使用 McpError 包装统一改用 wrapToolError 包装器流式输出 client 不展示client 版本不支持流式响应降低兼容性要求使用开关切换普通返回远程部署后鉴权失败header 名称或 token 不匹配在 server 端打印脱敏后的 header key 检查PM2 重启后连不上环境变量丢失确认 PM2 启动时带--env参数或使用了 ecosystem 文件日志文件巨大未配置日志轮转安装 pm2-logrotate 并配置大小6.2 一个真实的排查案例SDK 版本升级导致的流式输出失效我之前把一个 MCP server 从modelcontextprotocol/sdk的 0.x 版本升级到 1.x 版本升级后工具能正常返回文本结果但流式输出完全失效client 端永远只显示最终一次性结果没有中间过程。排查过程是这样的先确认不是 client 问题用官方 inspector 连接后手动发起调用依然没有流式效果。接着怀疑是传输层问题检查 HTTP 响应头发现Content-Type不是预期的流式类型。最后定位到 SDK 1.x 版本里server.tool()的流式 API 从原来的回调参数传参改成了需要显式在 response 里声明streamable支持而我旧代码里没有做这个能力声明。解决办法是在 server 初始化时通过 capabilities 声明流式支持const server new McpServer({ name: advanced-mcp-server, version: 1.0.0, capabilities: { tools: { streamable: true, }, }, });这个案例给到我的教训是MCP SDK 每个小版本都可能调整协议协商方式升级后一定要跑完整的连通性验证不要只看编译通过就上线。6.3 独家避坑技巧如何调试 stdio 模式下的 MCP serverstdio 模式调试非常折磨人因为你不能随便往 stdout 打日志。我的调试方案是在代码里设一个调试开关开启后把 JSON-RPC 的收发消息全部镜像到 stderrconst DEBUG process.env.MCP_DEBUG true; // 在 transport 挂载后具体 API 以 SDK 版本为准 transport.onmessage (msg) { if (DEBUG) log(debug, client - server, JSON.stringify(msg)); originalHandler(msg); };用MCP_DEBUGtrue node dist/index.js启动就能在不污染协议流的前提下看到完整消息交互。线上排查远程问题时同样适用只是要注意日志脱敏。6.4 性能调优经验MCP server 在高频调用场景下也会遇到性能瓶颈。我遇到最多的是 zod schema 校验耗时和日志序列化开销。zod 在大对象上校验其实不慢但如果你在每次调用里都创建新的 schema 实例开销会累积。正确做法是把 schema 定义成模块级常量。另一个容易被忽略的点是MCP server 进程内的全局变量经常因为 client 断开而泄漏。如果你在工具回调里创建了数据库连接、HTTP client、文件句柄一定要在 finally 块里释放。尤其是使用 Streamable HTTP transport 时一次请求的生命周期和进程生命周期不是绑定的连接泄漏久了内存必然上涨。我写过一个简单的请求级资源跟踪器用一个Set记录未释放的资源名在每次请求结束时断言 Set 为空不为空就记警告日志。这个做法帮我抓到了不少连接未关闭的问题。6.5 从 MCP server 到 agent skill 的演进思考热词里有人问“agent skill 和 MCP 有什么区别”在部署和运维视角我简单说下体会。MCP 侧重点在标准化外部工具接入而 agent skill 更偏向于把“调用多个工具的完整流程”沉淀成可复用的技能包。两者不是替代关系而是嵌套关系一个 skill 内部可能调用多个 MCP server 暴露的工具。所以你在设计 MCP server 时工具粒度不能设计得太大否则 skill 层无法灵活编排。一个工具做一件边界清晰的事描述里写清楚输入输出约束这样上层 Agent 才能把多个工具组合成复杂流程。如果你把一大堆逻辑都塞进一个工具里Agent 没法复用skill 自然也不好写。这个设计原则同样影响部署策略。工具粒度越小接口越稳定你的 server 拆分和扩容也就越灵活。单个 server 实例承载过多工具不是问题但如果工具之间职责差异太大建议拆成多个 server用不同的鉴权和资源配额来管理运维和排障都会轻松很多。7. 结尾的真心话做 MCP server 开发这一年多我最深的体会是协议本身并不复杂真正的复杂度全在工程细节里。错误处理、流式输出、TypeScript 类型边界、部署形态每一个单独拿出来都是老生常谈的话题但组合在 MCP 的场景下就有了很多意想不到的坑。你在社区里看到的那些 MCP 教程大多停留在“如何 10 分钟跑通一个 server”而我更愿意把这些踩坑后的经验沉淀下来让你少走几个月的弯路。最后再分享一个小技巧无论你的 server 多简单上线前一定用官方 inspector 完整测一遍工具列表和核心调用链。这个工具能模拟各种 client 的行为很多你以为只在某个编辑器里出现的问题用 inspector 一测就能定位到是 client 兼容性问题还是 server 本身的缺陷。调试 MCP server 时永远记住一句话协议消息是透明的别让日志和临时调试代码挡在协议和数据之间。
返回列表