MCP自定义服务器进阶:从能跑到能扛的工程实践 1. MCP 自定义服务器的能跑与能扛进阶开发要跨过的四道坎说实话MCP 自定义服务器开发圈里很多人觉得会注册 tool 就是会了。其实真正决定一个 MCP 服务器能不能上生产环境的是错误处理、流式输出、TypeScript 工程化和部署这几个进阶话题——这些恰恰是官方示例里讲得最少的部分。写这篇指南的起因是我自己最近把好几个 MCP Server 从本地 demo改造成团队内可复用服务时反复撞上同一批问题工具抛异常时客户端只回一句笼统的 There was an error你根本分不清是参数没传对、上游 API 挂了还是代码自己炸了一个耗时任务跑十几秒客户端一直转圈用户看不到进度AI 也不知道该不该中途放弃重试如果走 stdio 传输某个console.log忘了清理调试日志直接混进通信管道MCP 握手都能失败等你终于把服务部署到远程服务器又发现鉴权、日志、健康检查全都没着落。这些现象背后不是某一处代码写错了而是对 MCP 协议的理解停留在能调通的层面。从一个能跑的 demo 到一个能扛真实流量的服务器需要把协议错误分层、进度通知、stdio 边界、远程鉴权这四件事系统过一遍。这篇文章就把我在实际项目中踩过的坑、验证过的做法完整梳理出来给正在进阶的同路人做个参考。2. TypeScript 工程化地基版本、Schema 与隐藏陷阱2.1 官方 SDK 版本与传输模式怎么选MCP 的 TypeScript SDK 目前是绝大多数人写服务器的首选官方维护和协议迭代基本同步。但正因为迭代快版本选择的坑比想象中多。我自己吃过一次亏项目里用了某个较新的 minor 版本结果协议里一个字段的行为变了导致客户端全部缓存了旧的工具列表改了服务端代码也不生效。排查了半天才发现是 SDK 版本语义变化。所以第一条建议是锁大版本小版本升级要单独做回归测试。别图新功能随手npm updateMCP 协议还在演进期SDK 偶尔会有破坏性变更尤其是和传输层、错误码枚举相关的改动影响面可能很大。第二个选择是传输模式。MCP 服务器有两条主流路径传输模式典型客户端适用场景注意事项stdioClaude Desktop、Cursor、Codex 等桌面工具本机进程随客户端启停标准输出不能被日志污染Streamable HTTP远程 AI 平台、团队共享服务多端访问、服务端常驻需要鉴权、会话管理、健康检查安装依赖时建议这样写npm install modelcontextprotocol/sdk zodSDK 内部其实也依赖 zod但你在自己的代码里会频繁用到 zod 类型和方法显式声明依赖更稳妥避免将来 SDK 升级后拿到一个隐式不存在的版本。2.2 用 Zod 定义工具 Schema一个类型源头同时拿到校验和推导MCP 工具暴露给 AI 的参数描述本质上是 JSON Schema但 SDK 官方推荐直接用 Zod 对象来定义由 SDK 负责把 Zod 转换成 JSON Schema。这样做最大的收益是你在一个地方维护 schema另一处自动获得完整的 TypeScript 类型推导不需要维护两份类型定义。来看一段标准写法的示例import { z } from zod; import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; const QueryUserInput z.object({ userId: z.string().describe(用户ID形如 user_123), includeOrders: z.boolean().optional().describe(是否同时返回订单列表默认 false), }); type QueryUserInput z.infertypeof QueryUserInput; const server new McpServer({ name: user-service, version: 1.0.0 }); server.registerTool( query_user, { title: 查询用户信息, description: 根据用户ID查询用户的基础信息可选用 includeOrders 参数决定是否附带订单数据。, inputSchema: QueryUserInput, }, async (params: QueryUserInput) { // 到这里 params 的类型已经被推导出来了 const { userId, includeOrders } params; // ...业务逻辑 return { content: [{ type: text, text: JSON.stringify({ userId, includeOrders }) }] }; } );如果你之前习惯手写 interface 再手动校验参数换成这套组合拳之后类型定义、运行时校验、JSON Schema 三件事被压缩成一次声明少了一大批维护成本。2.3 工具 description 的设计AI 能不能用好关键在描述里很多人写工具时只关注参数 schema不重视工具自身的description。但在 MCP 的调用链路里AI 模型是先读 tools 列表里的描述和参数说明再决定要不要调用、传什么参数。如果描述写得含糊模型就会频繁传错参数或者干脆把能做的任务判断成没有可用工具。我总结了一个非常实用的原则把人的直觉翻译成模型的约束条件。比如参数date只写string模型不知道该传YYYY-MM-DD还是时间戳失败率很高你在.describe()里补一句格式为 YYYY-MM-DD例如 2025-06-01问题基本消失。工具需要调用第三方服务比如对接一个需要 token 的外部产品类似 figma mcp 那类场景在描述里注明该工具需要 X_TOKEN_HEADER 环境变量已配置若未配置请返回错误码 TOKEN_MISSING模型就能在出错时给出更合理的提示。我还见过一种做法在 description 里写明本工具可能耗时较久建议调用时携带 progress token。这其实是给模型一个纪律性引导让它知道什么时候该启用进度通知。关于进度通知的细节后面第 4 章会展开。2.4 stdio 模式下最大的坑标准输出污染stdio 传输的机制是客户端通过子进程的标准输入写请求服务器在标准输出上写响应。也就是说你在服务器代码里console.log的任何内容都会被当作协议数据的一部分进行解析轻则让工具返回结果错乱重则让整个 MCP 握手失败。这个坑几乎每个写过 stdio MCP 服务器的人都踩过。很多开发者以为开发阶段打几个调试日志没影响等真出了问题才发现客户端那端根本收不到合法的 JSON-RPC 响应只会卡在connecting或者不断重试。我的处理方案很固定分两层第一层开发阶段就用文件日志替代控制台输出import fs from node:fs; function log(...args: unknown[]) { fs.appendFileSync(/tmp/mcp-server.log, ${new Date().toISOString()} ${args.map(String).join( )}\n); }第二层上线前的全局兜底。把console.log和console.error都重定向掉避免任何第三方库偷偷打日志console.log (...args: unknown[]) { fs.appendFileSync(/tmp/mcp-server-console.log, ${args.map(String).join( )}\n); }; console.error console.log;需要特别提醒console.error也要处理。虽然大多数客户端会把 stderr 当作日志展示而非协议数据但有些严格的客户端会因为在 stderr 上看到大量输出而判断进程异常。标准做法是正常运行日志进文件只有致命错误才往 stderr 写一行。这样既保住了可观测性也保住了通信通道。3. 错误处理让 AI 客户端不再拿一段笼统报错去猜3.1 MCP 错误处理的两层模型协议层与工具层MCP 基于 JSON-RPC 2.0所以错误处理天然分成两层很多人在开发中把这两层混在一起导致客户端拿到错误后完全不知道该往哪个方向排查。第一层是协议层错误发生在请求本身无法被正常处理的时候比如 JSON 格式错误、方法不存在、参数校验失败、服务器内部异常。这类错误通过 JSON-RPC 的error字段返回对应不同的错误码。第二层是工具层错误发生在工具已经被成功调用、但执行业务逻辑时遇到了业务失败比如查无此人、余额不足、上游 API 超时。这类问题不属于请求格式错误正确的做法是在返回结果中显式标记isError: true并返回一段面向 AI 可读的结构化错误内容。先明确这个分层后面所有策略都建立在它之上。3.2 协议层错误码不是所有异常都该用 -32603JSON-RPC 2.0 定义了一批标准错误码SDK 里也提供了对应的常量或错误类错误码含义MCP 开发中的典型场景-32700解析错误请求体不是合法的 JSON-32600无效请求请求 JSON 合法但不符合 JSON-RPC 结构-32601方法不存在调用了未注册的工具或方法-32602参数无效zod 校验失败、必填参数缺失、类型不对-32603内部错误处理器内部抛出了未捕获的异常我见过很多人的 MCP 服务器把所有异常都一口吞成一个 -32603然后 message 里写一行 internal error。这对 AI 和运维都不友好。更合理的做法是区分情况参数校验失败返回 -32602 或让 z-tier 的校验器自动映射到 -32602未知方法返回 -32601超时返回自定义错误码并在 data 里写明retryable: true。message 字段要能帮助模型理解而不是只写 Error。建议在data里带上错误分类、错误码、是否可重试等信息。例如{ code: -32001, message: UPSTREAM_TIMEOUT, data: { category: timeout, retryable: true, detail: POST https://api.example.com/xxx timed out after 10s } }这样的结构AI 拿到后能区分参数不对和上游超时从而采取不同策略改参数重试、提示用户、稍后重试。3.3 工具层失败isError 标记与业务错误的结构化返回当工具的业务逻辑运行失败时不能简单 throw 一个 Error 让它冒泡到协议层。正确的做法是返回一个合法的 tool result同时设置isError: true。这样客户端会把这个调用视为失败而不会把结果字符串当作正常工具输出去做下一步推导。我在项目里长期使用两个辅助函数保证所有工具返回的结构统一function ok(data: unknown) { return { content: [{ type: text as const, text: JSON.stringify(data) }], }; } function fail(code: string, message: string, detail?: unknown) { return { isError: true, content: [ { type: text as const, text: JSON.stringify({ code, message, detail }), }, ], }; }有了这个统一结构AI 看到错误后能明确知道这是业务失败而且错误信息是结构化的。比如fail(USER_NOT_FOUND, 用户不存在, { userId })模型就知道该提示用户检查用户ID而不是自己瞎编一个重试策略。一个重要的细节是工具返回时如果只写一行 error: xxx 而没加isError: true很多客户端会把它当成正常的工具输出AI 甚至可能把错误信息当成有效数据展示给用户这是我在实际使用中踩过的真实坑。3.4 超时、取消与并发冲突的处理真实使用场景里客户端可能随时超时用户也可能中途取消对话。但 MCP 协议层面并没有强制要求支持取消所以这部分要靠服务器自己兜底。我的做法一般分三步工具内部所有 IO 调用都挂上 AbortController 超时避免请求无限挂起。比如访问外部 API 时设置 10 秒超时超时后返回协议层自定义错误。const controller new AbortController(); const timer setTimeout(() controller.abort(), 10_000); try { const resp await fetch(url, { signal: controller.signal }); // ... } finally { clearTimeout(timer); }工具执行前检查当前是否有相同幂等键的请求正在执行如果有直接返回一个已有相同请求处理中的错误。这样能避免 AI 在不确定是否成功时连续提交多个重复写操作。对于写操作设计幂等键参数。AI 在调用失败后很可能重试如果工具不支持幂等重试可能造成重复下单、重复写文件等问题。并发冲突也一样多个客户端同时调用同一个 MCP 服务器后端和普通 API 服务一样要处理锁、事务、连接池。MCP 并不会帮你解决这些问题它只负责把请求送到你的处理器。3.5 日志策略与一次线上故障排查实录给 MCP 服务器做日志我有三个固定通道用户可见的消息返回即 content 里的文本这是给 AI 和最终用户看的。服务端内部日志写文件或标准日志收集系统。协议调试日志完整记录每个进来的 JSON-RPC 请求和出去的响应。为什么要单独强调协议调试日志因为 MCP 服务器是给 AI 用的排查问题时你必须能回看AI 到底发了什么、你的服务回去了什么。很多时候模型侧的幻觉其实是人眼看不到的原始请求导致的。我之前接手过一个 MCP 服务器AI 调用某个工具时总是返回 -32602 参数无效。查协议日志发现 zod 校验失败错误信息里带了一段abc_123 is not a valid ID format。到这里最容易想到的修复方案是改宽松校验但根因其实是模型根本不知道 ID 的正确格式——工具 description 里没写清楚。最后我在参数描述里加上ID 形如 abc_123下划线前必须为三个字母再观察一段时间调用成功率明显上升。这类问题的排查如果缺少协议日志你根本不知道模型传进来的是什么只能靠猜。4. 流式输出把长时间运行变成边跑边报进度4.1 MCP 的进度通知机制是什么、解决什么问题从普通后端思维切过来的人很容易默认 MCP 工具调用只有一次 request、一次 response。但真实业务里很多工具的执行时间远超几秒要拉取多个外部 API 再汇总、要在本地跑一次比较重的推理比如本地部署的大模型服务、要批量处理几十个文件。这种场景下客户端干等十几秒用户完全没有反馈体验非常糟糕。MCP 协议因此设计了 Progress Notification 机制。客户端可以在调用工具时通过_meta.progressToken告诉服务器请把执行进度通知给我服务器在异步执行的过程中主动调用sendProgressNotification向客户端推送进度。它的核心价值不只是让用户看到转圈百分比而是让 AI 拥有判断依据——比如在进度长期停滞时模型可以选择主动放弃并换一条路径重试。这对 Agent 型客户端像 Codex、Cursor 这类特别重要。4.2 实现 Progress Notification 的关键代码与为什么实现进度通知有两个关键点必须同时满足。第一工具的 schema 和描述里最好声明这个工具是长耗时任务建议模型携带进度 token。我通常直接在工具 description 里写本工具预计耗时 10-30 秒建议调用时在 _meta 中携带 progressToken。第二处理器内读取_meta.progressToken在异步任务中循环发送进度通知。实现示例server.setRequestHandler(CallToolRequestSchema, async (request) { const progressToken request.params._meta?.progressToken; const total steps.length; for (let i 0; i total; i) { await runStep(steps[i]); if (progressToken) { await server.sendProgressNotification({ progressToken, progress: i 1, total, message: 第 ${i 1}/${total} 步完成, }); } } return ok(finalResult); });为什么progressToken一定要原样传回因为它是客户端用来关联到具体请求的不透明标识。如果你在服务器里随便换一个字符串或者加前缀客户端会找不到对应上下文进度就被丢弃了而且没有任何报错。其次sendProgressNotification必须是非强制的。有些客户端不支持进度通知或者虽然传了_meta但中途不响应。所以发送进度通知时不要依赖它的返回值发送失败也不能中断主逻辑。我用 try-catch 包住并打一条内部日志即可。4.3 流式文本输出与进度通知不是一回事很多人容易把进度通知和流式输出混为一谈这里必须掰开讲清楚。进度通知只携带进度信息比如已完成 3/5不携带最终语义输出的一部分。它适合告知用户还在跑没卡死。但如果你拿到一个 MCP 服务器想实现类似 ChatGPT 打字机那样的流式文本输出——每次增量返回一部分文本内容最终拼起来是一段完整回答——那么单靠 Progress Notification 是不够的。因为协议里没有定义把本次工具输出的半成品分块发给客户端的标准消息。真要做这种语义层面的流式输出通常绕不开协议之外的通道一种方式是使用 MCP 的 sampling 能力让服务器请求模型继续生成另一种方式是自己设计基于 SSE 长连接的事件流接口让客户端通过另一个通道订阅增量。但这类能力在当前主流客户端上兼容性参差不齐需要你自己评估。我的判断标准很简单场景推荐做法理由耗时数秒到数十秒、用户需要还活着的反馈Progress Notification协议原生支持兼容性最好要做类似流式聊天打字机效果额外自建 SSE 或 WebSocket 通道协议进度通知语义不满足1 秒内能出结果的同步工具普通返回即可加流式反而徒增复杂度4.4 客户端兼容性与降级策略进度通知在实现层面很轻但客户端的表现差异很大。我实测过几个主流客户端Claude Desktop 对大部分 progress 消息基本无视但不报错。Cursor 和 Codex 这类 IDE/Agent 工具对 progress 有一定的呈现逻辑能提升任务执行透明度但不同版本展示样式不同。较老的客户端 SDK 可能对不认识的 notification 直接忽略少部分会报错。所以进度通知的代码必须遵循最佳努力原则。我的降级策略是在工具 description 里写明这是长耗时任务建议携带 progressToken但绝不强制依赖它。核心业务逻辑不因为sendProgressNotification失败而中断。这样老客户端不会报错新客户端能获得更好的交互体验。5. 部署stdio、HTTP、容器三种形态的实战选择5.1 先想清楚你的 MCP 服务器给谁用部署的第一步不是选技术而是想清楚消费方是谁。选错了形态后面全白搭。消费方推荐部署形态原因自己本机的 Claude / Cursor / Codexstdio进程由客户端拉起零网络配置安全边界清楚团队内部多个成员共享内网 HTTP 或 Docker 部署成员只需要配一个 URL不用各自维护本地环境和密钥对外开放给远程 AI 平台或外部应用Streamable HTTP 鉴权 高可用需要处理跨网络访问、鉴权、限流、日志审计很多人一上来就想着做成远程服务但实际使用中如果只是自己电脑上的 AI 编辑器要调用一个本地文件查询工具stdio 的体验远远优于 HTTP不需要开端口、不需要处理网络超时、不会暴露敏感数据。5.2 stdio 部署到桌面客户端Claude / Cursor / Codex 配置要点stdio 部署的本质是告诉客户端执行这条命令并通过 stdin/stdout 与我通信。以 Claude Desktop 为例配置文件通常长这样{ mcpServers: { my-server: { command: node, args: [/path/to/dist/index.js], env: { MY_API_KEY: your-key } } } }Cursor 和 Codex 的配置语法基本一致都是定义一个命令和参数列表。我在这个阶段踩过几个明显但容易反复出现的坑路径必须写绝对路径。写相对路径时客户端的工作目录不一定是你预想的位置经常表现为连接成功但工具列表为空。环境变量尽量通过env字段注入而不要在代码里硬编码。这样同一个二进制文件可以在不同客户端场景里复用。如果工具依赖本机网络服务比如虚拟机里跑的数据库、本地 Ollama先确保这些服务已经启动并且客户端进程有权限访问。很多人部署后一直超时结果是本地服务没起。5.3 HTTP 远程部署Streamable HTTP 与鉴权如果想要远程访问 MCP 服务器就需要走 Streamable HTTP 传输。新版 SDK 的思路是用StreamableHTTPServerTransport这个类来承接 HTTP 请求你需要自己包一层 Node http 或 Express。一个最小化的 Node http 服务骨架大致长这样import http from node:http; import { Server } from modelcontextprotocol/sdk/server/index.js; import { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; const server new Server({ name: remote-mcp, version: 1.0.0 }, { capabilities: { tools: {} } }); // 注册工具... const httpServer http.createServer(async (req, res) { // 健康检查 if (req.method GET req.url /healthz) { res.writeHead(200); res.end(ok); return; } if (req.method POST) { const transport new StreamableHTTPServerTransport({ sessionIdGenerator: () crypto.randomUUID(), }); res.on(close, () transport.close()); await server.connect(transport); await transport.handleRequest(req, res); } else { res.writeHead(405); res.end(); } }); httpServer.listen(3000);这里有几个关键点GET 请求在 Streamable HTTP 中一般用于获取服务器信息和建立 SSE 流DELETE 用于关闭会话。如果业务不需要长连接推送至少要做好健康检查 GET。每次 POST 都可能创建一个新的 transport 实例连接后不要忘记在响应关闭时清理。sessionIdGenerator不是可选项自己在每次建立传输时生成 UUID否则部分客户端无法维持会话。远程部署最关键的还是鉴权。开发阶段用 API Key 放在 header 里最简单生产环境建议走 OAuth 2.0 或至少 Bearer Token。对于 Codex、Cursor 等客户端多数支持自定义 Header 或 Bearer Token你需要在配置里指定。这里有一条铁律不要把一个没有鉴权的 MCP 服务器直接暴露公网。MCP 工具本质上是要读写你的业务数据和执行操作的公开出去等于把内部能力敞开给任何人调用。曾经有团队图方便把一个能查订单数据库的 MCP 服务直接绑在公网端口上后来被扫描工具发现订单数据差点泄露。5.4 用 Docker 和 systemd 把服务养起来容器化部署非常适合 HTTP 模式的 MCP 服务器。一个最小 Dockerfile 的结构参考FROM node:20-slim AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-slim AS runtime WORKDIR /app COPY --frombuild /app/dist ./dist COPY --frombuild /app/node_modules ./node_modules ENV NODE_ENVproduction USER node EXPOSE 3000 CMD [node, dist/index.js]使用非 root 用户运行是安全底线MCP 服务器可能会被 AI 用来执行各种工具进程权限越小越好。如果部署在单台内网 Linux 机器上systemd 比裸跑 node 进程更稳妥[Unit] DescriptionMCP Server Afternetwork.target [Service] ExecStart/usr/bin/node /opt/mcp-server/dist/index.js Restartalways RestartSec3 EnvironmentNODE_ENVproduction EnvironmentAPI_TOKENxxx [Install] WantedBymulti-user.targetRestartalways和RestartSec3保证进程异常退出后可以快速拉起。MCP 服务器如果跑在容器编排平台里就交给平台的健康检查机制来判断存活。5.5 部署后的可观测性请求日志、健康检查、版本管理MCP 服务器比传统 API 服务更需要可观测性因为它的调用方是 AI你排查问题时必须能回答模型当时发出了什么样的请求、拿到了什么响应。部署后我建议至少具备以下能力请求日志记录每个 JSON-RPC 方法、工具名、耗时、是否错误。格式尽量结构化方便接入日志平台。健康检查HTTP 模式一定要有/healthz或类似端点让负载均衡和容器编排系统能判断存活。版本管理MCP 客户端和 AI 都会缓存工具列表。服务端更新后客户端不一定立即感知。我习惯在工具列表里带一个_server_version的 resource或者让某些工具在描述里注明版本便于排查为什么模型还在用旧工具。一个实用的技巧可以注册一个叫get_server_info的内部工具返回服务器版本、已注册工具数量、运行时长、最近错误数。AI 在排查问题时甚至能自动调用这个工具来诊断自己的失败这是一个很多团队都忽略的强力玩法。6. 从进阶到实战几个值得借鉴的模式和我最后的建议6.1 三个让我少踩坑的设计模式第一个是 Schema 先行。不要先写业务函数再补参数类型而是先定义 zod schema再写实现。这样从源头保证了类型一致也强迫你把参数边界想清楚。第二个是错误分类标准化。所有的工具返回要么是ok()要么是fail()绝不允许某个工具自己拼一段自由格式的错误字符串。统一结构后AI 处理错误的成功率明显提升因为它在所有工具上看到的错误格式是一致的。第三个是幂等设计。只要工具涉及写操作就给参数里加一个requestId之类的幂等键。AI 调用失败的场景非常常见而且它倾向于重试如果没有幂等保护一次操作可能被重复执行多次。这里多花十分钟生产环境少熬两个夜。6.2 给刚开始做 MCP 服务器开发的同路人三条建议第一先做 stdio 本地调试通透了再上 HTTP 和容器化。stdio 模式最容易定位协议层问题因为你不需要考虑网络、端口、鉴权这些额外的复杂度。第二日志优先于功能。哪怕只写一个看起来很小的工具也要把协议日志先做好。原因前面提到过MCP 服务器的调用方是 AI没有请求日志等于盲人摸象。第三SDK 升级要克制。MCP 生态还处于快速演进期功能上跟最新协议当然有吸引力但升级前先看 changelog对每个 minor 版本变更做回归测试。生产环境里稳比新更重要。6.3 后续还可以这样扩展MCP 服务器的进阶空间并不止于这篇指南。我个人觉得有几个方向值得继续投入把服务器做成可插拔框架用配置文件声明挂载哪些工具这样团队里不同项目可以复用同一套基础能力给工具引入自愈逻辑让 AI 在碰到特定错误码后自动调整参数重试而不是把错误原样丢给用户再就是结合本地部署的大模型把 MCP 服务器和本地推理服务串起来打造一条完全可控、不依赖外部服务的 AI 工具调用链路。最后分享一个我自己反复验证过的体会MCP 服务器本质上不是写完就完事的普通接口它的调用方是一个会思考、会重试、也会误判的 AI。所以你在设计错误信息、参数描述、返回结构时心里都要有一句话——不是我这个人看得懂就好而是要让模型看得懂、能行动。这点想透了你这个 MCP 服务器就真的进阶了。