ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

MCP自定义服务器进阶指南:错误处理、流式输出、TypeScript与部署实战 最近在做 MCP 自定义服务器的时候我明显感觉到一个分水岭demo 跑通很简单但一旦进入真实业务错误处理、流式输出、TypeScript 工程化和部署这四个环节才是真正拉开差距的地方。很多人在本地把 server 写好了一接入 Cursor、Codex 这类 MCP host 就翻车要么工具报了错但客户端只给一行 internal error要么长任务处理时界面像死机一样卡住要么部署到服务器上 404 不断。这篇指南就是围绕这四个方向把我从零到上线的完整思路和踩坑记录整理出来适合已经跑通第一个 MCP server、想把它做成能稳定服务生产环境的开发者参考。1. 先搞清楚一件事MCP服务器到底在扮演什么角色1.1 MCP不是魔法是“AI应用的USB-C接口”MCPModel Context Protocol模型上下文协议本质上是一套公开的、标准化的通信协议让 AI 应用host能够以统一方式调用外部工具和数据源。你可以把它理解为 AI 世界的 USB-C 接口以前每种外设都要专用线缆和驱动现在不管你是接文件系统、接数据库还是接设计稿都用同一套标准握手、同一种数据格式。这个统一的语义非常关键。我在实际开发里接触过 Figma MCP、蓝湖 MCP也见过有人把股票软件本地数据封装成 MCP 接口它们的共同点都是把原来“AI 无法直接访问”的能力变成“AI 可以按标准协议调用”的服务。你不需要为每个模型单独适配只要 host 支持 MCP工具就能复用。1.2 画清host与server的边界一条请求是怎么被调用的在写自定义服务器之前必须把 MCP 的两个核心角色分清MCP Host主控端比如 Claude Desktop、Cursor、Codex 这类 AI 编程工具负责展示界面、管理对话、决定“何时调用哪个工具”。MCP Server能力提供端暴露工具tools、资源resources和提示词prompts通过协议响应 host 的调用请求。典型调用链路是这样的host 启动时拉起 server通过 stdio 或 HTTP 传输层双方完成 initialize 初始化握手交换协议版本和能力声明随后 host 通过 tools/list 拉取工具清单用户触发操作时 host 发送 tools/call 请求server 执行逻辑并返回结构化结果。我见过不少人把业务逻辑全塞进 serverhost 只当传话筒这其实是本末倒置。host 负责的是“什么时候调”server 负责的是“怎么调得稳、调得好”。你写的每一个工具本质上都是在替 host 完成一次外部世界的信息交互。1.3 为什么我坚持用TypeScript写MCP服务器MCP SDK 官方覆盖 TypeScript、Python、Java 等语言但我自己最推荐的还是 TypeScript。原因很直接MCP 的 schema 定义天然是结构化数据TypeScript 的静态类型和 zod 这类校验库可以极大减少字段不匹配的问题另外整个 MCP 生态里 host 端工具大多是前端/Node 技术栈TypeScript 能让你在 server 和 host 联调时少跨一层语言思维。当然如果你团队主力是 Java 或者 Python用 Spring Boot MCP 或 FastMCP 也完全可以。我见过 springboot mcp 配合 jdk 17 做出很稳定的服务这套进阶思路是通用的语言只是载体。2. 错误处理MCP服务器里最容易翻车的环节2.1 协议层的错误码体系别用“反正报错”糊弄过去MCP 基于 JSON-RPC 2.0所以协议层的错误码是固定的。最常见的几个错误码含义出现场景-32700解析错误请求体不是合法 JSON-32600无效请求请求结构不符合 JSON-RPC 规范-32601方法未找到host 调用了一个不存在的工具-32602无效参数参数类型或字段不符合 schema-32603内部错误server 内部抛出未处理异常这里有个容易踩的坑很多人一上来就抛 Error结果 host 端只显示 generic error用户根本不知道是参数错了还是上游接口挂了。协议层的错误码要严格遵守但真正决定排查效率的是下面要讲的业务层错误设计。2.2 业务错误设计错误码、消息、上下文三位一体MCP 协议有个容易被忽略的细节工具调用只要协议层面成功HTTP 状态通常就是 200真正的业务失败是通过返回结果里的 isError 字段标识的。这意味着你不能把所有异常都直接抛给协议层必须自己设计一套业务错误结构。我自己的规范是每个错误带三样东西code业务错误码比如 1000 参数校验失败、2001 上游依赖超时、3000 权限不足message给用户看的简短原因不暴露内部堆栈details调试上下文比如是哪个模块、哪个 requestId、哪个上游响应码。这样设计的好处是host 端拿到 isError 后可以把 code 和 message 直接展示给用户而 details 则留给日志系统做进一步排查。生产环境下千万不要把堆栈直接写在 message 里一是信息泄露风险二是用户根本看不懂。2.3 用TypeScript实现统一的错误处理层我习惯在项目里定义一个自定义错误类然后在每个工具注册处统一 catch。核心思路是让业务代码里只抛出语义化错误由中间层负责转换成协议格式。// error.ts export class McpError extends Error { constructor( public readonly code: number, message: string, public readonly details?: unknown ) { super(message); this.name McpError; } } export const ErrorCodes { INVALID_ARGUMENT: 1000, UPSTREAM_TIMEOUT: 2001, PERMISSION_DENIED: 3000, } as const;工具注册时统一包一层import { z } from zod; server.registerTool( fetchUserProfile, { title: 获取用户资料, description: 根据用户ID从业务系统拉取资料, inputSchema: { userId: z.string() }, }, async ({ userId }) { try { const data await getUserProfile(userId); return { content: [{ type: text, text: JSON.stringify(data) }] }; } catch (err) { if (err instanceof McpError) { return { content: [{ type: text, text: err.message }], isError: true, structuredContent: { code: err.code, details: err.details, }, }; } // 未知异常记录完整堆栈返回通用错误 logger.error({ err, userId }, fetchUserProfile failed); return { content: [{ type: text, text: 内部错误请稍后重试 }], isError: true, }; } } );你可能会问为什么不直接把异常抛出去让 SDK 处理因为 SDK 处理的结果通常不够结构化host 端难以针对性地提示用户。记住一个原则协议层错误是“连协议都没走通”业务层错误是“协议通了但业务没完成”两者一定要分开处理。2.4 日志与追踪没有requestId错误排查就是大海捞针MCP 一次工具调用会经历 host 发起请求、server 执行、返回结果三段链路。如果没有一个贯穿始终的追踪标识出问题后你根本不知道是 host 传参不对还是 server 执行时上游超时。我在 server 里加了一层中间件每次收到 tools/call 请求时自动生成 requestId写入异步上下文所有日志都带上这个 ID。这样 host 端用户报错时只要把报错信息或者日志里的 requestId 发给你你就能直接定位到那一次调用的完整链路。日志推荐使用 pino 这类结构化日志库输出 JSON 格式方便接入 ELK 或 Loki。别用 console.log 打天下不然到了生产环境排查问题的时候会哭的。3. 流式输出让MCP服务器从“憋大招”变成“说人话”3.1 什么时候必须上流式输出MCP 服务器最常见的业务场景有两种一是查数据二是调用大模型生成内容。查数据通常几秒内就能返回流式不是刚需但如果你在 server 里接入了大模型或者在做耗时较长的数据处理一次性返回会让 host 侧长时间无响应用户体验极其糟糕。我在接入 Ollama 本地模型时感受特别深。本地模型生成一段几百字的回答非流式接口要等二三十秒流式接口可以做到几百毫秒内第一个 token 就到达。前端体验是天壤之别这就是为什么很多 Vue 聊天对话 AI 项目都在做流式输出。判断标准很简单任何执行时间可能超过 3 秒的逻辑都建议设计成流式返回至少给客户端一个“进度感”。3.2 MCP协议下的流式传输机制MCP 本身支持两种流式路径一是传输层的流式比如 Streamable HTTP Transport基于 SSEServer-Sent Events把响应分块推给 host二是工具返回结果的流式即 content 数组里的某个元素可以是一个可读流SDK 会自动把流里的内容逐段发送给客户端。很多人在这一步搞混了以为流式输出只是传输层的事其实工具层的返回流式同样重要。以我实践过的方案为例工具内部业务逻辑里每产出一段数据就写入 ReadableStreamSDK 检测到返回的是一个流时会以流式方式推送。一个很重要的小知识SDK 中请求返回的流式数据每个 chunk 一旦被发送如果客户端连接断开了后续 chunk 就无法送达。所以在做流式输出时要设计好重试或断点续传策略别天真地以为流是绝对可靠的。3.3 TypeScript实现流式输出的核心代码下面是我在 modelcontextprotocol/sdk 里实现流式输出的一个核心片段直接可作为参考import { ReadableStream } from node:stream/web; server.registerTool( streamAnalyze, { title: 流式分析, description: 对输入文本进行逐步分析流式返回结果, inputSchema: { text: z.string() }, }, async ({ text }) { const stream new ReadableStream({ async start(controller) { try { const chunks analyzeInChunks(text); // 模拟分批处理 for await (const chunk of chunks) { const payload { type: text as const, text: chunk, }; controller.enqueue(payload); // 模拟异步间隔 await new Promise((r) setTimeout(r, 100)); } controller.close(); } catch (err) { controller.error(err); } }, }); return { content: [stream], }; } );要点就三个create ReadableStream把每段处理结果 enqueue 进去最后 close。SDK 看到 content 里有 ReadableStream会自动逐块转发给 hosthost 的 UI 层就能像打字机一样把内容打出来。如果你在服务端接的是 JDBC 查询结果也可以用同样的思路ResultSet 每读一批数据就 enqueue 一批文本客户端就能看到查询进度而不是干等全部查完。3.4 流式输出四大坑截断、背压、超时和编码流式输出看着简单实际跑起来坑不少。我逐个说截断问题很多人遇到“标签返回未完整怎么处理”本质上是流提前结束。原因通常是 controller.close() 没有被调用或者生产端的生成器提前 return。我在本地调试 Ollama 时遇到过同一个接口短文本正常长文本偶尔少尾巴排查半天发现是超时配置太短连接被 host 端断掉了。解决方法是把超时时间调长并且确认生成器有 finally 块保证 close。背压问题生产速度大于消费速度时流内部会积压。Node 的 ReadableStream 默认不限制 enqueue但如果客户端消费慢内存会涨。建议在 enqueue 前判断 controller.desiredSize如果接近 0就稍微等一下。超时问题host 端往往有 idle timeout流长时间没有新 chunk 会被判定超时。如果你只是进度提示记得定期发心跳 chunk比如空文本保持连接活跃。编码问题默认全链路一定要 UTF-8尤其当你从外部接口读取数据再转发时外部接口返回的编码可能不是 UTF-8接收端就会出现中文乱码。这个在生产环境非常常见我在对接一个老系统时就踩过最后靠显式转码解决。4. TypeScript开发踩坑合集从SDK选型到tsconfig配置4.1 SDK选型与项目初始化MCP TypeScript 生态目前最主流的是官方 modelcontextprotocol/sdk。它的开发体验已经比较成熟自带 Server 类、注册工具方法、stdio 和 HTTP 传输层实现。我建议项目用 pnpm 管理依赖初始化时一步到位pnpm init pnpm add modelcontextprotocol/sdk zod pnpm add -D typescript tsx types/nodeTypeScript 版本建议锁定在 5.x不要随手 upgrade 到最新版因为 SDK 部分类型定义可能还没有跟上最新 TS 的严格检查。项目结构上我习惯分四层src/index.ts入口负责启动传输层src/tools/每个工具一个文件注册逻辑src/lib/公共方法包括错误处理、日志、HTTP 客户端src/config/环境变量和配置解析。4.2 工具Schema定义zod拯救类型地狱MCP 工具声明里的 inputSchema 原本是 JSON Schema 格式手写很容易出错。我推荐用 zod 定义输入参数再用 zod-to-json-schema 转换成 MCP 需要的 schema。这样做的好处是类型声明和运行时校验共用一套代码不会出现“代码里用的字段和 schema 里声明的字段不一致”这种低级问题。import { z } from zod; import { zodToJsonSchema } from zod-to-json-schema; const InputSchema z.object({ userId: z.string().describe(用户ID), includeHistory: z.boolean().default(false), }); server.registerTool( getUserInfo, { inputSchema: zodToJsonSchema(InputSchema), }, async (args) { const parsed InputSchema.parse(args); // parsed 已经是类型安全的了 } );4.3 tsconfig里的过时选项和版本兼容问题很多人初始化项目时习惯从旧项目复制 tsconfig结果新版 TypeScript 直接报弃用警告。最典型的例子就是 baseUrlTypeScript 6.2 开始标记弃用并将在 TypeScript 7.0 中停止运行。报错文本大致是 options “baseurl”已弃用并将停止在 typescript 7.0 中运行。建议的做法是去掉 baseUrl直接用 paths 配合相对路径解析。另一个高频问题是“vue 类型工具与现有 typescript 7 不兼容”这通常出现在前端项目中本质上是类型工具版本跟不上 TS 新版本的破坏性变更。MCP 项目里我建议不要追新SDK 和 TS 版本都锁定在当前稳定组合跑通了就别随便升级。至于typescript [{}]这类写法我理解是老代码里用空对象数组绕过类型检查的 hack在 MCP 项目里千万不要学。MCP 生态的价值恰恰在于类型安全靠 hack 绕过类型系统后续维护成本会高到你怀疑人生。4.4 全局类型扩展declare global用对场景MCP server 里经常需要往全局对象上挂载一些运行时数据比如把 requestId 塞到全局上下文。TypeScript 里声明这类全局变量正确的姿势是 declare global。// global.d.ts declare global { var __requestContext: { requestId: string; startedAt: number; } | undefined; } export {};这里有三个细节文件必须是一个模块要有 export {}否则 declare global 不生效全局变量建议用 var 而不是 let/const这样才能在类型上和 globalThis 保持一致用完记得清理避免内存泄漏。我在面试时也经常问相关的问题TypeScript 的命名空间和 declare global 看起来冷门但在做基础设施类项目时特别实用。5. 部署上线本地跑通只是开始稳定运行才是目标5.1 传输层选择stdio还是HTTP/SSEMCP server 写完第一件事是决定传输层。stdio 适合本机运行的场景比如 Cursor、Claude Desktop 通过 npx 拉起本地 server配置简单、性能好但无法远程访问。如果你要在服务器上部署给团队或线上服务用必须走 HTTP 传输。SDK 里常见的 HTTP 传输有两种SSE transport 和 Streamable HTTP transport。后者是当前推荐方向它支持流式输出、连接复用行为更接近现代 HTTP 接口。我实践下来Streamable HTTP 天然适合配合 Docker 部署因为它的请求模型就是标准 HTTP POST SSE 响应。如果你在本地用 stdio 调试得好好的部署到服务器上突然不工作大概率是传输层选错——很多 host 配置默认走 stdio远程模式下根本不支持。记得在 host 配置文件里把 transport 类型指到 HTTP endpoint。5.2 Docker多阶段构建把MCP服务器装进容器部署 MCP server 我用的是 Docker 多阶段构建核心目标是让最终镜像尽量小、启动尽量快。FROM node:20-alpine AS build WORKDIR /app RUN corepack enable COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile COPY tsconfig.json ./ COPY src ./src RUN pnpm build FROM node:20-alpine WORKDIR /app ENV NODE_ENVproduction COPY --frombuild /app/dist ./dist COPY --frombuild /app/node_modules ./node_modules EXPOSE 3000 CMD [node, dist/index.js]这个 Dockerfile 有几个关键点多阶段构建保证最终镜像不包含源码和构建工具链体积能控制在 150MB 以内使用 pnpm 的 frozen-lockfile 保证依赖一致性避免部署时拉取到不同版本生产镜像直接用 node 运行编译后的 dist完全不需要 tsx。5.3 配置管理与环境变量配置管理是最容易被低估的一环。MCP server 经常要对接外部服务的 token比如 Figma MCP token、蓝湖 MCP token、数据库密码等这些绝不能硬编码在代码里。我的做法是环境变量集中解析成一个 config 对象用 zod 做运行时校验启动时如果缺少必要配置直接报错退出。这样做的好处是任何配置缺失都能在启动阶段暴露而不是等到运行时才报错。import { z } from zod; const ConfigSchema z.object({ PORT: z.coerce.number().default(3000), MCP_API_TOKEN: z.string().min(1), UPSTREAM_URL: z.url(), }); export const config ConfigSchema.parse(process.env);5.4 与ollama、deepseek等本地模型联动时的注意事项现在很多人做 MCP server 是为了给 AI 编程工具扩展本地模型能力比如本地部署 ollama、deepseek 等。联动时最常见的坑是网络隔离问题MCP server 在 Docker 容器里跑容器访问宿主机上的 ollama 是访问不通的除非你把网络模式设置成 host或者把 ollama 的地址配置成宿主机在 Docker 网络中的地址。另外本地模型推理速度比云端慢务必给上游请求设置合理的超时和重试。我一般把超时设成 60 秒重试一次超过就返回业务错误码 2001 给 host。还有一点本地模型上下文长度有限超过长度会报错或截断server 层要做截断提示而不是让用户稀里糊涂地拿到不完整结果。5.5 健康检查与可观测性部署上线只是开始稳定运行才是目标。MCP server 至少要做到三点健康检查端点、结构化日志、错误指标。我通常会给 server 增加一个 /health 路由返回当前进程状态和依赖服务的连通性比如是否连得上上游、数据库、本地模型。这样无论你用 Docker 的 HEALTHCHECK还是接入 Prometheus 监控做探活都有个统一的入口。指标方面不需要一开始就上全量监控我建议先埋三个关键指标请求总量、错误率、p95 延迟。用 prom-client 这类库可以轻松暴露 /metrics 端点后续就算要接 Grafana 也顺理成章。6. 常见问题速查直接照抄的排查清单6.1 高频问题排查表我把实际跑 MCP server 时频率最高的问题整理成一张表按现象、可能原因、解决思路三列排序现象可能原因解决思路Cursor里配置了server但看不到工具启动命令或 node_modules 问题在命令行手动跑一遍启动命令看有没有报错工具调用一直转圈没有返回流式输出未正常结束检查服务端是否调用了 controller.close()返回的JSON变成提示文本content 里没设置结构化内容用 structuredContent 字段返回结构化数据中文乱码或者标签显示不全编码问题或流被截断显式指定UTF-8确认流正常EOF调整超时时间部署后访问 404路由前缀不一致检查 server 配置里的路由前缀和实际请求路径端口被占用默认端口冲突端口用环境变量配置不要写死参数校验一直报错schema 与实际参数不匹配临时打印收到的 arguments和 schema 对比上游大模型响应太慢本地模型推理速度慢调大超时或者改成流式输出提升反馈速度6.2 我的几条保命经验最后分享几条每次做 MCP server 都会提醒自己的经验。第一永远在 server 里记录 requestId。没有 requestId出了线上问题就只能大海捞针有了它一次追溯能定位到毫秒级的调用链。第二生产环境不要裸奔错误堆栈。你在本地调试时可以打印完整错误但线上环境一旦暴露内部实现细节被有心人扫描到就是安全风险。对用户只展示可读错误完整堆栈留给日志系统。第三本地模型联动务必测长文本。短文本跑通了不算数长文本最容易暴露超时、截断、内存问题。我一般会用一段超过 2000 字的输入做压测看流式输出是否稳定。第四升级依赖之前先看 changelog。TypeScript 6 到 7 的迁移会有不少破坏性变更MCP SDK 也是快速迭代阶段升级前先看 SDK 的发布说明避免上线前突然编译不过。第五不要把 MCP server 当成一个装了就跑的黑盒。它本质是一个需要长期运维的服务日志、监控、配置管理这些基本功一样都不能少。我个人的体会是MCP 自定义服务器开发的复杂度不在协议本身而在把这些工程细节一条条落实。错误处理做到位了流式输出做顺了TypeScript 类型不糊弄部署流程自动化这个服务器才能真正从“能跑”变成“好用”。如果你正在做类似的 MCP 项目希望这篇进阶指南能帮你少踩几个坑。
返回列表