MCP 自定义服务器开发进阶指南 —— 错误处理、流式输出、TypeScript 与部署
MCP(Model 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 vite@latest 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) => Promise<any>) { 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?: Record<string, unknown>) { process.stderr.write(JSON.stringify({ timestamp: new Date().toISOString(), level, message, meta: meta ?? {}, }) + '\n'); }线上排查时,我通常配合jq过滤结构化日志:
node dist/server.js 2>&1 | jq 'select(.level == "error")'这样既不影响协议流,又能快速定位问题。
错误追踪方面,我建议至少把以下信息固化到日志里:请求 ID(requestId或 JSON-RPC 的id)、工具名、传入参数(注意脱敏)、错误码、耗时。有了这些,不管后面是接 Sentry 还是自建 ELK,你都有现成的数据源。而且 MCP 工具的参数经常包含用户输入,脱敏一定要做,否则日志本身就成了数据泄露点。
3. 流式输出设计与实现
3.1 流式信息的几种形态
MCP 里的流式输出并没有像 SSE 那样单独定义一套标准,而是依托现有的 JSON-RPC 消息做组合。我实践中主要用到三种形态:
- 进度通知(Progress Notification):
notifications/progress,适合任务耗时较长但不必实时输出内容的场景,比如批量处理文件、大数据量聚合查询。客户端能拿到进度百分比和当前阶段描述,体验上是一个明确的“正在处理”状态。 - 增量结果(Partial Result / Streaming Response):某些 SDK 版本支持在工具调用尚未完成时,先把中间结果片段返回给客户端。适合生成型任务,比如文本写作、代码生成、报告生成。这个对上层 Agent 的提示词设计有要求,不然后续片段会和最终结果混淆。
- 结构化事件流:你可以在工具返回的文本内容里塞入自定义事件标记,比如
[EVENT]stage=analysis、[EVENT]stage=review,让 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.infer<typeof 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_ENV=production COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --from=builder /app/dist ./dist EXPOSE 3000 CMD ["node", "dist/index.js"]这里有几个点我反复踩过:npm ci要求package-lock.json必须存在,如果你用的 npm 版本较老,建议先删掉node_modules重新生成锁文件再构建。另外,运行阶段再次npm ci --omit=dev是为了只装生产依赖,避免把 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 server
stdio 模式调试非常折磨人,因为你不能随便往 stdout 打日志。我的调试方案是在代码里设一个调试开关,开启后把 JSON-RPC 的收发消息全部镜像到 stderr:
const DEBUG = process.env.MCP_DEBUG === 'true'; // 在 transport 挂载后(具体 API 以 SDK 版本为准) transport.onmessage = (msg) => { if (DEBUG) log('debug', 'client -> server', JSON.stringify(msg)); originalHandler(msg); };用MCP_DEBUG=true 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 时永远记住一句话:协议消息是透明的,别让日志和临时调试代码挡在协议和数据之间。