资讯动态

MCP服务器进阶开发:错误处理、流式输出与TypeScript工程化实践

发布时间:2026/9/14 8:29:40 来源:尧图企业网站定制
做 MCP 自定义服务器开发有一段时间了从最开始照着官方示例把 echo 工具跑通到后来真正在业务系统里接 Agent、处理长任务中间踩了不少坑。最近经常有人问我为什么我写的 MCP server 一接到客户端就超时为什么工具调用报错后客户端那边只看到一个莫名其妙的Internal error为什么长文本任务要等全部生成完才能返回这些问题大多不是 SDK 用法的问题而是错误处理、流式输出、TypeScript 工程化和部署这几个环节没想明白。这篇文章围绕 MCP 自定义服务器开发进阶按我实际落地时的思路把这四块拆开讲清楚。内容适合已经能跑通 Hello World、准备把服务器往工程化方向做的同学也适合正在排查线上 MCP 服务不稳定问题的朋友。里面会包含大量可复制的代码、参数取舍和避坑经验希望能帮你少走弯路。1. 先把协议骨架摸清楚请求、响应和通知的边界很多人一上来就写setRequestHandler反而忽略了 MCP 底层是 JSON-RPC 2.0 消息模型这件事。自定义服务器之所以经常出问题本质上是对协议边界没有概念。1.1 自定义服务器到底在扮演什么角色MCP 架构里服务器是一个独立的逻辑进程它负责暴露工具、资源和提示词给客户端。客户端是 Claude Desktop、Copilot、Dify、自研 Agent 这类侧载器它们负责理解用户的自然语言然后把用户意图转化为对服务器工具的调用。我在开发时习惯把 MCP 服务器想成“工具的 HTTP 接口”只不过调用方式从 REST 变成了 JSON-RPC 消息。服务器本身不关心 Agent 是怎么规划任务的它只做几件事启动时声明自己的能力收到工具调用时执行对应逻辑然后把结果按协议格式返回。所以进阶开发的第一步不是堆功能而是把消息边界定义清楚。这里最容易被忽略的是一次工具调用并不仅仅是“请求-响应”两个动作。客户端可能携带进度令牌服务器可以主动发通知长任务可以分阶段返回中间状态异常情况需要返回结构化错误码。这些都需要在协议层面处理而不是简单地在函数里throw new Error。1.2 三类消息模型与进阶开发的切入口MCP 遵循 JSON-RPC 2.0消息分成三类请求、响应和通知。请求必须有id响应必须有和请求相同的id通知则没有id不需要回复。我在自定义服务器里最重要的一个认知转变是不是所有客户端消息都需要你写 handler 才能正常工作。SDK 内置了很多 handler比如InitializeRequestSchema、PingRequestSchema、ListToolsRequestSchema。自定义开发的重点是扩充ListToolsRequestSchema的返回列表以及实现CallToolRequestSchema的具体业务逻辑。进阶开发的切入口藏在两个地方请求头_meta里可能带progressToken这是客户端允许你发送进度通知的凭证。客户端可能在任意时刻发送notifications/cancelled告诉你某个请求不需要继续执行了。这两个点直接决定了你的服务器是“接口”还是“服务”。只做接口的话错误处理和流式输出都可以不讲究要做成服务就必须把超时、取消、进度、重试全部纳入设计。后面的内容都会围绕这两点展开。2. 错误处理是服务器质量的试金石MCP 客户端对错误的展示非常“原教旨”JSON-RPC 的错误对象长什么样用户就看到什么样。很多服务器把异常处理做成catch (e) { throw e }结果数据库密码泄漏在 message 里或者所有错误都变成Internal error客户端那边完全无法判断要不要重试。这是进阶开发第一个要解决的问题。2.1 用 JSON-RPC 错误码做第一道约束JSON-RPC 2.0 标准定义了一组错误码MCP SDK 也沿用了这套规范。我用 TypeScript 开发时最常用的是这套映射错误码含义使用场景-32700解析错误收到的消息不是合法 JSON-32600无效请求消息不是合法请求对象-32601方法不存在客户端调用了未注册的 handler-32602无效参数工具入参不符合 schema-32603内部错误工具执行过程中发生未知异常-32000 到 -32099服务器自定义错误业务层面的特定错误注意MCP SDK 里提供了McpError和ErrorCode枚举直接使用可以保证错误对象格式正确。我的一个习惯是所有工具执行函数都不直接抛普通Error而是统一封装成McpError这样客户端从error.code和error.message能拿到明确信息而不是看到一行堆栈。比如参数校验失败时我会在 data 里带上字段名和期望值import { McpError, ErrorCode } from modelcontextprotocol/sdk/types.js; import { z } from zod; const InputSchema z.object({ prompt: z.string().min(5), maxTokens: z.number().int().positive().max(8192).optional(), }); try { const input InputSchema.parse(args); } catch (err) { if (err instanceof z.ZodError) { throw new McpError(ErrorCode.InvalidParams, 输入参数不合法, { issues: err.issues.map((issue) ({ path: issue.path.join(.), message: issue.message, })), }); } throw err; }这样客户端看到的是-32602并且在data.issues里拿到完整校验明细。比返回一段“参数错误”的文本好用得多。2.2 工具执行过程中的异常如何映射到协议工具执行时的异常来源很多上游 API 超时、数据库连接断开、文件不存在、外部服务返回 5xx等等。如果全部映射成同一个internal error客户端就只能碰运气重试。我的做法是三层分类可重试错误上游超时、网络抖动、依赖服务暂时不可用。这类错误可以设置retryable: true客户端可以根据 data 里的提示来重试。不可重试错误参数错误、权限不足、数据不存在。这类错误无论重试多少次都一样应该直接返回明确错误码。未知错误虽然会返回-32603但我会在日志里记录完整的堆栈而在错误 message 中只保留通用描述避免内部路径和敏感信息泄露。在代码层面我会把每个工具的执行函数包一层错误映射中间件async function safeExecute(fn: () PromiseCallToolResult) { try { return await fn(); } catch (err) { if (err instanceof McpError) { throw err; } if (err instanceof TimeoutError) { throw new McpError(ErrorCode.InternalError, 上游服务响应超时, { retryable: true, timeoutMs: 30000, }); } if (err instanceof PermissionError) { throw new McpError(ErrorCode.InternalError, 当前凭据无权限访问该资源, { retryable: false, }); } throw new McpError(ErrorCode.InternalError, 工具执行失败, { retryable: false, errorId: crypto.randomUUID(), }); } }errorId是我强烈建议加的一个字段把它同时写入日志和错误响应 data。客户端反馈问题时只要能提供errorId你就能在日志系统中快速定位到具体那次执行。这个习惯帮我排查过非常多线上问题。2.3 日志、追踪与前端可读的提示信息怎么配合错误处理不只是返回一个结构体还要考虑日志。MCP 服务器通常隐藏在 Agent 后面用户看到的是自然语言包装过的错误信息。如果日志里没有足够上下文排障就会非常痛苦。我在项目里把日志输出成结构化 JSON每条日志都带requestId、toolName、userId如果有、errorCode等字段。这样后续接日志平台或直接 grep 都很方便。另外日志级别要分清楚调试时输出完整参数生产环境只输出脱敏后的信息。关于“前端可读提示”我的经验是给用户看的信息和给开发看的信息要分开。错误响应里的message给客户端 Agent 用措辞要简洁、动作导向比如“服务暂时不可用请稍后重试”详细技术细节放到data字段里同时记录到日志。绝对不要在 message 里出现C:/Users/xxx/config.json这类机器路径否则客户端会把整段话渲染给用户。3. 流式输出让 Agent 像人一样边想边说很多 MCP 服务器卡在“工具调用必须等结果全部返回”的思路上。对于短任务没问题但一旦涉及文本生成、批量数据分析、视频渲染这类耗时操作一次请求可能要好几十秒。客户端默认超时时间往往没那么长即使不超时用户也等得很煎熬。这就是流式输出要解决的场景。3.1 为什么工具返回一次性结果会卡住智能体假设你写了一个“总结今日待办”的工具它需要遍历邮件、日历、IM可能耗时十几秒。如果你在这十几秒内什么都不返回Agent 就只能一直等。更麻烦的是如果客户端设置了 10 秒超时工具已经执行了一半客户端却没有收到响应它会误判为失败甚至直接发起重试造成重复执行。我最初的方案是把任务放到后台队列立即返回一个 taskId然后客户端轮询状态。这个方案可行但体验不够自然。更好的方式是使用 MCP 协议里的进度通知机制服务器在任务执行过程中主动向客户端发送进度消息让 Agent 知道任务还在正常推进。这样既不会超时用户也能看到“正在生成摘要 40%”的中间反馈。这里要先明确MCP 的CallToolRequest在执行阶段实际是一次请求-响应模型后续的进度通知是协议层独立的notifications/progress消息。客户端通过请求参数里的_meta.progressToken告诉我们“你可以向我发进度”服务器收到后才有资格推送。3.2 在 TypeScript 服务器里实现增量文本和进度事件我推荐的长任务方案是三件套立即返回任务句柄、后台更新任务状态、通过进度通知推送中间状态。当客户端调用long_task工具时我先做参数校验然后创建任务记录立刻返回import { randomUUID } from node:crypto; const taskStore new Mapstring, TaskRecord(); server.setRequestHandler(CallToolRequestSchema, async (request) { if (request.params.name ! long_task) { throw new McpError(ErrorCode.MethodNotFound, 未知工具: ${request.params.name}); } const { prompt, maxTokens } request.params.arguments as LongTaskInput; const taskId randomUUID(); const progressToken request.params._meta?.progressToken; taskStore.set(taskId, { id: taskId, status: running, progress: 0, content: , startTime: Date.now(), }); runLongTask(taskId, prompt, maxTokens, progressToken); return { content: [{ type: text, text: 任务已启动: ${taskId} }], structuredContent: { taskId, status: running, }, }; });runLongTask在后台执行每处理完一个阶段就更新taskStore并通过server.notification发送进度async function runLongTask( taskId: string, prompt: string, maxTokens: number, progressToken: unknown ) { const server getServerInstance(); for (let step 0; step 10; step) { const chunk await generateChunk(prompt, step, maxTokens); const record taskStore.get(taskId); if (!record || record.status cancelled) { return; } record.content chunk; record.progress (step 1) * 10; if (progressToken) { await server.notification({ method: notifications/progress, params: { progressToken, progress: record.progress, total: 100, message: 正在生成内容, }, }); } } const record taskStore.get(taskId); if (record) { record.status completed; } }这里的关键是最终那一次CallToolRequest的响应我们其实已经提前返回了所以任务执行完毕后客户端如果需要拿到完整结果应该通过另一个工具get_task_result来获取。这个“两段式”设计能绕开 MCP 单次请求超时的限制。如果客户端不关心中间过程也可以等到任务结束后再返回完整结果但那样就不算真正的流式了。3.3 客户端接入时的断点续传与取消策略流式输出不能只做“推”还要处理“拉”和“停”。断点续传如果任务执行到一半客户端崩溃服务器端taskStore里的进度不能丢。我一般会把任务状态持久化到 Redis 或者本地 SQLite避免进程重启后任务断掉。对绝大多数内部工具场景用Map加定期清理就够如果要上生产建议至少把任务状态和中间内容快照落地。取消策略MCP 客户端可以通过notifications/cancelled通知取消一个请求。服务器收到后不能只是停止向客户端发进度还应该真正中断后台计算。我的做法是在server.setRequestHandler之外给传输层注册取消监听当收到取消通知时把任务状态标记为cancelled并停止循环。循环里每次迭代都检查状态保证在几百毫秒内能够退出。另外所有taskStore的记录都必须设置存活时间。我用定时器每 60 秒扫描一次把超过 5 分钟仍未完成或已完成的任务清理掉防止内存只增不减。这个踩过坑的朋友应该都知道本地跑没什么感觉部署在 Docker 里连续跑几天内存曲线一路向上。4. TypeScript 开发中的类型安全与工程化细节MCP 官方 SDK 对 TypeScript 支持很好但很多项目只用了一小部分能力导致类型优势完全没发挥出来。这个章节讲我怎么搭一个既好调试又好发布的 TypeScript MCP 服务器工程。4.1 基于官方 SDK 的最小工程怎么搭我推荐的项目结构my-mcp-server/ ├── package.json ├── tsconfig.json └── src/ ├── index.ts # 入口负责启动 stdio 或 http transport ├── server.ts # 创建 MCP Server 实例注册 handler ├── tools/ │ └── longTask.ts # 具体工具实现 ├── errors.ts # 错误码和异常类 ├── logger.ts # 结构化日志 └── taskStore.ts # 长任务状态管理package.json 里关键配置{ name: my-mcp-server, version: 1.0.0, type: module, bin: { my-mcp-server: dist/index.js }, scripts: { dev: tsx watch src/index.ts, build: tsup src/index.ts --format cjs,esm --dts, start: node dist/index.js }, dependencies: { modelcontextprotocol/sdk: ^1.12.0, zod: ^3.24.0 }, devDependencies: { tsx: ^4.19.0, tsup: ^8.3.0, typescript: ^5.7.0 } }我为什么用tsup而不是直接tsc因为 MCP 服务器经常要被别人以 CLI 方式安装也可能被其他 Node 项目 import。tsup能同时产出 CommonJS 和 ES Module 两套产物避免“模块格式不支持”的安装报错。如果只是自己内部用tsc完全够但要注意module和moduleResolution的设置否则 ESM 下会找不到依赖。tsconfig.json 里几个关键项{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true } }strict必须开。MCP 协议里很多字段是可选的不开 strict 的话一个undefined传到客户端可能出现完全不可预期的行为。我有一次就是忘了开 strict代码里用了request.params.arguments.someField跑起来才发现arguments可能是 undefined直接报错。4.2 类型定义背后的协议约束MCP SDK 的类型定义非常值得研究。以CallToolRequestSchema为例它的params.arguments类型是{ [key: string]: unknown }也就是任意 JSON 对象。我们在工具实现里最忌讳直接把这个类型到处传因为“任意对象”意味着没有编译期检查。我的做法是为每个工具定义 zod schema然后用z.infer推导出精确类型import { z } from zod; export const LongTaskSchema z.object({ prompt: z.string().min(1, prompt 不能为空), maxTokens: z.number().int().positive().max(8192).default(2048), temperature: z.number().min(0).max(2).default(0.7), }); export type LongTaskInput z.infertypeof LongTaskSchema;这样在工具函数内部input.prompt是经过校验且类型明确的字符串不再需要满屏的as断言。更重要的是对外暴露的ListToolsRequestSchema响应里工具描述和输入 schema 保持了一致客户端能自动做一层预校验很多基础错误在发送请求前就被拦下来了。另一个容易踩坑的地方是content数组的类型。返回给客户端的content并不是普通字符串而是按类型区分的TextContent、ImageContent等对象。很多人直接返回content: [{ type: text, text: ... }]没问题但如果要返回图片或结构化数据就要严格匹配 SDK 里的联合类型。新版 SDK 还提供了structuredContent字段用来返回机器可读的数据客户端 Agent 能直接读取而不需要解析文本。这个字段对对接 Dify、自研 Agent 尤其重要我建议能填就填。4.3 调试、构建和发布 npm 包的关键点调试 MCP 服务器最常用的工具是官方的 MCP Inspector。启动方式npx modelcontextprotocol/inspector node dist/index.js如果你的服务器走 stdioInspector 会把它当成子进程启动然后在网页里看到工具列表、手动调用工具、查看 JSON-RPC 日志。这个工具是我排查问题时的第一道防线客户端报错之前先用它确认服务器本身是否正常。如果走 HTTP 传输可以用 Inspector 的远程模式或者直接写个小脚本用 fetch 发 JSON-RPC 请求。注意 HTTP 模式下服务器要正确处理OPTIONS预检请求SDK 的StreamableHTTPServerTransport会自动处理一部分但要确保 CORS 配置开放了正确的前端域名。发布 npm 包时有一个容易忽略的点必须把dist目录包含进发布文件。如果你的服务器打算被别人通过npx直接运行还要在bin字段里指定入口文件并且入口文件头部要有#!/usr/bin/env node。如果不加这行在 Unix 系统下执行my-mcp-server会直接报“无法识别该命令”。5. 部署上线从本地进程到 Docker 服务MCP 服务器开发完成后部署方式和普通 Web 服务有相似之处也有不少特殊细节。很多人本地跑得好好的部署到服务器上就各种连接不上这里面的坑我基本都踩过一遍。5.1 本地进程模式与端口管理最简单的部署方式是直接在服务器上运行 Node 进程客户端通过command: node配合参数调用。这种方式适合个人用或者给同机上的本地客户端用。需要注意的是stdio transport 要求 Node 进程以子进程方式启动客户端要能直接访问到dist/index.js文件所以文件路径要固定不要随手放在用户目录下。如果服务器需要被多个客户端共享我建议使用 HTTP transport监听一个固定端口比如 3000。入口文件里通过环境变量控制传输方式const isHttp process.env.TRANSPORT http; const server new Server({ name: my-tools, version: 1.0.0, }); if (isHttp) { const transport new StreamableHTTPServerTransport({ url: http://localhost:${process.env.PORT || 3000}/mcp, cors: { origin: process.env.CORS_ORIGIN?.split(,) ?? [], }, }); await server.connect(transport); } else { const transport new StdioServerTransport(); await server.connect(transport); }环境变量统一放进.env文件里但.env永远不要提交到代码仓库。生产环境用 Docker 或平台的 secrets 管理。端口管理建议使用127.0.0.1监听再在网关层做请求转发避免服务直接暴露到公网。如果部署在云服务器上安全组和防火墙规则只放行需要的端口。5.2 用 Docker 固定运行环境我更喜欢用 Docker 部署因为 Node 版本、系统依赖都能固定住不会出现“本地好好的服务器上跑不起来”的情况。多阶段构建能有效减小镜像体积FROM node:20-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-alpine AS runtime WORKDIR /app ENV NODE_ENVproduction COPY --frombuild /app/package.json /app/package-lock.json ./ COPY --frombuild /app/dist ./dist RUN npm ci --omitdev RUN addgroup -S mcp adduser -S mcp -G mcp USER mcp EXPOSE 3000 CMD [node, dist/index.js]注意几点npm ci依赖package-lock.json文件必须提交到代码仓库。生产阶段用--omitdev这样镜像里不包含 typescript、tsup 等开发依赖体积能小很多。用非 root 用户运行避免容器被攻破后拿到 root 权限。Node 20 的镜像里自带addgroup和adduser这是 alpine 版的命令。如果构建时需要拉取私有 npm 包可以在构建阶段设置NODE_AUTH_TOKEN环境变量但要注意别把这变量写进最终镜像层。docker-compose 示例services: mcp-server: build: . ports: - 3000:3000 environment: - PORT3000 - TRANSPORThttp - LOG_LEVELinfo - API_BASE_URL${API_BASE_URL} restart: unless-stopped logging: driver: json-file options: max-size: 20m max-file: 5restart: unless-stopped保证进程异常退出后能自动拉起。logging配置避免日志文件无限增大这个在生产环境非常重要。5.3 请求转发、鉴权与监控的一个相对完整方案有时候你不想让每个客户端都直连 3000 端口可以在网关层做请求转发。比如用云平台的负载均衡或者内部用 SSH 隧道。关键是要处理好两个问题长连接超时和响应缓冲。MCP 的流式输出依赖长连接网关层的 idle timeout 至少要大于客户端调用的最大等待时间。我在内部环境一般把超时设置到 300 秒以上。同时响应缓冲必须关闭否则 SSE 或通知流会被缓冲区卡住客户端迟迟收不到数据。如果你在用 Nginx 做请求转发记得关闭proxy_buffering如果用的是云平台负载均衡找到对应的“响应缓冲”开关并关掉。鉴权方面如果是内部服务可以用 API Key。服务端收到请求后先检查Authorization头不合法直接返回 401。这里有一个细节JSON-RPC 错误对象里没有 401 这个概念所以鉴权失败我建议在 HTTP 层直接返回 401 状态码而不是进入 MCP 的 JSON-RPC 处理流程。客户端那边能正确识别 HTTP 状态码并提示用户需要重新配置凭据。监控方面最少要做到三件事进程健康检查路径比如/healthz返回 200。结构化日志采集requestId、工具名、耗时、错误码可作为核心字段。长任务状态上报把taskStore里的任务数、耗时、失败率记下来超过阈值时告警。我习惯用 Prometheus 格式暴露自定义指标但内部小团队也可以只用日志加告警。重点是先有再完善。6. 常见问题与排查技巧实录最后这部分是我在真实项目里遇过的典型问题整理成速查表方便你对照排查。6.1 SDK 连接失败或工具列表不展示现象客户端显示服务器已连接但工具列表为空。排查步骤先确认服务器有没有执行server.setRequestHandler(ListToolsRequestSchema, ...)。如果只注册了CallToolRequestSchema但忘了注册 ListTools客户端就不知道服务器有哪些工具。如果 ListTools 返回了工具但客户端依然不显示检查返回的对象格式。inputSchema必须是符合 JSON Schema 的对象不能是 zod schema 实例。有些人直接从 zod 里导出 schema 塞回去客户端解析失败就会丢失工具。stdio mode 下如果服务器启动时打印了console.log日志会污染 stdio 通道导致客户端无法解析。要在入口里把所有业务日志改成写入 stderr不能用 stdout 打印。一个快速自查方法先用 Inspector 启动服务器看它能不能列出工具、能不能调用成功。Inspector 正常客户端异常说明问题基本在客户端配置或 CORS。6.2 流式输出中断、消息时序错乱现象进度通知发到一半断了或者任务已完成但客户端还在等通知。这个问题的本质是任务状态管理没有处理好。我遇到过三种情况通知发送时客户端已经断开服务器还在循环里继续发异常被吞掉。解决方法是每次通知前先检查任务状态是否还是running发送失败就标记为失败并退出。多个通知并发写导致客户端收到乱序。解决方法是给通知增加自增序号字段或者把状态更新放到单线程的串行队列里。任务实际完成时间早于客户端超时时间但通知没有发出去。原因可能是 progressToken 没拿到或通知方法名写错。MCP 标准里进度通知的方法是notifications/progress不是progress拼写错误会让客户端忽略。6.3 部署后连接超时、内存泄漏与日志丢失部署后最常见的三个问题连接超时要分清楚是网络超时还是应用层超时。用curl -v看端到端耗时如果 Request 到达服务器很快但响应很慢那就是业务逻辑阻塞。MCP 服务器不要执行同步的密集计算任务应交给后台进程处理。内存泄漏大部分来自taskStore无清理。如果你用了 Map 存任务状态一定要加 TTL不然运行一个月后 Map 里可能有几百万条记录。日志丢失服务器在 Docker 容器里默认日志只写 stdout/stderr。用 json-file 驱动时要配置max-size和max-file如果直接上云最好用平台日志组件采集。没有日志排查问题基本靠猜所以这个钱不能省。最后再分享一个小技巧每次修改协议处理逻辑后我都会先用 Inspector 跑一遍正常调用和异常调用再放出去对接真实客户端。这个习惯帮我挡住了至少一半的线上回归问题。MCP 服务器看似简单但协议边界一旦出错客户端那边只会给用户一个非常抽象的错误描述排查成本远高于修复成本。希望这篇文章能让你少踩一些我踩过的坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价