最近大半年,各路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 一个子进程跑 server;HTTP 方式则适合远程部署、多个 Host 共享同一个 server。
Host 拿到用户的一句话之后,会通过大模型判断要不要调用哪个工具;如果要调用,Host 会发一个 JSON-RPC 请求给 MCP Server,比如tools/call,Server 执行完业务逻辑后把结果返回。这个请求-响应模型是整个协议的核心,也是后面讨论错误处理和流式输出的基础。
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 ToolHandler<T> = (args: T) => Promise<string>; function safeTool<T>(handler: ToolHandler<T>) { 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,但有些场景会触发“后台任务”:比如工具先快速返回“任务已启动”,然后异步去处理某个耗时的数据同步。这时候如果异步任务抛异常,而你又没在任何地方catch,Node.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 的查询结果塞进content,Host 端也得等全部数据拼完才拿得到。
那“流式输出”到底流什么?这里有两个不同的层面:
- 传输层的流式(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 Set<express.Response>(); app.get("/events", (req, res) => { res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-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] Description=Order MCP Server After=network.target [Service] ExecStart=/usr/bin/node /opt/order-mcp/dist/index.js WorkingDirectory=/opt/order-mcp Restart=always RestartSec=3 User=www-data Group=www-data EnvironmentFile=/etc/order-mcp.env [Install] WantedBy=multi-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_ENV=production COPY --from=builder /app/dist ./dist COPY --from=builder /app/package*.json ./ RUN npm ci --omit=dev && 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 \ --restart=always \ -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 的自定义服务器开发其实不算难,难就难在要把协议规范、业务逻辑、部署运维三者串起来。把错误处理做成体系、把流式输出做对做稳、把部署流程记录成脚本,稳定性和可维护性自然就上来了。这些经验没有捷径,都是在一次次的“客户端报错、翻日志、查协议”中攒下来的。