MCP自定义服务器开发实战:错误处理、流式输出、TypeScript与部署全指南
我在做 MCP 自定义服务器开发之前其实走了不少弯路。一开始以为只要把 SDK 装上、把工具函数一个个暴露出来就算完事结果一接入真实 Agent 场景问题全冒出来了工具偶发超时、错误信息模型看不懂、长时间任务客户端直接卡死、换台机器部署又起不来。后来把错误处理、流式输出、TypeScript 工程化、部署这套链路重新过了一遍才算是真正把 MCP 自定义服务器做成一个可以交付的东西。这篇东西就是我这段时间踩坑后的整理按标题里的四条主线来讲适合已经跑通 MCP 基础 Demo、准备做生产级服务器的同学参考。如果你还没接触过 MCP我建议先把什么是 MCP Server这条概念补上。MCP 全称 Model Context Protocol模型上下文协议解决的是 AI 应用和大模型、外部工具、数据源之间的连接标准化问题。自定义 MCP 服务器简单说就是你自己写一个符合这套协议的服务把内部 API、数据库、文件系统、业务逻辑包装成 AI 客户端可以调用的工具。而一旦进入真实业务错误处理、流式输出、TypeScript 类型安全、部署运维就变成绕不开的硬骨头了。1. MCP 自定义服务器开发从 Demo 到生产级的差距在哪1.1 MCP 服务器到底解决什么问题我常把 MCP 自定义服务器理解成AI 时代的适配器。大模型本身不直接访问你的数据库、不直接调你的订单接口它只会通过工具调用Function Calling去触发某个动作。MCP 就是把这些动作标准化成统一的 JSON-RPC 请求让客户端比如 Claude、Cursor、自研 Agent可以用同一种方式去发现工具、调用工具、接收结果。MCP 服务器分两种形态一种是本地进程比如你写个脚本放在电脑上客户端通过 stdio 和它通信另一种是远程服务客户端通过 HTTP 访问。无论哪种形态核心都是把工具暴露给 AI。我们说的自定义服务器通常指基于官方 SDK 开发自己的工具集合而不是用现成的 file、database 这类内置服务器。纯 Demo 阶段你可能只写一个函数返回一段文本客户端能调用就完事。但生产环境里AI 会频繁调用工具请求会失败数据会超时用户会催结果服务会崩。这时候能不能跑通和能不能稳定跑完全是两码事。这就是我把错误处理、流式输出、TypeScript、部署列为进阶四件套的原因。1.2 为什么第一条主线是错误处理MCP 本质是远程过程调用AI 客户端拿到错误信息后会尝试自我修正。如果你的错误信息是干巴巴的Error: something wrong模型根本不知道下一步该怎么办。错误处理不是简单地 catch 异常而是要设计一套机器可读、模型可理解的错误协议让 Agent 能根据错误码、错误描述、重试建议做出正确决策。举个例子你暴露了一个查询订单的工具如果订单号不存在你得告诉 Agent订单不存在请检查参数 order_id 是否准确而不是直接抛一个 Internal error。前者模型可以自行修正参数后重试后者模型只能反复报错或者直接放弃。这就是错误处理在 MCP 场景下的独特之处。1.3 第二条主线流式输出是用户体验的分水岭我先问个问题你的工具执行一个耗时任务比如生成一份 PDF、批量处理一千条数据客户端会怎样如果没有流式输出客户端会一直转圈AI 模型也不知道进展用户体验极差。实际的 Agent 场景里很多工具执行时间都超过十秒甚至几分钟。流式输出可以让服务器在执行过程中持续发送进度事件客户端能实时展示正在处理第 3/10 条用户知道系统没卡死模型也能根据中间结果提前判断是否要继续。很多人误以为流式输出只和大模型 Token 生成有关其实 MCP 工具层同样需要。MCP 协议里定义了进度通知Progress Notification机制服务器可以通过它向客户端推送执行进度。如果你只开发单次返回的工具这条可以跳过但只要涉及耗时任务流式输出就是必备能力。1.4 第三条主线TypeScript 是开发效率与稳定性的放大器官方 SDK 对 TypeScript 支持很好类型定义非常完整。用 TypeScript 写 MCP Server最直接的好处是工具参数校验、请求类型推导、错误类型收窄都能在编译期发现。写 JavaScript 的时候我最头疼的就是处理一个可能为 null 的请求参数总得在运行时加各种判断换成 TypeScript 之后SDK 会帮我把请求结构约束好我只要关注业务逻辑。还有一个隐藏好处TypeScript 的编译产物可以直接运行在任何 Node 环境部署时用 tsc 或 tsup 打包成纯 JavaScript 即可依赖清晰体积可控。后面部署章节我会细说。1.5 第四条主线部署能力决定了服务器能不能真正被用起来本地开发没问题一堆测试代码跑得飞起但你要让同事或者线上 Agent 调用你的 MCP Server就得部署。部署里最常见的坑包括端口被占用、Node 版本不兼容、环境变量缺失、服务进程没有守护、重启后起不来。更麻烦的是远程调用时的安全认证——你不能把内部工具裸奔在公网上。我自己的习惯是本地开发用 ts-node 或 tsx watcher 调试测试通过后打包成 dist 目录再用 PM2 或者 Docker 部署。Docker 部署的好处是环境一致本地能跑服务器就能跑PM2 适合单机快速部署。两种方式我都会在第五章详细讲。2. 错误处理把不可预期的异常变成可预期协议2.1 先搞清楚 MCP 的错误基线MCP 基于 JSON-RPC 2.0所以错误码也遵循 JSON-RPC 规范。标准错误码范围是 -32768 到 -32000其中常用的有-32700解析错误请求不是有效 JSON-32600无效请求请求结构不符合规范-32601方法不存在调用了未注册的方法-32602参数无效参数类型或数量错误-32603内部错误服务器执行时发生未捕获异常我踩过的第一个坑是工具执行业务逻辑出错时直接抛了个普通 ErrorSDK 会把这个错误包成 code -32603 返回给客户端。这个响应本身没错但信息太贫乏模型无法判断是参数问题还是业务问题。所以我在设计错误处理时优先做的事是分类参数错误、业务错误、外部依赖错误、未知错误每种都定义清晰的结构。2.2 设计一个结构化的错误对象我习惯给 MCP Server 的错误定义一套统一的结构字段包括 code、message、details、retriable。前端 AI 拿到这个结构后能明确判断是不是参数问题要不要重试重试的话建议怎么做在 TypeScript 里我这样定义export type McpToolError { code: string; // 业务错误码如 ORDER_NOT_FOUND message: string; // 人类可读的错误描述 details?: unknown; // 附加数据比如哪个字段错了 retriable: boolean; // 客户端是否应该重试 };为什么要有 retriable 这个字段因为在 Agent 场景下模型拿到错误后会判断是否要发起另一轮调用。如果我们的错误说数据库连不上请稍后重试模型大概率会等一会再试一次如果说参数 price 必须是数字当前是 string模型会修正参数再调用。要是没有这个字段模型只能靠猜容易陷入不断重试的死循环。2.3 在工具函数里统一 catch 并包装错误MCP SDK 允许你在注册工具时传入一个执行函数。我通常会在执行函数的最外层加一个包装函数把内部异常转换成上述结构并封装成 McpError 抛给 SDK。import { McpError, ErrorCode } from modelcontextprotocol/sdk/types.js; async function safeExecuteT(task: () PromiseT): PromiseT { try { return await task(); } catch (error) { if (error instanceof McpError) { throw error; } // 普通异常包装成内部错误 throw new McpError( ErrorCode.InternalError, 工具执行异常: ${error instanceof Error ? error.message : String(error)} ); } }当然这只是兜底。真正的业务错误应该在每个工具内部主动抛出一个可识别的错误。我会写一个自定义错误类export class ToolExecutionError extends Error { constructor( public readonly code: string, message: string, public readonly retriable: boolean false, public readonly details?: unknown ) { super(message); this.name ToolExecutionError; } }然后在工具实现里主动判断业务规则不满足就直接抛出 ToolExecutionError。这样错误信息才足够明确不会等到最后变成泛泛的 InternalError。2.4 错误信息如何写AI 才能看懂这里有个容易被忽略的点MCP 的错误消息不只是给人看的更是给 AI 模型看的。模型会基于 message 的内容决定下一步行动。所以写错误信息时要做到三点指明是哪个工具、哪个参数出了问题例如 Tool create_order parameter amount must be a positive number, got -5给出修正建议例如 Please provide a positive amount and retry避免模棱两可的措辞比如 failed 就没有 the upstream payment API returned 500 有用我见过不少同事在错误信息里写 Error: something is wrong这等于把判断责任全甩给了模型。你可以想象一下AI 模型看到这句提示只能一脸懵地重试或者直接放弃。实际测试中一个含参数提示的错误信息能让 Agent 自动修正后成功的概率从不到 50% 提升到 80% 以上。2.5 全局兜底要与进程安全分开MCP Server 和普通 Web 服务不同它往往是长驻进程。如果某个工具实现里出现了未捕获的 promise rejection可能导致整个进程崩溃。所以我建议在进程级别也加上兜底process.on(unhandledRejection, (reason) { console.error([mcp-server] unhandledRejection:, reason); // 这里可以选择继续运行也可以做状态上报 }); process.on(uncaughtException, (err) { console.error([mcp-server] uncaughtException:, err); // 重要记录后可以优雅退出由守护进程拉起 });有人说既然要守护进程崩溃了重启就行何必捕获话是这么说但重启会中断正在进行的工具调用。能捕获的异常尽量在边界处捕获保住进程稳定性才是生产环境的正确姿势。这里的区别是业务异常要返回给客户端进程级异常要记录并尽量保持存活。3. 流式输出从单次阻塞到实时反馈3.1 什么时候非用流式不可我刚开始做 MCP Server 时总觉得工具调用嘛请求进去、结果出来都是同步的。直到我接了一个批量翻译文档的工具用户给一篇十页的文档工具要逐段调用翻译接口总耗时可能要两三分钟。如果不用流式客户端会进入长时间的等待用户反复刷新搞不好还会 Agent 直接判定超时然后放弃。这种场景下流式输出不是锦上添花而是刚需。MCP 的进度通知就是一种流式信息的载体。它可以让客户端持续收到服务器端的工作进度甚至中途的中间结果。用户看到正在翻译第 8/32 段心里就有底了Agent 也能在半途判断是否需要调整策略。3.2 理解 MCP 的进度通知机制MCP 协议里服务器向客户端推送进度依赖notifications/progress这个通知。客户端发起一个工具请求时可以在请求参数中带上_meta.progressToken这相当于给这个任务打了一个标识。服务器在执行工具的过程中可以多次发送 progress 通知每次带上这个 token 和进度值。举个具体例子客户端发来的请求是这样的{ method: tools/call, params: { name: translate_doc, arguments: { docId: 12345 }, _meta: { progressToken: task-abc } } }那么服务器就可以在进度更新时发送{ method: notifications/progress, params: { progressToken: task-abc, progress: 5, total: 32 } }客户端会把这看作实时进度更新。如果你在做一个 Agent 前端完全可以把这些进度渲染到界面上用户看到的不是转圈而是具体进展。3.3 在 TypeScript 的 MCP Server 里实现进度通知SDK 中使用进度通知很简单在工具执行函数中直接调用server.notification。我写了一个示例工具批量处理图片。它会循环处理多张图片每完成一张就推送一次进度。import { Server } from modelcontextprotocol/sdk/server/index.js; import { CallToolRequestSchema } from modelcontextprotocol/sdk/server/tools.js; server.setRequestHandler(CallToolRequestSchema, async (request) { if (request.params.name batch_process_images) { const args request.params.arguments as { imageUrls: string[] }; const progressToken (request.params._meta as any)?.progressToken; const total args.imageUrls.length; for (let i 0; i total; i) { // 实际处理图片逻辑这里省略 await processImage(args.imageUrls[i]); if (progressToken) { await server.notification({ method: notifications/progress, params: { progressToken, progress: i 1, total } }); } } return { content: [{ type: text, text: 完成 ${total} 张图片处理 }] }; } });注意三点第一progressToken可能不存在客户端可以不提供所以要判空第二循环里不要等全部完成再通知每处理完一个单元就及时通知第三如果进度更新非常频繁建议做节流比如每处理 10% 才发送一次避免通知风暴。3.4 流式场景下的取消与错误处理流式任务最常见的坑是用户取消了服务器还在傻傻地跑。MCP 协议支持取消通知客户端会发送notifications/cancelled来取消任务。服务器端如果想知道取消信号可以监听这个通知或者借助 AbortController 来中断工具执行。我通常的做法是为每个任务创建一个 AbortController把这个 controller 挂到请求上下文里然后在取消通知里调用 abort。工具内部的耗时循环需要感知这个 abort 信号在每次循环开始时检查一下export async function batchProcess( items: string[], signal: AbortSignal, onProgress: (done: number, total: number) void ) { for (let i 0; i items.length; i) { if (signal.aborted) { throw new ToolExecutionError( TASK_CANCELLED, Task was cancelled by client, false ); } await processItem(items[i]); onProgress(i 1, items.length); } }这样做的好处是取消不是硬杀进程而是让正在执行的逻辑有一个优雅的出口释放掉数据库连接和临时资源然后返回一个可识别的错误给客户端。客户端收到这个错误就知道任务是被自己取消的而不是服务器真的出了故障。3.5 更进一步的流式事件中间结果如果只是进度条其实很多场景不够用。更有价值的是把中间结果也推给客户端。比如逐行分析日志这个工具每分析完一行日志就把分析结果追加推送过去用户或者在顶层 Agent 的编排上就可以实时看到部分输出。实现方式同样是 notification只是 progress 换成了自定义事件。MCP 允许 you 发送自定义方法通知你也可以在同一个服务器上定义一种notifications/tool_reasoning之类的通知把中间结果放在 params 里。这个没有标准格式我建议你把事件名定义清楚并在 README 中说明方便客户端适配。4. TypeScript 工程化拿到类型安全感别在编译期翻车4.1 为什么官方 SDK 之外我还要强调 TS 类型设计MCP 客户端发送过来的请求是 JSON 结构天然没有类型约束。如果你在工具代码里到处用any等于放弃了 TypeScript 最大的优势。我自己写 MCP Server 时会把每个工具的 parameters 定义成明确的接口并且尽量用 zod 或 TypeScript 的 satisfies 去做运行时校验和类型推导。有了类型后自动补全只是小事真正值钱的是重构安全。比如我把某个工具返回的数据结构从{ count: number }改成{ total: number }如果其他地方引用了这个类型编译器会直接标红而不是等运行时踩坑。4.2 工具 Schema 与类型的联动在 MCP SDK 中注册工具时你需要提供一个inputSchemaJSON Schema。这个 schema 决定了客户端AI 模型会怎么生成参数。我强烈建议你不要手写 JSON Schema而是用 zod 推理出来。zod 的z.object()定义好以后既可以在运行时校验参数又能用zodToJsonSchema生成 MCP 需要的 inputSchema一举两得。举个例子import { z } from zod; import { zodToJsonSchema } from zod-to-json-schema; const SendEmailArgs z.object({ to: z.string().email(), subject: z.string().min(1), body: z.string(), cc: z.array(z.string().email()).optional() }); type SendEmailArgsType z.infertypeof SendEmailArgs; const inputSchema zodToJsonSchema(SendEmailArgs, SendEmailArgs);在工具实现里我可以直接const args SendEmailArgs.parse(request.params.arguments)。如果参数不符合要求zod 抛出的错误会带详细的路径信息这是普通手写校验很难比的。4.3 错误类型收窄不要随便用 any处理错误时最容易出现any的地方就是 catch 块里的 error。TypeScript 的 catch 参数默认是unknown你不收窄就没法访问 message。我用的是自定义工具错误类配合 instanceof 收窄try { await doSomething(); } catch (error) { if (error instanceof ToolExecutionError) { // 业务错误正常返回给 MCP 客户端 throw error; } else if (error instanceof SyntaxError) { // 参数解析错误包装成 InvalidParams throw new McpError(ErrorCode.InvalidParams, error.message); } else { // 未知错误统一记录并包装 console.error([unexpected], error); throw new McpError(ErrorCode.InternalError, Unexpected server error); } }这种收窄模式配合我前面定义的错误结构基本能保证错误对象始终是可序列化的 JSON客户端不会拿到奇奇怪怪的对象。4.4 tsconfig 与构建脚本配置新版 MCP SDK 默认走 ESM所以 tsconfig 要按 NodeNext 或 ESNext 配置。我贴一份我常用的最小配置{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, skipLibCheck: true, sourceMap: true, declaration: false }, include: [src/**/*], exclude: [node_modules, dist, test] }构建脚本我用tsc -p tsconfig.build.json然后再加一个tsup打包成单文件这样部署时只要拷贝一个 dist/index.js 就行。tsup 的配置也简单import { defineConfig } from tsup; export default defineConfig({ entry: [src/index.ts], format: [esm], target: node20, sourcemap: true, clean: true });打包单文件的好处是减少部署时的文件数量也避免 node_modules 版本不一致的问题。不过需要注意如果你的工具依赖原生模块单文件打包可能不适用这种情况直接用tsc编译目录更稳妥。4.5 调试 MCP Server 的小技巧TypeScript 调试 MCP Server 时最痛苦的是看 JSON-RPC 请求响应。我用过不少工具最后觉得最适合的是在入口处加一个简单的日志中间件把每次请求和响应打印出来生产环境记得脱敏。SDK 本身也支持 server.on 监听日志事件。比如server.on(notifications, (notification) { console.error([notifications], JSON.stringify(notification)); });注意MCP 的 stdio 传输会把 stdout 当作协议通道如果你在代码里用 console.log 打日志会污染通信。我在调试 stdio 模式时会一律用 console.error 输出日志或者把日志写到文件里。这是新手最容易踩的坑一旦你看到客户端那边请求超时但你的服务里死活没有日志多半就是 console.log 把 stdout 弄坏了。5. 部署实战从本地进程到可靠服务5.1 本地部署开发快但不是终点开发阶段直接用tsx watch src/index.ts跑起来很爽改代码自动重启。但如果要给同事或者远端 Agent 用你得部署到一个长期运行的环境上。最简单的方案是编译后直接用 Node 运行npm run build node dist/index.js不过裸跑 node 有很多隐患进程挂了没人拉起来、重启不自动、日志没轮转。所以我建议至少用 PM2 这样的进程守护工具。PM2 可以配置自动重启、内存限制、日志文件配起来很省事。一个最小 PM2 配置module.exports { apps: [ { name: mcp-tool-server, script: dist/index.js, instances: 1, exec_mode: fork, autorestart: true, max_memory_restart: 300M, env: { NODE_ENV: production, MCP_SERVER_PORT: 3001 } } ] };注意MCP 服务器如果走 stdio 传输不应该用 cluster 模式instances 设 1用 fork 模式。否则多个进程抢占 stdin/stdout协议就乱了。如果走 HTTP/SSE 传输才可以用 cluster 或者多实例配合负载均衡。5.2 Docker 部署环境一致性最好Docker 是我现在最推荐的部署方式。好处不用多说本地能跑服务器上就能跑不用担心 Node 版本不一致也不会污染宿主环境。下面是一个多阶段构建的 Dockerfile# 构建阶段 FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY tsconfig.json tsup.config.ts ./ 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 3001 CMD [node, dist/index.js]这里我用了两阶段构建最终镜像只包含运行需要的文件和依赖。你可以根据自己的实际情况调整比如如果你没用 tsup就拷贝 dist 目录即可。需要提醒的是别把.env文件塞进镜像里我一般通过环境变量注入配置用 docker-compose 管理。docker-compose 的例子version: 3.8 services: mcp-server: build: . ports: - 3001:3001 environment: - MCP_SERVER_PORT3001 - DB_CONNECTION_STRINGpostgres://user:passhost:5432/db - MCP_AUTH_TOKENchange-me-in-prod logging: driver: json-file restart: unless-stopped5.3 通过 HTTP 暴露给远程客户端时的安全配置如果你的 MCP Server 是走 HTTP 传输的那么恭喜你你进入了更需要小心的安全区。公网上裸奔的 MCP 服务器如果没鉴权相当于任何人都能调用你的内部工具轻则被刷请求重则核心数据泄露。我建议至少在入口处加一个 Bearer Token 校验。SDK 允许自定义传输中间件如果你使用 Express 或原生 http 模块可以在启动时检查Authorization头。下面是一个非常简化的 Express 中间件思路const authMiddleware (req: Request, res: Response, next: NextFunction) { const token req.headers.authorization?.replace(Bearer , ).trim(); if (token ! process.env.MCP_AUTH_TOKEN) { res.status(401).json({ error: Unauthorized }); return; } next(); };如果你的云服务平台支持额外的安全组或身份验证也可以把鉴权放在网关层。无论如何不要只在业务代码里判断 token而是要在所有工具入口之上统一拦截。5.4 远程协议HTTPS 与反向代理直接把 Node 服务监听 80/443 端口又得处理证书、进程权限、代理缓冲等问题太麻烦。更稳的做法是前面挂一个 Nginx 或 Caddy 做反向代理负责 HTTPS 终止和流量转发。MCP 的 Streamable HTTP 和 SSE 都是基于 HTTP/1.1 长连接反向代理的配置有几点要特别注意关闭代理缓冲否则流式输出会被缓冲客户端半天收不到数据设置长连接超时比如 60 秒以上避免耗时任务被网关切断保留 Host 和 Authorization 头拿 Nginx 来说关键配置长这样server { listen 443 ssl; server_name mcp.example.com; ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_send_timeout 300s; } }proxy_buffering off是最关键的一行少了它流式消息会被 Nginx 攒到缓冲区前端看到的进度就是一阵一阵的体验非常痛苦。Caddy 的配置就简单得多reverse_proxy默认就不缓冲还能自动配 HTTPS个人项目里我很推荐。5.5 日志、监控与优雅停机部署上线不是终点。你需要知道服务器今天被调用了多少次、哪些工具失败了、响应时间多长。最简单的做法是输出结构化 JSON 日志然后用 logrotate 轮转。如果团队已经有 Prometheus 监控体系也可以在 MCP Server 里暴露一个/metrics端点把请求数、错误类型、耗时直方图上报上去。MCP 服务器支持优雅停机非常重要。因为一个耗时任务可能正在执行直接 kill 进程会让客户端拿到一个不完整的连接错误。我在项目里会监听 SIGTERM 信号先停止接收新请求再等待当前任务结束然后关闭服务器。async function shutdown(signal: string) { console.error([mcp-server] received ${signal}, shutting down gracefully); await server.close(); process.exit(0); } process.on(SIGTERM, () shutdown(SIGTERM)); process.on(SIGINT, () shutdown(SIGINT));配合 Docker 或 PM2 的 stop 命令这套优雅停机逻辑能显著降低生产环境里的客户端看到服务端突然断开的报错。5.6 部署后的连通性测试部署完不能拍拍屁股走人。我通常会写一组冒烟测试至少覆盖三种情况工具列表能拉到、单个简单工具调用成功、错误参数能返回结构化错误。测试不一定用 MCP 客户端库我经常直接拿 curl 模拟 JSON-RPC 请求去验证 HTTP 传输。比如curl -X POST http://localhost:3001/mcp \ -H Authorization: Bearer test-token \ -H Content-Type: application/json \ -d {jsonrpc:2.0,id:1,method:tools/list,params:{}}返回里应该有你注册的所有工具列表。然后再测一次带错误参数的工具调用看错误结构是否符合预期。如果这些基础测试都过了再接入真实的 AI 客户端做端到端验证。6. 常见问题与排查实录6.1 高频问题速查表我在开发和上线过程中整理了一张问题排查表基本覆盖了大多数 MCP Server 的坑现象可能原因解决办法客户端连接不上 MCP Server服务没启动、端口错误、防火墙拦截检查端口监听使用lsof -i:3001或ss -lntpstdio 模式客户端无响应代码里使用 console.log 污染了 stdout改用 console.error 输出日志或写日志文件工具调用报 Method not found工具注册名不匹配或没有在 server 上注册检查 tools/call 中的 name 和注册时的 name 是否一致AI 客户端反复调用同一个错误参数错误信息太模糊模型无法自我修正输出带参数提示和修正建议的结构化错误流式进度收不到客户端没传 progressToken或代理缓冲了响应检查 _meta 字段关闭反向代理缓冲Docker 部署后端口不通容器端口映射错、代码监听 0.0.0.0 而不是 127.0.0.1确认 EXPOSE 和 port 映射监听地址设为 0.0.0.0PM2 重启后报端口占用旧进程没有完全释放端口pm2 delete后重新启动或用 Docker 管理生命周期工具执行中崩溃导致整个服务退出缺少进程级兜底添加 unhandledRejection/uncaughtException 处理远程调用超时反向代理超时时间太短增加 proxy_read_timeout关闭 proxy_buffering6.2 一个真实的排查案例工具偶发超时这里分享一个我印象很深的排查过程。当时我用 MCP 服务器包装了一个外部搜索 API调用方反馈说工具偶尔超时但本地测试又总是好的。我在服务器里加了日志看到超时的请求都卡在等待外部 API 响应的环节并没有立即 reject。进一步排查发现外部 API 的响应偶尔会延迟到 20 秒以上而 AI 客户端的工具超时设置只有 10 秒。解决办法有两步第一在 MCP Server 内部给外部调用加一个 8 秒的超时控制用 AbortSignal.timeout超时后立刻返回一个可重试的结构化错误第二在工具说明文档里标注该工具最大执行时间约 8 秒如果超时请重试。这样模型就会知道超时是预期内的情况而不是服务器故障。从那以后调用方的 Agent 会自动重试成功率大幅提升。这个案例也说明了为什么retriable字段和清晰的错误信息那么重要。没有这些客户端只会看到一个笼统的 timeout它无从判断应该重试还是放弃。6.3 部署环境切换我踩过的坑一次从本地部署到 Docker 时我遇到过编译出来的代码没有读取环境变量的问题。原因很蠢我在src/index.ts里用process.env.MCP_SERVER_PORT || 3001本地 shell 里设置了环境变量跑得好好的Docker 里忘了在 compose 文件加环境变量服务就默认跑了 3001而我的反向代理转发到 3001 也正常看起来没问题。直到某天我想换端口才发现在容器里环境变量根本没传进去。这种问题最坑的是完全不影响测试只在特定配置变更时才会暴露。后来我养成了一个习惯每次部署后先打印一下关键配置变量是否存在但不要打印真实 secret 值再用上面的 curl 冒烟测试。没有这一步很多环境差异问题会藏得很深。实操总结最后分享一点我的个人体会做了这么多 MCP 自定义服务器之后我最深的感觉是这个领域的难点不在协议本身而在于你能不能把一个玩具服务器变成一个可靠服务。协议文档两三天就能读完但错误处理该定义哪些字段、进度通知该在哪个粒度推送、TypeScript 类型怎么和 Schema 联动、Docker 部署时怎么优雅停机这些都需要在一轮轮被真实客户端毒打之后才能搞清楚。我个人建议所有刚开始做 MCP Server 的同学先别急着堆工具数量先挑一个真实场景的耗时工具把它彻底打磨好加上结构化错误、实现进度通知、用 TypeScript 把类型收窄、打好 Docker 镜像部署上去。跑通这一条完整链路之后再扩展其他工具你会顺手特别多。这套方法论对后面接任何新工具都适用。