ARTICLE DETAIL

资讯详情

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

MCP Server进阶实战:错误处理、流式输出与TypeScript工程化

MCP Server进阶实战:错误处理、流式输出与TypeScript工程化 MCP最近是真的火从Figma到蓝湖再到通达信连CATIA、NXOpen这种工业软件都开始提供MCP Server。可要是你只在用现成server那还谈不上“开发”。这篇不是新手入门我默认你已经跑通过hello world知道MCP Server本质上就是给AI模型提供工具、资源和提示的适配层。我重点讲四个进阶方向错误处理、流式输出、TypeScript工程化、以及部署。这四块是我在把自定义server接入Cursor、Claude Desktop、MCP Inspector过程中反复踩坑总结出来的照着做能少走很多弯路。1. 整体设计思路先把架构想清楚再写代码1.1 为什么还需要自己写MCP Server官方生态里现成的MCP Server确实不少Figma能读设计稿蓝湖能提资源Gitee能管仓库甚至通达信这类股票软件都有社区版MCP。但对于企业内部来说这些远远不够。比如你想让模型查公司内部的订单数据、调用自主研发的推荐服务、或者把一套遗留系统的REST接口发布成AI工具现成server根本指望不上。我见过不少团队一上来就直接在handler里堆业务代码结果工具一多就乱成一锅粥。这里有个关键认知MCP Server本质上是把“AI客户端”和“业务能力”解耦的一层适配器它不负责核心业务逻辑而是负责把工具的输入输出翻译成模型能理解的schema并管理调用生命周期。所以写自定义server时重点不应该放在业务实现上而是放在协议层、校验层、业务调用层的分离上。从调用链来看客户端通过JSON-RPC发来tools/call请求server解析工具名和参数执行对应的handler然后把结果序列化返回。整个过程里参数校验、错误返回、进度通知、日志推送都是协议层面的事真正查数据库、调外部API、处理文件是业务层面的事。把这两件事放在同一个文件里短期内很爽长期维护就是灾难。1.2 技术选型为什么选 TypeScript 而不是 PythonMCP官方SDK提供了Python和TypeScript两套选型上我推荐TS。理由有几点一是类型系统对协议字段的约束特别友好工具入参校验、错误对象结构在编译期就能暴露一堆问题二是和前端/Node生态的CI、Docker体系衔接顺滑现在很多团队后端就是Node三是部署轻量一个node进程就能跑不需要虚拟环境。Python版也不是不行特别是数据类、AI类项目本来就围绕Python比如对接本地大模型、做RAG检索Python生态确实更方便。但如果你写的是对接业务系统的serverTS会让参数schema的可维护性高很多。zod的类型推断、JSON Schema自动生成这一套组合写起来非常顺手。后面第4部分我会展开讲这里先提一句别在TS项目里硬写JSON Schema用zod生成才是正道。1.3 项目结构协议层、校验层、业务层三层分离直接给一个我常用的目录结构mcp-server/ ├── src/ │ ├── index.ts # 入口启动stdio或HTTP transport │ ├── server.ts # 创建Server实例注册工具委托 │ ├── tools/ # 工具注册定义schema handler │ │ ├── weather.ts │ │ └── report.ts │ ├── handlers/ # 业务处理器不关心协议细节 │ │ ├── weather.ts │ │ └── report.ts │ ├── errors.ts # 错误码、业务错误类 │ ├── logger.ts # 结构化日志 │ └── config.ts # 环境变量校验 ├── package.json ├── tsconfig.json ├── tsup.config.ts └── Dockerfile协议层在server.ts和index.ts负责和客户端聊JSON-RPC工具层在tools目录只做schema定义和参数映射业务处理器在handlers目录不出现任何MCP相关类型。这样做的好处是业务逻辑可以脱离MCP独立测试后续如果协议升级或者换SDK改动集中在最外层。如果你想让团队里的Java、Go同事也能维护业务代码这种分层尤其重要他们根本不需要懂MCP。2. 错误处理的工程化设计别让大模型替你猜2.1 MCP错误协议和错误码MCP基于JSON-RPC 2.0错误必须走error响应而不是在result里塞一个字符串。SDK提供了McpError你应该在handler里主动throw而不是返回一个诡异对象。先看错误码列表错误码名称典型场景-32700Parse errorJSON解析失败通常客户端问题-32600Invalid Request请求结构不对-32601Method not found工具名不存在-32602Invalid params参数校验失败-32603Internal error未捕获异常-32000~-32099Server error自定义错误范围这里必须说一个我踩过的坑很多教程写handler时只会返回结果从不throw。结果工具内部报错了客户端看到的response还是200模型拿到一个success:false字段还得自己去猜哪里出了问题。到了生产环境这非常难受因为模型会把错误信息当成业务数据来“编故事”。所以我现在的做法是业务处理中的所有异常统一转为McpError或自定义Error保证错误的语义是显式的。2.2 自定义业务错误码在errors.ts里定义一套业务错误复用JSON-RPC的server error段-32000到-32099同时给错误附加一个bizCode字符串字段方便客户端和日志系统做程序化处理。import { McpError, ErrorCode } from modelcontextprotocol/sdk/types; export class AppError extends McpError { constructor( public readonly bizCode: string, message: string, data?: unknown ) { super(ErrorCode.InternalError, message, { bizCode, ...(data as object) }); } } export const Errors { ExternalAPIUnavailable: () new AppError(EXTERNAL_API_UNAVAILABLE, 上游接口暂时不可用), Timeout: (ms: number) new AppError(TIMEOUT, 请求超过 ${ms}ms 未响应), InvalidConfig: (key: string) new AppError(INVALID_CONFIG, 配置项 ${key} 缺失或非法), };这样设计之后丢给客户端的错误结构是稳定的code、message、datadata里带bizCode。AI客户端拿到message可以做自然语言反馈运维拿到bizCode可以做监控告警两边各取所需。如果你只是throw一个普通ErrorSDK虽然也会包装但错误信息很可能被截断或者结构不符合预期模型理解起来也更费劲。2.3 统一异常中间件wrapper在Node的SDK里工具注册是server.registerTool({ name, description, inputSchema, handler })。如果每个handler里都自己try/catch代码会非常啰嗦。我封装了一个wrapTool函数把通用的异常处理、日志、计时全收进去function wrapToolT extends Recordstring, unknown( toolName: string, handler: (args: T) Promiseunknown ) { return async (args: T) { const start Date.now(); logger.info(tool invoked, { tool: toolName, args }); try { const result await handler(args); logger.info(tool success, { tool: toolName, duration: Date.now() - start, }); return result; } catch (err) { if (err instanceof McpError) { logger.warn(tool mcp error, { tool: toolName, err }); throw err; } // 非MCP错误打完整堆栈但抛出去时只保留安全信息 logger.error(tool failed, { tool: toolName, error: err }); throw new AppError(INTERNAL_ERROR, 工具执行失败请稍后重试); } }; }注意这里有个安全设计完整堆栈只在服务端日志里丢给客户端的信息绝不包含内部路径、数据库连接串、第三方API密钥。因为模型可能把这个错误信息原样打印给用户甚至写进生成的文件里。一旦泄露内部IP或token后果很严重。2.4 错误日志与追踪错误处理不只是“抛个异常”还包括记录。我建议为每个请求生成requestId在handler入口埋入日志结束和异常时各打一条。这样排查问题会舒服很多直接在MCP Inspector或者客户端日志里看到requestId再回服务端日志grep。import crypto from node:crypto; export function createRequestId(): string { return crypto.randomUUID(); }在wrapTool里尽量把requestId注入到日志上下文中。如果没有日志平台也可以简单地在服务端打印出来。我通常会把args、耗时、错误码都打进去这样大多数问题不需要复现就能定位。这里提醒一下参数里如果包含敏感信息比如用户token、密钥打日志前要脱敏写个sanitize函数处理一下。2.5 边界情况超时、取消与流中断请求超时是自定义server最常见的故障来源。如果tool要去调用外部REST接口务必给所有fetch加AbortController超时。不做超时控制模型很可能被一个慢接口卡到连接断开客户端那边表现成“工具无响应”。async function fetchWithTimeout(url: string, timeoutMs 10_000) { const controller new AbortController(); const timer setTimeout(() controller.abort(), timeoutMs); try { return await fetch(url, { signal: controller.signal }); } finally { clearTimeout(timer); } }另外MCP的请求是可以被客户端取消的如果handler里持有长任务需要监听取消信号及时释放资源。SDK内部对取消通知有处理但你自己的业务循环里要定期检查比如每处理100条数据就判断一下是否被中止避免做无用功。3. 流式输出让长文本和大任务不再“卡死”3.1 为什么需要流式进度反馈 vs 内容流MCP场景里的流式输出有两个维度。一是进度可视化工具在跑一个10分钟的数据处理任务客户端需要知道你执行到哪一步二是长文本输出工具返回一个几万字的报告一次性塞进JSON response里既不现实用户在UI上也看不到中间过程。这两个问题不解决自定义server一遇到大任务就会像个黑盒体验很糟糕。3.2 用notifications/progress做进度反馈SDK的server实例有sendNotification方法可以在handler执行过程中推送通知。看下面这个例子await server.sendNotification({ method: notifications/progress, params: { progressToken, progress: 30, total: 100, message: 正在拉取数据, }, });progressToken是从客户端请求参数里带过来的别自己造。客户端比如MCP Inspector会在界面上显示进度条。长任务里建议把进度计算独立成一个辅助函数不要散落在业务代码里。有一个容易忽略的点不是所有客户端都会传progressToken如果没传你推送进度通知可能无效甚至报错所以要判空。3.3 用logMessage把中间日志发给客户端除了进度还可以用notifications/message推送结构化日志。这个在调试时特别有用客户端界面直接能看到你服务端的执行日志不用每次都戳服务器看文件。await server.sendLogMessage({ level: info, logger: report-tool, data: 开始生成报告共5000行, });注意有些客户端对logMessage的渲染不一定很友好所以不要把敏感信息发过去只发必要的过程信息。我在调试阶段会用这个功能生产环境下默认不开启或者只发WARN和ERROR级别避免日志刷屏。3.4 工具结果的流式返回ReadableStream 与分块再说长文本。现在的SDK支持工具结果返回ReadableStream以Streamable HTTP传输时尤其友好。如果你的文本可以分批生成就返回一个流客户端可以边收边渲染。import { Readable } from node:stream; await server.registerTool({ name: stream_report, description: 流式生成一份超长报告, inputSchema: { type: object, properties: { chunks: { type: number, description: 分块数量 } }, }, handler: async (args) { const chunks args.chunks ?? 10; const stream new ReadableStream({ async start(controller) { for (let i 0; i chunks; i) { controller.enqueue({ type: text, text: 第${i 1}块内容\n }); await new Promise((r) setTimeout(r, 100)); } controller.close(); }, }); return { content: stream }; }, });这里最好先确认目标客户端是否支持流类型因为有些客户端实现得早只认纯数组content遇到流会直接报错。我一般默认做兼容策略小结果返回数组大结果返回流。具体阈值可以根据你的场景定比如超过20KB就切流。3.5 流式节奏与背压控制流式输出不是无脑enqueue。如果生成速度比客户端消费速度快太多内存会爆。比较稳妥的做法是做节流比如每100ms enqueue一条或者限制队列长度。这个和你对接的LLM API、下游数据库都有关系需要压测后定。个人经验是先小批量每512字符或每200ms推观察客户端表现再逐步放大。还有一个细节流结束后的清理工作要做好。如果中途出错要调用controller.error(err)而不是直接close让客户端知道这次流不完整。不然客户端等半天只拿到一半数据还以为是自己网络问题。4. TypeScript 工程化类型即文档校验即边界4.1 为什么TS写MCP体验更好MCP的inputSchema本质上是一个JSON Schema而TS的类型和zod schema可以做到几乎无缝映射。入参校验这个环节zod比手写JSON Schema要舒服很多而且报错信息对模型非常友好。模型需要在工具列表中读懂“这个参数是什么意思、取值范围是什么”如果schema里都是晦涩的描述它就会填错。另外TS还有declare global、命名空间这些特性可以做类型增强不过对MCP server来说最常用的还是zod 泛型这套组合。类型即文档校验即边界这就是TS能提升MCP开发效率的核心原因。4.2 用zod做参数校验并与SDK对接MCP SDK的inputSchema需要的是JSON Schema对象所以要么手写JSON Schema要么把zod对象转成JSON Schema。官方推荐借助zod-to-json-schema这类工具import { z } from zod; import { zodToJsonSchema } from zod-to-json-schema; const WeatherArgs z.object({ city: z.string().describe(城市名例如北京), unit: z.enum([celsius, fahrenheit]).default(celsius), }); await server.registerTool({ name: get_weather, description: 查询实时天气, inputSchema: zodToJsonSchema(WeatherArgs, WeatherArgs), handler: async (args) { const parsed WeatherArgs.parse(args); // typed: { city: string; unit: celsius | fahrenheit } // ... }, });zod的好处是默认值、枚举、嵌套元组都表达得很清楚生成给模型看的schema可读性也高。描述里要写清楚枚举的取值含义模型才能正确填参。比如unit字段你不写celsius和fahrenheit的语义模型可能会传一个“C”。4.3 泛型工具函数减少样板代码实际项目里很多工具注册逻辑是重复的。我推荐封装一个工厂函数export function defineToolT extends z.ZodTypeAny( name: string, description: string, schema: T, handler: (args: z.inferT) Promiseunknown ) { return { name, description, inputSchema: zodToJsonSchema(schema, name), handler: async (args: unknown) { const safe schema.parse(args); return handler(safe); }, } as const; }然后每个工具文件就只声明“输入是什么、做什么”不再重复build流程。比如这样export const getWeatherTool defineTool( get_weather, 查询实时天气, WeatherArgs, async ({ city, unit }) { // 这里city和unit都有类型了 return { content: [{ type: text, text: 天气信息... }] }; } );代码量至少降一半而且后续切SDK或调整校验逻辑时只改包装函数读者和维护者都会感谢你。4.4 编译与打包ESM、CJS、tsupMCP SDK新版本对ESM/CJS都支持但部署环境不同踩过的坑也不少。我现在的做法是用tsup同时产出ESM和CJS并生成d.ts。配一个tsup.config.tsimport { defineConfig } from tsup; export default defineConfig({ entry: [src/index.ts], format: [esm, cjs], dts: true, sourcemap: true, clean: true, target: node20, });注意如果你的server要给别人通过npx运行建议在package.json里加上bin字段并且标记type: module或commonjs要和产物对应否则会出现ERR_REQUIRE_ESM或require报错。另外包里不要带上ts-node部署时直接跑编译后的js。我在早期犯过把ts-node当运行时用的错结果一上生产环境就各种坑。4.5 调试技巧MCP Inspector VSCode写MCP server时调试是非常关键的。官方提供了modelcontextprotocol/inspector命令行工具可以图形化测试server。用法大致是执行npx modelcontextprotocol/inspector node dist/index.js然后浏览器访问调试端口。调试小技巧在index.ts里加一个环境变量开关让server支持stdio和HTTP两种模式。本地调试用stdio inspector远端部署用Streamable HTTP同一套代码都能跑。默认情况下如果检测到环境变量MCP_SERVER_TRANSPORThttp就启动HTTP模式否则走stdio。这样不用维护两套入口而且非常方便在VSCode里打断点调试。import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; const server createMcpServer(); if (process.env.MCP_SERVER_TRANSPORT http) { // 挂到Express等HTTP框架上 } else { const transport new StdioServerTransport(); await server.connect(transport); }5. 部署与运维从本地跑到公网别忽略传输和鉴权5.1 本地开发stdio 模式本地调试时MCP server通常以stdio传输启动。客户端Cursor、Claude Code、Cline会在子进程里启动你的node入口。这里有一个关键约束不能往stdout打印任何业务日志因为stdout是JSON-RPC通道。之前有个同事把console.log写在handler里结果客户端直接解析失败排查了半天才发现是日志污染了协议通道。所有调试日志都要走stderr或logger。如果你用PM2或systemd管理stdio进程也要注意这一点。有些进程管理器会把stdout重定向到文件如果这个日志文件被误读客户端一样会出问题。5.2 远程部署Streamable HTTP / SSE如果想让server被远程客户端调用就得用HTTP传输。MCP新版推荐Streamable HTTP Transport它比老SSE多了“可写”能力。实现一般是这样import { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; import express from express; const app express(); app.use(express.json()); const server createMcpServer(); app.post(/mcp, async (req, res) { const transport new StreamableHTTPServerTransport({ sessionIdGenerator: () crypto.randomUUID(), onsessioninitialized: (sessionId) { console.log(session started: ${sessionId}); }, }); res.on(close, () transport.close()); await server.connect(transport); await transport.handleRequest(req, res); });注意一个永久性的坑HTTP传输是有状态session的客户端必须带上session id你不能每次请求都new transport然后把老连接顶掉。生产环境建议用内存或Redis保存session。如果你发现客户端连接一多就频繁断大概率就是session管理出了问题。5.3 Docker化部署Docker部署建议用多阶段构建减小镜像体积FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-alpine WORKDIR /app COPY --frombuild /app/dist ./dist COPY package*.json ./ RUN npm ci --omitdev ENV NODE_ENVproduction EXPOSE 3000 CMD [node, dist/index.js]然后docker-compose里记得做健康检查。如果server本身没提供/healthz接口可以在入口多加一个路由或直接用tcp对端口探测。services: mcp-server: build: . ports: - 3000:3000 environment: - MCP_SERVER_TRANSPORThttp - MCP_API_TOKEN${MCP_API_TOKEN} healthcheck: test: [CMD, wget, -qO-, http://localhost:3000/healthz] interval: 30s timeout: 5s retries: 3有一点要注意alpine镜像里默认没有wget健康检查会失败所以要么在Dockerfile里装busybox-wget要么用node命令做HTTP请求。5.4 环境变量与鉴权远程部署一定要做鉴权。最简单的是在应用层检查Authorization Bearer token或者用Nginx做basic auth。环境变量不要写死在代码里。启动时用zod校验环境变量const configSchema z.object({ PORT: z.coerce.number().default(3000), MCP_API_TOKEN: z.string().min(32), DATABASE_URL: z.string().url(), }); const config configSchema.parse(process.env);配置不对直接启动失败比运行到一半再报错要舒服。而且我建议在README里写清楚每个环境变量的用途和示例值不然过几个月你自己都会忘。5.5 反向代理与公网发布如果要暴露到公网不要让Node进程直接面对公网建议前面挂Nginxserver { listen 443 ssl; server_name mcp.example.com; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 300s; } }这里proxy_read_timeout是重点。我踩过很久的坑默认60秒工具执行一旦超过60sNginx就返回504客户端只能看到空响应。如果你流转的任务可能跑几分钟这个值要相应调大。另外如果启用了HTTP/2或gzip注意有些老客户端对SSE或流式响应在压缩层表现不佳最好对/mcp路径关闭gzip。5.6 客户端适配与兼容不同客户端对MCP server的支持程度不一样比如Cursor、Claude Desktop、Cline、MCP Inspector对传输方式、session机制的支持有差异。建议在README里写明本地通过stdio使用远端通过URLtoken使用。另外如果客户端支持自定义请求头配置时把Authorization头填好。Figma MCP为啥要问token在哪获取就是因为很多MCP server用HTTP模式部署时都靠token鉴权这一步绕不开。6. 常见问题与排查技巧实录6.1 工具就是不出现在客户端里大概率是注册工具时没有正确实现listToolsSchema或者服务器没有正确启动。排查顺序先用MCP Inspector跑一遍确认工具列表正常再看客户端进程的stderr日志是否有错误再看是否因为缓存导致旧的工具列表被客户端保留重启客户端再试。还有一个容易被忽略的原因工具名和协议保留关键字冲突比如名字叫“connect”或“close”可能导致客户端过滤掉。6.2 参数校验总是失败模型传参乱来模型对schema的理解取决于描述质量。JSON Schema里的description要写清格式和示例比如日期字段写上“格式YYYY-MM-DD”枚举字段写明含义。必要时用zod的.transform()先做归一化再进业务。如果你的工具要接收数组务必在schema里定义items类型否则模型可能传一个字符串进来。还有一点给schema加上additionalProperties:false能防止模型传多余的字段被静默忽略也避免意外副作用。6.3 长任务一执行就连接中断先检查超时设置Node侧、Nginx侧、客户端侧都有各自的超时。如果用的是stdio传输客户端子进程可能因为stdout缓冲溢出被杀掉这时要减少一次性输出改用流式。如果是HTTP模式检查proxy_read_timeout和SDK内部的timeout配置。再有一点就是心跳机制部分客户端会定期发ping如果server长时间不响应客户端会主动断开。6.4 部署后发现客户端收不到流式输出确认transport版本与客户端兼容MCP的动态端点格式POST /mcp需要客户端支持。如果客户端比较老可能需要退回到SSE模式。还有一点流式输出时别手动关闭HTTP连接应该让流结束自动触发。很多人习惯在handler末尾加res.end()这会把流截断务必注意。6.5 日志打了一堆业务还是难定位建议在每条日志上带requestId并在工具入口、出口、异常三处都打点。日志级别也要设计好info记录调用debug记录参数和中间过程error记录异常堆栈。生产环境把日志收集到统一的日志平台不然排查效率极低。我个人的习惯是给每个工具一个单独的子日志logger这样过滤起来非常方便。6.6 一些小坑速查永远不要把console.log写到stdout会污染JSON-RPC通道。JSON Schema里字段建议加additionalProperties:false防止模型传多余字段进来。工具返回的content数组里每种typetext/image/resource的格式要符合SDK定义一个字段顺序错了客户端就可能解析失败。升级SDK版本时务必看CHANGELOGMCP协议还在快速演进很多API会破坏性变更。如果用stdout做stdio传输测试时避免用console.log输出对象对象会被强制转字符串JSON结构就坏了。最后说句实际的MCP自定义服务器开发没有太多玄学把协议错误处理、流式输出、工程化、部署这四块吃透生产环境的99%问题都能解决。我在实际项目里最大的体会是不要一门心思堆业务工具先花时间把错误模型和传输层搞稳后面加工具只是体力活。另外MCP生态还在高速变化SDK版本更新很快建议动手前先看官方仓库的README和CHANGELOG也多在社区看看成功的case。踩过几次坑之后你会发现自己对协议的理解会深很多再去看什么Figma MCP、蓝湖MCP的实现基本一眼就能看出他们的架构设计思路。
返回列表