MCP服务器开发实践:错误处理、流式输出与TypeScript工程化
MCPModel Context Protocol这个概念今年算是彻底出圈了从最初的 Claude 生态工具到现在 Cursor、Windsurf 这类编辑器原生支持甚至很多团队已经开始用 MCP 把内部系统接入 AI 工作流。说句实话跑通一个 MCP server 的 hello world 并不难官方 SDK 里抄个示例改改就能用。但真正到了要写“能上生产”的自定义工具事情就变得复杂了工具报错该怎么返回给大模型、长任务怎么让用户看到进度、TypeScript 类型怎么组织、最后怎么部署才能稳定运行。这篇文章不聊概念直接讲我最近开发 MCP 自定义服务器时的完整经验重点围绕四个关卡错误处理、流式输出、TypeScript 工程化、以及部署上线。如果你已经在用 MCP SDK但经常被各种边界情况搞得头疼这篇文章应该能帮你省下不少踩坑的时间。1. TypeScript 选型为什么生产级 MCP 服务我首选它1.1 不只是“会 TypeScript”而是协议层就在用它的类型系统MCP 的交互模型本质上是 JSON-RPC 2.0客户端发请求服务器响应。但这里有个非常核心的细节——MCP 的工具调用是带 Schema 的而 Schema 必须描述清楚输入参数的结构。你用 Python 写也不是不行但我在实际开发中的体感是TypeScript 几乎是跟 MCP 协议配合最紧密的语言没有之一。原因有三。第一官方 SDK 的modelcontextprotocol/sdk一套吃遍协议发展从最开始的 stdio 传输到后来的 Streamable HTTP 传输TypeScript 永远是最先覆盖的。这意味着你在 TS 生态里拿到的是第一手能力而不是等社区搬运。第二TypeScript 的结构化类型系统可以直接变成工具的 inputSchema。我在定义工具入参时只需要写好 interface 或者 type 别名再借助 zod 这类库做运行时校验就能保证发给大模型的参数描述和真实代码逻辑完全一致。类型就是你给大模型的“说明书”类型写歪了模型调工具的时候就会不停报参数错误。第三MCP 服务器经常要嵌入到前端或者 Electron 应用中比如我接过一个需求把内部运营后台变成一个 AI 助手入口前端页面要直接消费 MCP 返回的数据。这种情况下前后端共用一套类型定义省掉的不只是写接口文档的时间更重要的是消除了前后端数据结构不一致的隐患。1.2 工程结构一个能长期维护的 MCP 项目要这样搭明确了用 TypeScript接下来就是工程化落地。我不推荐把所有工具都塞在一个庞大的 index.ts 里那跟把接口都写在入口文件里没有区别代码会很快腐烂。一个我实测比较顺手的目录结构是这样mcp-server/ ├── src/ │ ├── index.ts # 服务入口负责启动 server 实例 │ ├── tools/ │ │ ├── index.ts # 工具注册中心 │ │ ├── queryOrder.ts # 单个工具实现 │ │ └── sendMessage.ts │ ├── errors.ts # 自定义错误类型 │ ├── logger.ts # 日志封装 │ └── types.ts # 跨模块共享的 TS 类型 ├── package.json ├── tsconfig.json └── Dockerfile工具注册中心的核心逻辑是做一个“收集器”把每个独立文件的 tool 定义和 handler 统一导出来再在 index.ts 里批量注册。这样每次新增工具只需要在 tools/ 目录下新建一个文件不用反复改动入口。tsconfig.json 里我建议开启 strict 模式target 至少是 ES2022module 改成 NodeNext。其实这些配置都不算特殊真正容易踩坑的是开发时的运行方式。MCP SDK 的很多依赖都是 ESM-only 的如果你的 package.json 没有加type: module运行的时候会疯狂报 module 语法错误。我的做法是{ name: my-mcp-server, type: module, scripts: { dev: tsx watch src/index.ts, build: tsc, start: node dist/index.js } }开发时用 tsx 直接跑 TS 源码改完自动重启。生产构建时用 tsc 编译到 dist/再用 node 启动。注意 build 之前要把dist/和node_modules加进.gitignore并且生产环境尽量用npm ci而不是npm install保证依赖版本和 lockfile 一致。2. 错误处理别把异常直接抛给大模型2.1 MCP 里有两套错误通道很多人只用了其中一套这是我在审查别人 MCP 代码时发现的高频问题很多开发者把工具里的所有问题都直接 throw 出去让 SDK 框架层捕获后返回一个 JSON-RPC 错误。这么做的问题在于大模型拿到的是一个冰冷的“Internal error”字符串它根本不知道该不该重试、是不是参数写错了、甚至不知道该向你提示错误信息。MCP 协议其实给了两条错误通道必须分清场景。第一层是JSON-RPC 协议错误适合表达框架级别的问题比如参数格式非法、方法不存在、内部异常。这一类错误不该指望大模型处理而是应该让客户端比如 Claude Desktop 或者 Cursor直接弹框提示。第二层更关键是工具结果里的isError标记。MCP 的工具返回结构允许你正常返回一个 result但这个 result 可以显式标记isError: true。这就像是你在业务接口里返回了{ code: 40001, message: 订单不存在 }而不是把 HTTP 状态码直接改成 500。对大模型来说看到了具体错误信息它就有能力调整输入参数重新调用或者把这个错误转述给用户。在实际开发中我的处理原则是参数校验失败、业务规则不允许比如订单已取消不能再次退款 → 返回isError: true的结果并附上人能看懂的错误描述数据库连接失败、上游 API 超时这类基础设施问题 → 可以先重试重试仍然失败就抛出框架层错误配置缺失、环境变量未设置 → 在服务启动阶段直接 fail fast根本不该拖到运行时2.2 自定义错误分层与统一捕获只是区分通道还不够你需要一套可管理的错误类型体系。我一般会定义这样一个基础错误类export class ToolError extends Error { constructor( message: string, public readonly code: string, public readonly retryable false ) { super(message); this.name ToolError; } }然后在实现具体工具时遇到业务问题就throw new ToolError(订单状态不允许操作, ORDER_STATUS_FORBIDDEN)而不是随便throw new Error(...)。这里有一个必须绕开的坑如果业务错误被你 throw 出去又想在返回结果里带上isError: true就得在统一拦截层做转换。否则框架捕获到异常后会变成一个 JSON-RPC 错误大模型完全看不懂。所以我建议在注册工具时做一层包装把 handler 包在一个统一执行函数里import { Server } from modelcontextprotocol/sdk/server/index.js; function wrapTool(fn: (args: any, extra: any) Promiseany) { return async (args: any, extra: any) { try { return await fn(args, extra); } catch (err) { if (err instanceof ToolError) { return { content: [ { type: text, text: [${err.code}] ${err.message} } ], isError: true }; } logger.error(Unhandled tool error, err); throw err; } }; }这样设计的好处是业务错误有统一的返回格式而代码里依然可以放心使用 try/catch 和异常控制流。不要试图把所有错误处理都塞进每个工具函数里那只会让每个文件都变得臃肿。2.3 错误日志和敏感信息保护也许是做后端留下的习惯我一直强调 MCP 服务器上线后最重要的可观测手段就是日志。但这里有一个特殊的“坑”需要格外注意MCP 服务器返回给大模型的错误信息会被模型用于自我修正所以不能包含敏感细节但日志里的错误信息又要足够详细。我的解决方案是给用户/模型的错误信息是简短的安全描述而把完整堆栈、请求参数、上游响应原文全部写到日志里。// 日志保留完整信息 logger.error(Query order failed, { orderId, stack: err.stack, cause: err.cause, }); // 返回给模型的是安全信息 return { content: [{ type: text, text: 订单查询失败请重试或检查订单号 }], isError: true, };这类错误信息里尤其要注意不要顺手把数据库连接串、API Key、内部服务地址写进去。我第一次上线时就因为顺手把 Redis 连接错误信息原样返回导致日志里暴露了整个连接串还好是内网环境不然就是一次安全事故了。所以在错误类设计上就应该把“堆栈详情”和“对外消息”分开存不允许 err.message 直接被返回。3. 流式输出让长任务“有回音”3.1 先厘清MCP 语境里的“流式”到底指什么很多人在网上搜“MCP 流式输出”以为是在工具返回的时候像 ChatGPT 那样一个字一个字往外蹦。这个理解其实有偏差。MCP 工具调用的典型交互是大模型决定调用工具 → 服务器执行 → 服务器一次性返回完整结果 → 大模型再把结果组织成自然语言回复。在这个链路里工具结果本身目前是“一次性返回”的。那为什么还需要流式因为工具执行过程可能有很长的时间窗口尤其是批处理任务、大文件汇总、多步骤编排这类场景。如果用户界面在几秒钟内毫无反馈体验会非常差而且客户端甚至可能误判为请求挂死触发超时。MCP 协议为此设计了progress notification进度通知机制服务器在执行过程中可以不断向客户端推送进度事件告诉它“我正在干活完成了 30%”。这其实就是 MCP 场景下的“流式”只不过流的是进度状态而不是最终内容。3.2 用进度通知实现阶段反馈在 TypeScript SDK 里工具注册的 handler 会收到第二个参数 extra里面就有progressToken。你可以通过这个 token 向客户端发送进度通知。我最近写的一个批量翻译工具就是典型案例。用户提交一批文档工具需要逐篇调用翻译 API每篇可能耗时 10 秒整个任务几分钟。如果不加进度推送客户端界面就是白屏干等。代码思路是这样的server.registerTool( batch_translate, { title: 批量翻译文档, inputSchema: { type: object, properties: { docIds: { type: array, items: { type: string } } }, required: [docIds] } }, async ({ docIds }, extra) { const token extra.progressToken; const total docIds.length; for (let i 0; i total; i) { const result await translateOne(docIds[i]); // 每翻译完一篇推送一次进度 await extra.sendProgress?.({ progress: i 1, total, message: 已完成 ${i 1}/${total} 篇 }); if (result.failed) { results.push({ docId: docIds[i], error: result.message }); } } return { content: [ { type: text, text: JSON.stringify(results) } ] }; } );需要注意不同版本的 SDK 在进度 API 的命名上可能有细微差异有的版本是extra.sendProgress有的版本是通过server.sendNotification发送。我建议在动手之前先看一眼当前 SDK 的RequestHandlerExtra类型定义几分钟的事能避免之后反复踩文档过时的坑。推送频率也要控制好。如果任务有 10000 个小项逐项推送会把通知通道刷爆。合理做法是每处理 5% 或者每 200 毫秒最多推送一次做一次节流。我当时就吃过这个亏给一个千级任务循环里每个子任务都发通知直接把客户端信息流打满了最后只能限频重发。3.3 支持取消流式任务的另一半责任当任务有了进度反馈用户自然会想在界面点“取消”。MCP 协议里也有 cancellation 机制客户端发起取消后SDK 会把取消信号以AbortSignal的形式传给 handler。我有一次开发批处理工具时没处理取消用户体验非常割裂前端点了取消进度条还在走任务还在后端跑。那是纯粹的资源浪费尤其当工具内部还占着数据库连接和第三方 API 配额。实现取消的正确姿势是在任务的每个分片处检查中断信号async ({ docIds }, extra) { const { signal } extra; for (let i 0; i docIds.length; i) { if (signal?.aborted) { return { content: [{ type: text, text: 任务已被用户取消 }], isError: true, }; } // 执行某个分片任务 await processOne(docIds[i]); } }检查点要放在“耗时操作之前或之后”不能放在两个耗时代码之间有空隙的地方。信号只是被动了但如果你的操作是阻塞式的比如一个超长的数据库查询那就得主动检查不要把整个执行流程都交给信号“自己去中断”。实话实说MCP 的取消机制目前还比较基础跟 HTTP 请求的取消体验不完全一样。但无论如何在长任务工具里做“每步可取消”是一个非常重要的健壮性设计你永远不会希望工具在后台白白消耗算力。4. 部署从本地跑通到线上可用4.1 选择传输模式stdio 还是 HTTPMCP 服务器到底怎么部署第一件事是看你的使用场景。如果这个 MCP 工具只服务于你自己的开发机或者只在公司内网的某几台机器上被 Claude Desktop、Cursor 调用那 stdio 模式是最省心的。它不需要监听任何端口不需要网络配置宿主应用直接作为父进程把 MCP 服务器跑起来数据走标准输入输出。部署的“上线动作”其实就是把那台机器上的代码拉下来、npm ci npm run build然后配置好宿主应用的启动命令。但如果你要给一个团队甚至多个团队共享使用或者前端的 Web 应用要跨设备调用那就要用 HTTP 传输模式。你的 MCP 服务器变成了一个真正意义上的后端服务监听端口、处理请求、接受反向代理。SDK 里提供了一个开箱即用的 express 集成import { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; const server new McpServer({ name: my-tools, version: 1.0.0 }); const transport new StreamableHTTPServerTransport({ endpoint: /mcp, enableJsonResponse: process.env.MCP_JSON_RESPONSE true, }); const app express(); app.use(express.json()); app.post(/mcp, async (req, res) { await server.connect(transport); // 处理请求... });这种模式的优点是标准 HTTP谁都能调缺点是你得自己保障进程稳定性、并发连接管理、以及健康检查。我的实际建议是团队共享工具或 Web 端集成直接上 HTTP自己开发机个人工具用 stdio 就够了。不要一上来就用 HTTP 模式给自己找麻烦这里多出来的运维成本是实打实的。4.2 容器化让部署行为确定给 MCP 服务器做容器化是有价值的哪怕只是个人使用。原因很简单MCP 服务器的直接依赖除了 Node 还有各种系统库如果在一台机器上手工npm ci装到一半环境崩了或者 Node 版本不对排查成本很高。而容器化之后部署行为就是确定的。我常写的一个 Dockerfile 是这样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 WORKDIR /app ENV NODE_ENVproduction COPY package.json package-lock.json ./ RUN npm ci --omitdev COPY --frombuild /app/dist ./dist USER node EXPOSE 3001 CMD [node, dist/index.js]这里有两个细节值得注意多阶段构建把编译工具链甩在 build 阶段运行时镜像只保留依赖和产物镜像尺寸能小不少我测试过这样最终镜像大约 120MB比一把梭的 300 多MB 小了很多。最后用USER node切换非 root 用户运行。很多安全扫描工具会直接对运行在 root 下的容器亮红灯而且 MCP 服务器如果被传入恶意内容非 root 身份可以削弱影响半径。如果你需要用 docker-compose 管理我提供一个最小配置services: mcp-server: build: . ports: - 3001:3001 environment: - OPENAI_API_KEY${OPENAI_API_KEY} - MCP_LOG_LEVELinfo healthcheck: test: [CMD, node, -e, fetch(http://localhost:3001/mcp/health).then(rprocess.exit(r.ok?0:1))] interval: 30s timeout: 5s retries: 3 restart: unless-stopped4.3 认证、日志与健康检查线上部署的三件套HTTP 模式的 MCP 服务器一旦暴露到网络上第一件事就是不能被裸奔访问。MCP 协议本身目前没有内建的认证方案所以常见的做法是在反向代理层做简单鉴权比如 Nginx 里校验自定义请求头location /mcp { if ($http_x_mcp_token ! your-secret-token-value) { return 401; } proxy_pass http://127.0.0.1:3001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这种方式能挡掉大部分滥用。如果安全要求更高可以对接成熟的 OAuth/OIDC 体系但说实话对多数内部工具来说共享密钥已经够用了没必要一上来就上重型方案。日志方面容器化部署的原则是应用只往 stdout 写日志不要自己管理日志文件。这样 Docker 原生日志驱动、云平台的日志采集都能直接接上。我通常在 logger 里区分 info / warn / error 三个级别并把每一条日志都打上时间戳和请求 ID这样排查跨链路问题时会轻松很多。健康检查不能省。哪怕是一个只有两个工具的简单服务器我也坚持加一个/health端点返回数据库连接池状态、内存占用、上游 API 连通性。原因很朴素MCP 是给 AI 用的工具不可用的时候大模型会不断重试最终反馈给用户的就是“这个 AI 不好用”。只有健康检查能帮你尽早发现问题而不是等用户来抱怨。5. 高频问题排查实录这一节总结几个我实际开发中反复遇到的坑当作速查表分享出来。问题现象可能原因排查思路工具调用总是超时服务器执行耗时过长没有做进度反馈或任务分片检查上游 API 响应时间把长任务拆成多次进度通知模型一直报参数错误inputSchema 写得过于宽泛或者类型和实参不一致用 zod 做运行时校验并测试工具入参的边界值MCP 服务器连不上传输模式选错了或者端口没监听先本地 curl 测试/mcp端点再检查反向代理部署后工具找不到构建产物未更新或者启动命令指向错误文件检查 dist/ 生成时间用npm run build后重启工具返回的信息里包含敏感内容错误处理时把堆栈/密钥原样返回统一用错误类隔离内部信息和外部消息取消不生效handler 没有检查 AbortSignal在任务每个分片之间加入 abort 判断进度通知不显示progressToken 没有正确传递或 SDK 版本 API 不一致查看当前 SDK 的 RequestHandlerExtra 类型定义还有一个容易被忽略的点MCP 协议版本在快速迭代不同宿主应用Claude Desktop、Cursor、自研客户端对协议版本的支持程度并不一致。如果你的服务器只兼容最新协议旧宿主可能连握手都过不去。最稳妥的做法是参照 SDK 默认的 protocolVersion不要手动硬编码版本号。我看到过有人为了尝鲜硬写了一个高版本结果主流客户端一夜之间全部连不上排查了半天才发现是版本不匹配。另外如果你的服务器在 Docker 环境里跑 stdio 模式会有个很搞笑但又常见的坑有些镜像的默认 shell 是 sh而不是 bashSDK 拉起来之后跟你说 “spawn ENOENT”因为 host 应用的配置里写死了bash -c ...。这种问题其实 5 秒就能定位但往往会被忽略。写在最后做 MCP 自定义服务器这段时间我最大的感受是它的本质其实还是后端服务只是交付对象从“人”变成了“大模型”。这也意味着我们过去积累的很多工程经验——错误分类、可观测性、进度反馈、安全边界——在这里不仅适用而且变得更加重要。因为模型本身很擅长“将错就错”如果你不给它明确的错误信号它可能会一本正经地把一个失败的工具调用包装成成功的结果。所以错误处理要格外细致流式反馈要格外主动部署方案要格外稳健。如果你也正在开发 MCP 工具建议先不要急着堆功能把错误分层、进度通知和 Docker 化这三件事做好再去扩展工具数量。地基打牢了后面加东西才能快。