尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

TypeScript自定义MCP服务器实战:错误处理、流式输出与部署 最近大半年各路MCP服务器如雨后春笋一样冒出来Figma 有官方 MCP蓝湖有 MCP数据库也有现成的 MCP。但真到自己写一个定制化很强的 MCP 服务器时我发现坑比想象中多错误处理不规范会导致客户端上莫名其妙出现Internal error长任务不搞流式会让用户等到怀疑人生TypeScript 项目明明本地能跑部署到服务器上却各种炸。这篇文章就把我踩过的这些坑集中讲透。这次我以 TypeScript 为语言从项目骨架、错误处理、流式输出、部署四个角度完整带大家走一遍自定义 MCP 服务器的开发流程。适合已经跑通过官方 quickstart、但对生产级细节还比较模糊的开发者。我会用一个很常见的业务场景贯穿全文一个订单查询工具支持按订单号查询也支持按时间范围批量拉订单其中批量接口耗时可能很长。1. 先搞懂 MCP 服务器在链路里的位置再动手写代码1.1 主机、服务器、传输层到底谁管什么很多人第一次接触 MCP 时容易把“MCP 服务器”理解成一个 HTTP 接口服务但其实它的定位更像大模型应用的一个“外设”。MCP 的三层结构可以类比成 USB 协议MCP Host是电脑也就是真正运行大模型、发起对话的客户端。Claude Desktop、Cursor、Codex、自研的 Web 聊天应用都属于 Host。MCP Server是外设负责把真实世界的能力接进来比如查数据库、读文件、调内部 API。传输层就是 USB 线目前最常用的两种stdio和Streamable HTTP。stdio适合本地进程通信Host 直接 fork 一个子进程跑 serverHTTP 方式则适合远程部署、多个 Host 共享同一个 server。Host 拿到用户的一句话之后会通过大模型判断要不要调用哪个工具如果要调用Host 会发一个 JSON-RPC 请求给 MCP Server比如tools/callServer 执行完业务逻辑后把结果返回。这个请求-响应模型是整个协议的核心也是后面讨论错误处理和流式输出的基础。1.2 为什么自定义服务器值得自己写官方和社区已经有很多现成 MCP 服务器覆盖了 GitHub、Figma、数据库这些主流场景。但你总会碰到几类问题内部的订单系统、CRM、权限体系根本没有现成 MCP。现成 MCP 大多是通用实现没法贴合自己的鉴权、限流和审计要求。希望把本地大模型、私有知识库和工具调用结合起来时只有自研才能确保数据不出内网。我自己当时的需求就是要把一个只读查询的订单库暴露给 AI 助手让它可以按订单号查详情、按日期范围统计订单金额。这个需求不需要全表写入权限也不需要复杂的管理后台但必须稳定、可观测、不能把 SQL 错误原样抛给用户。这种情况最适合自己写一个轻量的 MCP Server。1.3 选 TypeScript 而不是 Python 的理由其实 MCP 官方 SDK 的 Python 版本非常成熟很多 AI 开发者也更熟悉 Python。我最终选 TypeScript核心原因是团队技术栈偏前端而且这个 MCP Server 后面要集成进一个已有的 Node.js Web 服务里共用一套类型定义、日志组件和部署链路能少维护一套技术栈。另外 JSON-RPC 本身就是一个 JSON 格式的协议TypeScript 对 JSON 的类型推断、zod参数校验、以及stream模块对流式处理的原生支持都非常顺手。前端团队接手这个项目时基本不用重新学习。当然如果项目是 CPU 密集型比如要处理大量图片或视频编码那 TypeScript 不是最优解Python 或 Rust 更合适。但对于绝大多数“接数据、转格式、调接口”的工具型服务器TypeScript 完全够用。2. 搭一个能跑的 TypeScript MCP 服务器骨架2.1 依赖选型和版本坑官方 SDK 目前是modelcontextprotocol/sdk搭配zod做参数校验很顺手。开发环境我会用tsx直接跑 TypeScript避免每次改代码都要编译一遍。{ name: order-mcp-server, version: 1.0.0, type: module, private: true, engines: { node: 20 }, dependencies: { modelcontextprotocol/sdk: ^1.0.0, zod: ^3.23.0 }, devDependencies: { types/node: ^20.0.0, tsx: ^4.0.0, typescript: ^5.5.0 } }这里有三个容易踩的坑Node.js 版本不要太老最好 20 以上。SDK 某些版本对 Node 18 的兼容性有问题低版本 Node 会出现globalThis.crypto未定义之类的报错。package.json里我用了type: module也就是 ESM 写法。如果你的项目是 CommonJS导入语句就得改成require并且tsconfig的module设置不一样。zod和modelcontextprotocol/sdk的版本要尽量保持较新这两个库都在快速迭代老版本之间的 API 差异很大网上很多教程代码在新版 SDK 上直接跑不起来。2.2 tsconfig 配置的推荐写法我的tsconfig.json长期用这套配置兼容性最好类型检查也是严格模式{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, declaration: true, sourceMap: true }, include: [src/**/*] }NodeNext模块解析是配合 ESM 的关键如果这里配成CommonJS代码里使用import时会报错。skipLibCheck建议开启SDK 依赖的一些声明文件偶尔和严格模式冲突开启后能省很多麻烦。2.3 最小可运行的服务器代码下面是整个服务器最核心的骨架它定义了两个工具一个是按订单号查询订单详情一个是按时间范围批量拉订单列表。第二个工具故意设计成可能耗时长后面讲流式输出时还要用它。import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { z } from zod; const server new McpServer({ name: order-mcp-server, version: 1.0.0, }); server.registerTool( get_order, { title: 查询订单详情, description: 根据订单号查询订单的详细信息, inputSchema: { orderId: z.string().describe(订单号例如 ORD20250101001), }, }, async ({ orderId }) { // 这里可以接数据库查询 const order await queryOrderById(orderId); return { content: [ { type: text, text: JSON.stringify(order, null, 2), }, ], }; } ); server.registerTool( list_orders, { title: 批量查询订单, description: 按时间范围查询订单列表可能耗时较长, inputSchema: { startDate: z.string().describe(开始日期YYYY-MM-DD), endDate: z.string().describe(结束日期YYYY-MM-DD), limit: z.number().optional().describe(最大返回数量默认50), }, }, async ({ startDate, endDate, limit 50 }) { const orders await queryOrdersByRange(startDate, endDate, limit); return { content: [ { type: text, text: JSON.stringify(orders, null, 2), }, ], }; } ); async function main() { const transport new StdioServerTransport(); await server.connect(transport); console.error(order-mcp-server running on stdio); } main().catch((error) { console.error(Fatal error in main():, error); process.exit(1); });注意几个细节console.error在 stdio 模式下是唯一的日志出口千万别用console.log打印日志否则会污染 JSON-RPC 通信数据。这个坑我在第一次调试时踩得很惨客户端一直收不到消息后来才发现是日志混进了标准输出。2.4 本地启动与验证本地开发时用tsx直接启动npx tsx src/index.ts但更推荐用官方提供的 MCP Inspector 来做交互式验证它会启动一个 Web 面板可以模拟 Host 调用工具、查看返回结果和通知比手动拼 JSON-RPC 舒服太多npx modelcontextprotocol/inspector npx tsx src/index.ts打开浏览器后左侧能看到服务器注册的所有工具填参数并调用右侧能看到完整的请求响应记录。这是排查工具注册是否成功、参数校验是否符合预期的最快方式。3. 错误处理从“进程崩溃”到“协议级可诊断”3.1 先认清 MCP 这边的 JSON-RPC 错误码MCP 底层走的是 JSON-RPC 2.0所以错误处理首先要理解这一套错误码。协议标准里定义了这些错误码含义典型触发场景-32700解析错误请求体不是合法 JSON-32600无效请求请求结构不符合 JSON-RPC-32601方法不存在调用了未注册的工具-32602无效参数参数缺失、类型错误-32603内部错误业务逻辑异常、数据库不可用SDK 里已经内置了ErrorCode枚举和McpError类开发者不需要自己去拼错误对象直接抛出对应错误即可。3.2 标准姿势捕获一切异常再重新抛成 McpError工具回调里最容易犯的错误是“数据库抛什么就往上抛什么”。如果直接把底层 SQL 异常抛出去Host 端只会看到一个Internal error用户完全不知道发生了什么更危险的是某些数据库驱动会把 SQL 语句、连接串片段带在错误信息里直接返回给外部客户端存在信息泄露风险。我现在的做法是在每个工具外层套一层统一包装函数import { McpError, ErrorCode, } from modelcontextprotocol/sdk/types.js; import { ZodError } from zod; type ToolHandlerT (args: T) Promisestring; function safeToolT(handler: ToolHandlerT) { return async (args: T) { try { const result await handler(args); return { content: [{ type: text as const, text: result }], }; } catch (error) { if (error instanceof McpError) { throw error; } if (error instanceof ZodError) { throw new McpError( ErrorCode.InvalidParams, 参数校验失败: ${error.issues .map((issue) ${issue.path.join(.)}: ${issue.message}) .join(; )} ); } // 业务异常记录详细日志但返回给客户端的信息要克制 console.error([tool-error], error); throw new McpError( ErrorCode.InternalError, 工具执行失败请查看服务端日志 ); } }; }然后注册工具时这样套一层server.registerTool( get_order, { /* ... */ }, safeTool(async ({ orderId }) { const order await queryOrderById(orderId); if (!order) { throw new McpError( ErrorCode.InvalidParams, 订单 ${orderId} 不存在 ); } return JSON.stringify(order, null, 2); }) );这样做有几个好处参数错误明确。zod解析失败被转成InvalidParams客户端能直接看到是哪个字段不合法。业务错误有反馈。比如“订单不存在”这属于业务逻辑层面的错误不是系统内部错误返回InvalidParams是合理的。内部错误不泄露细节。真正的堆栈写在服务器日志里客户端只看到一句泛化提示。3.3 后台异步任务的异常捕获不能省MCP 工具回调虽然写成async但有些场景会触发“后台任务”比如工具先快速返回“任务已启动”然后异步去处理某个耗时的数据同步。这时候如果异步任务抛异常而你又没在任何地方catchNode.js 进程可能直接退出。我的建议是只要启动了一个不 await 的异步任务就必须在内部 catch 所有异常或者至少用Promise.catch兜底。同时建立全局兜底process.on(unhandledRejection, (reason) { console.error([unhandledRejection], reason); }); process.on(uncaughtException, (error) { console.error([uncaughtException], error); });但这只是兜底不是主防线。正确思路是让所有工具行为都收敛到工具回调本身不要让游离的异步任务破坏服务器稳定性。很多 MCP Host 对连续失败的容忍度很低一次崩溃就可能让客户端认为整个服务器不可用。3.4 传输断开时的处理stdio 模式下如果 Host 进程退出MCP Server 会收到stdin结束事件。如果服务器里还挂着数据库连接或者其他长连接理论上应该主动释放资源。可以在连接断开时做清理server.onclose async () { await database.close(); console.error(server closed, resources released); };这块容易忽略但一旦部署到长时间运行的生产环境资源泄漏会被慢慢放大。至少要做到数据库连接池有关闭入口。4. 流式输出长任务不是只能干等4.1 一个反直觉的事实工具调用本身不是流式的在 AI 聊天里我们习惯了大模型逐 token 输出但 MCP 的工具调用结果在协议层面是一个完整的 JSON-RPC 响应。你把一个 10MB 的查询结果塞进contentHost 端也得等全部数据拼完才拿得到。那“流式输出”到底流什么这里有两个不同的层面传输层的流式Streamable HTTP/SSE服务器可以向客户端主动推送多个消息除了最终结果还有进度通知、日志信息。工具内部分批返回如果某个工具本身是后台长任务正确做法是先周期性上报进度最后一次性返回最终结果如果结果集特别大则考虑分页或分批接口而不是在一个text里塞巨型 JSON。理解了这一点下面的方案才说得通。4.2 用进度通知让用户看到“它还在干活”MCP 规范里定义了notifications/progress通知。SDK 封装了createProgressToken来生成进度令牌。实现思路是在tools/call执行过程中服务器主动发进度通知Host 端会把这些进度展示在界面上。server.registerTool( list_orders, { /* ... */ }, async ({ startDate, endDate, limit 50 }, extra) { const progressToken extra?.progressToken; const totalSteps 10; const orders []; for (let step 1; step totalSteps; step) { // 模拟分批查询数据库 const batch await queryOrdersBatch(startDate, endDate, step); orders.push(...batch); if (progressToken ! undefined) { await server.notification({ method: notifications/progress, params: { progressToken, progress: step, total: totalSteps, message: 已查询 ${step * 10}% 的数据, }, }); } } return { content: [ { type: text, text: JSON.stringify(orders, null, 2), }, ], }; } );注意上面代码里server.registerTool的回调第二个参数是extra里面访问了progressToken。不同版本 SDK 的extra里字段名可能略有差异但大体一致。前端体验从“长时间无响应”变成“进度条一直在走”用户就不会怀疑程序卡死了。这是长任务工具最重要的优化。4.3 自建 SSE 端点的流式实现如果你的 MCP Server 不只是被 Host 调用还会被自己的 Web 前端直接调用比如要在管理后台里实时展示日志那就需要自己开一个 SSE 端点。用 Express 或 Fastify 搭一个小服务暴露一个/events接口前端用fetch或EventSource订阅import express from express; const app express(); const clients new Setexpress.Response(); app.get(/events, (req, res) { res.writeHead(200, { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, Connection: keep-alive, }); clients.add(res); req.on(close, () clients.delete(res)); }); export function broadcastToClients(event: string, data: unknown) { for (const client of clients) { client.write(event: ${event}\n); client.write(data: ${JSON.stringify(data)}\n\n); } }然后在长任务里调用broadcastToClients(order-progress, { ... })前端就能实时看到输出。这个模式特别适合把 MCP Server 内部的处理过程可视化相当于给服务器加了一个“实时监控面板”。不过在开发之前要想清楚如果只是给 MCP Host 用协议原生的进度通知就足够了不需要多做一套 SSE只有在 Web 前端直连场景下自建 SSE 才划算。4.4 部署流式接口时的反代配置SSE 最大的敌人是反向代理的缓冲。Nginx 默认会缓冲响应导致前端等很久才收到第一帧数据。所以凡是给 SSE 服务的反向代理都必须关闭缓冲并调大超时时间server { listen 80; server_name mcp.example.com; location /events { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; proxy_read_timeout 1h; proxy_send_timeout 1h; } }少了proxy_buffering off这一行前面的所有流式逻辑都会像“堵在水管里的水”一样憋在 Nginx 里。我第一次部署时一直怀疑是 Node 代码的问题花了大半天时间才定位到反代配置。5. 部署从“能跑”到“稳定跑”5.1 构建打包与命令行入口部署的第一步是把 TypeScript 编译成 JavaScript。如果按前面的tsconfig配置直接运行tsc产物会输出到dist/目录。接下来要确认package.json里有正确的bin字段这样外部工具可以通过命令直接启动{ bin: { order-mcp: ./dist/index.js } }npm 安装后MCP Host 配置里就可以写npx order-mcp。比如在 Cursor 或 Claude Desktop 的 MCP 配置文件中{ mcpServers: { order: { command: npx, args: [order-mcp], env: { DATABASE_URL: mysql://localhost:3306/orders } } } }这里有个小坑MCP Host 通过npx启动依赖时如果找不到order-mcp会自动尝试从 npm 拉包。如果你是本地开发、包还没发布建议用绝对路径启动node /path/to/dist/index.js否则会进入充满迷惑性的 npx 下载流程。5.2 用 systemd 管理常驻进程开发本机上直接在终端运行node dist/index.js就够了。但部署到 Linux 服务器上我更推荐用 systemd 把它管理起来好处是开机自启、崩溃自动重启、日志统一收集。写一个 unit 文件/etc/systemd/system/order-mcp.service[Unit] DescriptionOrder MCP Server Afternetwork.target [Service] ExecStart/usr/bin/node /opt/order-mcp/dist/index.js WorkingDirectory/opt/order-mcp Restartalways RestartSec3 Userwww-data Groupwww-data EnvironmentFile/etc/order-mcp.env [Install] WantedBymulti-user.target注意这里用EnvironmentFile指定环境变量文件把数据库密码等敏感信息放到/etc/order-mcp.env并且把文件权限设为 600避免其他用户读到。这是部署环节最容易忽略的安全问题。启动命令sudo systemctl daemon-reload sudo systemctl enable order-mcp sudo systemctl start order-mcp journalctl -u order-mcp -fjournalctl是查看日志的好帮手配合类型化的日志输出排查问题的效率会高很多。5.3 Docker 部署的推荐方式如果服务器环境比较统一我更建议用 Docker。多阶段构建能让最终镜像只保留运行时代码体积小、安全性高。# 构建阶段 FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY tsconfig.json ./ COPY src ./src RUN npm run build # 运行阶段 FROM node:20-alpine WORKDIR /app ENV NODE_ENVproduction COPY --frombuilder /app/dist ./dist COPY --frombuilder /app/package*.json ./ RUN npm ci --omitdev npm cache clean --force EXPOSE 3000 CMD [node, dist/index.js]构建镜像docker build -t order-mcp:1.0.0 . docker run -d --name order-mcp \ --restartalways \ -p 3000:3000 \ --env-file /etc/order-mcp.env \ order-mcp:1.0.0同样使用--env-file注入环境变量。如果 MCP Server 是stdio模式其实不需要-p暴露端口只有跑 HTTP 服务时才需要。这一点要分清。5.4 密钥边界与安全基线自定义 MCP 服务器一旦接入企业数据安全问题就要当作一等公民对待。我给自己定的底线是数据库账号最小权限。MCP 服务器用的是只读账号绝不使用有写权限的 root 账号。敏感信息只走环境变量。代码里不出现任何明文密码、Token。输入参数做二次校验。虽然zod会校验类型但 SQL 注入、路径穿越这类风险还是要靠参数化查询和禁止特殊字符来兜底。返回内容限制大小。给大查询设置结果集上限比如limit最大不超过 1000防止一次调用把内存打爆。这些不是“锦上添花”而是 MCP 服务器能不能长期稳定跑下去的关键。一个小型查询工具可能感受不到但一旦接入生产数据库、被多个 AI 客户端频繁调用任何一处的疏忽都可能变成事故。6. 调试 MCP 服务器时最值得养成的几个习惯6.1 用 MCP Inspector 做逐条验证不要靠猜。MCP Inspector 是官方推荐的调试器它可以连接本地stdio服务器可视化展示所有注册的工具、资源和提示词还能手动构造请求。我在开发每个新工具时都会先在 Inspector 里用各种边界参数调用一遍确认无误后再接入真实客户端。我常测的边界参数包括空字符串、缺失必填字段、超长字符串、负数、错误日期格式。每次报错都看错误码是否符合预期是InvalidParams还是InternalError。6.2 把日志输出成结构化 JSON由于stdio模式下stdout被协议占用日志只能走stderr。为了后面好分析我习惯用 JSON 格式输出function log(level: string, event: string, data?: unknown) { console.error( JSON.stringify({ time: new Date().toISOString(), level, event, ...data, }) ); }这样无论是本地开发还是用系统日志收集工具都能把 MCP 服务器的运行状态接入现有监控体系。日志里我会记录工具名、参数摘要、执行耗时、错误码但不会记录数据库完整查询语句和敏感字段。6.3 从小到大、从协议到业务地推进复杂度最后分享一个我自己的推进策略写任何新 MCP 工具先做“hello world 级别的最小实现”跑通协议链路再加上参数校验然后再接入真实数据源最后才考虑流式输出和进度通知。每一步都有明确的验证节点出问题时能很快定位是协议、业务还是数据库的问题。MCP 的自定义服务器开发其实不算难难就难在要把协议规范、业务逻辑、部署运维三者串起来。把错误处理做成体系、把流式输出做对做稳、把部署流程记录成脚本稳定性和可维护性自然就上来了。这些经验没有捷径都是在一次次的“客户端报错、翻日志、查协议”中攒下来的。
返回列表