最近这段时间,总有人拿着能跑通的 MCP server 来问我:“工具注册没问题,日志也正常,怎么一接到真正的 Agent 里就各种奇怪问题——要么超时,要么报错信息又长又乱,要么数据半天出不来?”说实话,一个 MCP server 能跑,和它能稳定地跑在生产业务里,中间隔着的不是“多写几个工具”,而是错误处理、流式输出、TypeScript 工程化和部署这套进阶组合拳。
这篇指南是我在做自定义 MCP 服务器过程中积累的实操笔记,适合已经用@modelcontextprotocol/sdk写过入门 demo、准备把项目推到真实环境里的开发者。我会把最容易被忽略又最容易出问题的四个环节拆开讲,每个环节都给出可复现的代码、配置和避坑经验。不管你是给内部 Agent 提供工具调用,还是做面向外部客户的服务端,这套方法论都通用。
1. MCP 自定义服务器到底在解决什么问题
1.1 先花三十秒重新理解 MCP 的价值
MCP(Model Context Protocol)本质上是一个“上下文接入协议”,它解决的是大模型应用和外部数据、工具、服务之间的集成问题。你可以把 MCP 理解成给 AI 应用装了一个标准化的 USB-C 接口,只要你的服务实现了这个协议,任何遵循协议的客户端都能直接插上使用。
自定义服务器之所以重要,是因为通用 MCP 服务器永远覆盖不了业务里的长尾场景。你公司内部的订单查询、权限校验、私有数据库访问、特定格式的文件处理,这些都需要自己实现。我们把 MCP 自定义服务器做到进阶水平,本质上是把自己的业务能力封装成 AI 模型能理解、能调用、能纠错的标准化服务。
1.2 MCP 服务器的三类核心能力:Tools、Resources、Prompts
很多初学者只盯着 Tool 注册,忽略了协议另外两个重要能力。我这里用表格帮你把三者看清楚:
| 能力 | 作用 | 典型场景 | 调用方式 |
|---|---|---|---|
| Tools | 让模型执行一个动作并获取结果 | 查天气、下单、读数据库 | 模型主动调用 |
| Resources | 给模型提供直接读取的上下文数据 | 项目文档、配置文件、日志片段 | 模型按 URI 读取 |
| Prompts | 预置可复用的提示词模板 | 代码审查、SQL 生成、周报生成 | 用户主动选择或模型触发 |
在自定义服务器开发中,绝大多数需求都集中在 Tools 上,这没错。但 Resources 经常被人忽略,它非常适合处理“每次对话都要带上、但体积又太大”的基础文档。把这类内容放到 Resources 里,比塞进系统提示词高效得多。
1.3 进阶路线的四个关键环节
从 demo 到生产,我认为必须补齐的四个环节,恰好就是标题里那四个词:
- 错误处理:让系统失败时可诊断,而不是抛一堆晦涩堆栈。
- 流式输出:让长任务响应可感知,而不是让用户对着空白等待。
- TypeScript 工程化:让代码可维护、可测试、可协作。
- 部署:让服务能稳定运行在目标环境里,并且可观测。
这四件事不是相互独立的,错误处理影响流式任务的可靠性,TS 工程化影响错误处理的实现质量,部署方式又反过来决定了你能用哪种错误采集手段。所以我把它们放在一条线里串起来讲。
2. 错误处理:让你的 Server 在失败时可诊断
2.1 先吃透 MCP 的 JSON-RPC 错误模型
MCP 底层走的是 JSON-RPC 2.0,这意味着绝大多数的调用错误最终都以 JSON-RPC error 对象的形式返回。JSON-RPC 定义了几个标准错误码:
-32700:解析错误,连请求 JSON 都解析不了。-32600:无效请求,请求结构不符合规范。-32601:方法不存在,客户端调了一个没有注册的方法。-32602:参数无效,参数缺失或类型不对。-32603:内部错误,服务执行过程中发生未预期异常。
在@modelcontextprotocol/sdk里,这些错误码被封装成了ErrorCode枚举,同时提供了McpError这个标准错误类。我做错误处理的第一条原则就是:所有从工具处理函数里抛出来的异常,最终都必须是一个McpError,或者能被 SDK 正确映射成McpError。
2.2 在 TypeScript SDK 中统一捕获与包装异常
很多人的工具处理函数长这样:
server.registerTool( "query_order", { description: "根据订单号查询订单状态", inputSchema: { type: "object", properties: { orderId: { type: "string" } }, required: ["orderId"] } }, async (args) => { const order = await db.query(`SELECT * FROM orders WHERE id = ?`, [args.orderId]); return { content: [{ type: "text", text: JSON.stringify(order) }] }; } );这段代码“能用”,但问题很大:数据库连接串写错、SQL 语法错误、查询超时,任何异常都会直接往上抛。SDK 虽然会接住并返回一个-32603,但错误信息可能是英文堆栈,用户和模型都看不懂,更不知道怎么修复。
我的做法是给每个工具处理函数套一层统一错误包装逻辑:
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; function toMcpError(error: unknown): McpError { if (error instanceof McpError) { return error; } if (isKnownBusinessError(error)) { // 业务校验失败,返回自定义错误码 return new McpError( -32001, error.message || "业务校验未通过", { code: error.code, retriable: false } ); } if (isUpstreamTimeout(error)) { return new McpError( -32002, "上游服务响应超时,请稍后重试", { retriable: true } ); } // 未知异常 console.error("[UnhandledError]", error); return new McpError( ErrorCode.InternalError, "服务内部异常,请稍后重试", { detail: extractSafeMessage(error) } ); }然后在注册工具时包一层:
function withErrorBoundary( handler: (args: any, extra: any) => Promise<{ content: any[] }> ) { return async (args: any, extra: any) => { try { return await handler(args, extra); } catch (error) { throw toMcpError(error); } }; } server.registerTool( "query_order", { description: "...", inputSchema: { ... } }, withErrorBoundary(async (args) => { // 这里可以放心写业务逻辑 const order = await db.query(...); return { content: [{ type: "text", text: JSON.stringify(order) }] }; }) );这层包装解决了三个问题:第一,不会把原始堆栈直接暴露给客户端;第二,业务错误和系统异常被区分开;第三,给错误补充了retriable标记,告诉调用方这次失败重试到底有没有意义。
2.3 错误信息要让人能懂、让 AI 也能懂
MCP server 的调用方通常有两种:一种是人类用户直接调试,另一种是大模型 Agent 在循环里自动调用。错误信息的表达必须同时照顾这两类消费者。
我给错误对象设计了一个固定结构:
{ "code": -32001, "message": "订单号格式不正确", "data": { "businessCode": "ORDER_ID_INVALID", "retriable": false, "hint": "订单号由字母和数字组成,例如 ORD-20250101-001" } }对模型来说,message和hint字段直接决定它下一步会不会修正输入参数。我见过太多错误信息只写Invalid input,模型根本不知道是哪个字段不对,只能反复试错。你要把“这个工具期望什么格式”“失败原因是什么”“有没有办法修正”都表达清楚。
重要原则:不要把完整堆栈塞进 error message。JSON-RPC 错误对象会被客户端长时间保留,太长的堆栈既不好展示,也容易泄露内部路径信息。堆栈只写进服务器日志,返回给客户端的永远是提炼过的安全信息。
2.4 错误处理中的三个高频坑
第一个坑是同步异常和异步异常混合。registerTool 的处理函数必须始终返回 Promise,如果你在里面混用了同步throw和异步reject,统一捕获的时机完全不同。用async/await之后,所有异常都走 Promise 通道,这一点能大幅减少心智负担。
第二个坑是资源没有释放。工具处理过程中如果打开了数据库连接、文件句柄或外部网络连接,错误发生时很容易 leak。我建议在try/finally或Promise.finally中处理资源释放,而不是只依赖 catch。
第三个坑是错误码滥用。有人把所有错误都映射成ErrorCode.InternalError,结果生产环境里所有失败都返回-32603,根本没法区分。自定义错误码一定要提前规划好,比如统一约定-32001到-32099是业务错误区间,-32100到-32199是上游依赖错误区间,这样监控报警才能精确起来。
3. 流式输出:长任务的正确打开方式
3.1 先澄清一个容易误解的点:MCP 工具调用默认不是流式的
很多刚接触 MCP 的人以为工具调用可以像大模型对话一样逐字输出。实际上,MCP 的tools/call走的是标准 JSON-RPC 请求-响应模型,服务端执行完工具之后返回一个完整的结果对象,中间不存在“持续的数据流”。
但这不代表 MCP 没办法做流式体验。实际生产中有两个常用方案:
- 方案一:使用进度通知(progress notification),让客户端感知长任务的执行进度。
- 方案二:让工具返回一个可流式读取的资源地址或 SSE 端点,由客户端另行消费事件流。
这两个方案覆盖了绝大多数“长任务”场景。下面分别讲清楚。
3.2 用进度通知表达“任务正在推进”
SDK 在处理工具请求时,extra 参数里带有signal和sendNotification。如果客户端在发起请求时传入了progressToken,服务端就可以通过notifications/progress不断推送进度。
server.registerTool( "batch_process_files", { description: "批量处理指定目录下的所有文件", inputSchema: { type: "object", properties: { directory: { type: "string" }, keyword: { type: "string" } }, required: ["directory"] } }, async (args, extra) => { const files = await scanDirectory(args.directory); const total = files.length; const results = []; for (let i = 0; i < total; i++) { // 每处理一个文件推送一次进度 await extra.sendNotification({ method: "notifications/progress", params: { progressToken: extra.progressToken, progress: i + 1, total } }); results.push(await processOneFile(files[i])); } return { content: [{ type: "text", text: JSON.stringify(results) }] }; } );这段代码的关键在于progressToken。它不是服务端自己生成的,而是客户端在请求里带来的标识符。客户端靠它把多个任务的进度通知关联到正确的调用上。这个机制很适合文件处理、数据导入、批量审核这类时间不定但用户可以等待的操作。
3.3 真正的流式数据输出:用 SSE 事件流转发业务数据
有些场景光有进度还不够,用户希望看到数据“一个一个冒出来”,比如日志实时分析、监控指标拉取、管道处理进度。MCP 工具本身不适合直接承载高频事件流,更合理的架构是:工具返回一个 SSE 端点地址,客户端拿着地址去消费事件数据。
在我项目里的实践套路是这样的:
server.registerTool( "tail_logs", { description: "实时读取指定服务的最新日志", inputSchema: { type: "object", properties: { service: { type: "string" }, lines: { type: "number", default: 100 } }, required: ["service"] } }, async (args) => { // 为一个日志流创建临时访问令牌 const streamId = await createLogStream(args.service, args.lines); return { content: [ { type: "text", text: `日志流已就绪,请访问 /events/${streamId}` } ] }; } );服务端对应有一个普通的/events/:streamIdSSE 接口,把日志数据通过text/event-stream持续推送。这样做的好处是职责清晰:MCP 的tools/call只负责“创建任务”,具体的流数据走标准 HTTP SSE 通道,前端的EventSource、后端 Agent 的 SSE client 都能消费。
如果你的客户端是浏览器端,SSE 天生友好;如果你是 Agent 客户端之间互通,也可以用同样的方式。流式输出的核心价值是“边生成边可见”,它能把用户的等待时间从“不可感知”变成“可感知”,体验上一个台阶。
3.4 流式输出最容易踩的坑
流式方案在生产里最容易出问题的不是服务端代码,而是中间链路。我踩过三个典型坑:
第一个坑是代理缓冲导致“半天不吐数据”。Nginx、部分云负载均衡器默认会对响应做缓冲,SSE 数据要攒到一定量才一次性输出。解决办法是显式关闭缓冲,Nginx 里设置proxy_buffering off,或者在应用侧对 SSE 响应加X-Accel-Buffering: no头。
第二个坑是连接空闲被断开。SSE 连接空闲一定时间后,代理服务器或客户端可能会主动断开。解决办法是设置固定的心跳机制,每 15 到 30 秒发一个注释行或者ping事件,保持链路活跃。
第三个坑是背压问题。如果工具产生的数据速率远大于客户端消费速率,你还是要控制队列大小,别让内存越长越大。合理做法是给事件流设置最大缓冲行数,超过阈值就丢弃最旧数据,并且记录丢弃量,方便定位。
4. 用 TypeScript 把 MCP Server 做成可维护的工程
4.1 为什么坚持用 TypeScript 而不是 JavaScript
写 demo 用 JavaScript 当然省事,但我不建议在生产项目里直接裸写 JS。MCP SDK 自身就是用 TypeScript 写的,类型定义已经非常完整。你用 TS 开发,能得到工具注册参数、错误类型、请求响应结构的全程提示,很多低级错误在编译期就暴露了。
更重要的是,MCP server 往往要对接公司内部的数据库、消息队列、HTTP 服务,这些系统通常都有现成类型定义或 OpenAPI 文档。TS 可以把这些外部数据结构直接并入工具参数校验逻辑,减少运行时类型判断的负担。
4.2 工程化目录与类型设计
我维护的 MCP 项目目录结构大致是这样的:
mcp-server/ ├── src/ │ ├── index.ts # 服务入口,创建 Server 实例 │ ├── tools/ # 每个工具一个文件 │ │ ├── queryOrder.ts │ │ └── batchProcess.ts │ ├── errors/ # 统一错误码与错误包装 │ │ ├── codes.ts │ │ └── index.ts │ ├── transports/ # 传输层初始化 │ │ └── index.ts │ ├── services/ # 业务逻辑 │ └── types/ # 共享类型定义 ├── tests/ ├── Dockerfile ├── package.json └── tsconfig.json“每个工具一个文件”不是教条,而是我实际感受下来最省心的组织方式。工具数量一多,堆在同一个文件里会非常难维护。每个工具文件导出register函数,内部自己定义输入输出类型,然后在 index.ts 里统一注册:
import { registerQueryOrder } from "./tools/queryOrder.js"; import { registerBatchProcess } from "./tools/batchProcess.js"; export function registerAllTools(server: McpServer) { registerQueryOrder(server); registerBatchProcess(server); }注意导入路径里带了.js后缀,这是 NodeNext 模块解析下的硬性要求。很多刚切到 ESM 的人会被这个细节卡住,习惯性写.ts,编译能找到,运行时就报模块不存在。
4.3 tsconfig 推荐配置:严格模式开满
下面这份 tsconfig 是我当前项目的基准配置,基本上可以直接抄:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "declaration": true, "sourceMap": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "tests"] }我想重点解释一下noUncheckedIndexedAccess。这个选项会把你对数组或对象的索引访问变成可能为undefined的类型。初看会增加很多判空代码,但它能有效防止工具参数里出现undefined直接被传进 SQL 或 HTTP 请求的严重问题。
sourceMap也必须开,生产环境排查堆栈离不开它。配合source-map-support或 Node 的--enable-source-maps启动参数,你才能在日志里看到映射回 TS 源码的行号。
4.4 构建与调试流水线
开发阶段我用tsx watch做热重载:
npm run dev # 等价于 npx tsx watch src/index.tstsx可以直接运行 TS 文件,并且 watch 模式会在文件变化后自动重启进程,调试 MCP server 的效率非常高。部署前再用 tsc 正式编译:
npm run build # 等价于 tsc -p tsconfig.json构建产物在dist/目录下,入口是dist/index.js。发布时可以用npm pack或者直接打包容器镜像。这里的核心经验是:开发环境和生产环境用两套运行方式,不要在生产里跑tsx,那不仅性能差,还会引入额外的启动开销。
5. 部署:从本机到生产环境
5.1 三种连接方式怎么选
MCP 服务器和客户端之间的连接方式有差异,部署前必须选对。我用表格帮你做对比:
| 连接方式 | 工作方式 | 适用场景 | 复杂度 | 客户端支持 |
|---|---|---|---|---|
| stdio | 通过标准输入输出通信 | 本地开发、桌面客户端内置 | 低 | 最好 |
| SSE | 客户端连接 HTTP 端点,服务端通过 SSE 推送 | 远程服务、跨网络调用 | 中 | 较广 |
| Streamable HTTP | 现代 HTTP 双向通信,支持通知与流式响应 | 生产远程服务 | 中高 | 越来越广 |
早期 MCP 主要是 stdio 和 SSE 两种模式,现在官方推荐 Streamable HTTP。但现实中客户端支持度参差不齐,你在选型前最好先确认目标客户端支持哪种传输。如果完全自己控制两端,优先 Streamable HTTP。
5.2 本地开发环境:需要注册到客户端
本地部署最简单的方式是 stdio。你在项目根目录构建之后,把启动命令注册到支持 MCP 的客户端里。以常见的桌面客户端配置为例:
{ "mcpServers": { "my-server": { "command": "node", "args": ["/absolute/path/to/mcp-server/dist/index.js"], "env": { "DATABASE_URL": "postgres://localhost:5432/mydb" } } } }注册后如果发现连不上,先直接在终端里手动跑一遍node dist/index.js,看看有没有启动报错。很多 stdio 连接失败都是路径写错、环境变量缺失或者启动时打印了多余内容污染了标准输出导致的。
注意:stdio 模式下,服务端绝对不能随意往标准输出打印日志。你打到 stdout 的内容都会被 MCP 客户端当成协议数据解析,轻则污染日志,重则直接断连。日志请一律走
console.error或专用日志文件。
5.3 用 Docker 容器化部署
把 MCP server 部署成 HTTP 服务后,容器化是自然选择。下面这个 Dockerfile 是我常用的模板:
FROM node:22-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY tsconfig.json ./ COPY src ./src RUN npm run build FROM node:22-alpine AS runtime ENV NODE_ENV=production WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY --from=build /app/dist ./dist # 使用非 root 用户运行 USER node EXPOSE 3001 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ CMD node -e "fetch('http://127.0.0.1:3001/health').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))" CMD ["node", "dist/index.js"]这里有两处小细节值得说:第一,使用多阶段构建,build 阶段装了完整依赖,runtime 阶段只装生产依赖,镜像体积能小很多。第二,显式指定USER node,避免容器以 root 权限运行,这是生产环境的基本安全要求。
docker-compose 配置也很直接:
services: mcp-server: build: . ports: - "3001:3001" environment: - DATABASE_URL=${DATABASE_URL} - API_TOKEN=${API_TOKEN} restart: unless-stopped这里不要把所有密钥放进安装镜像里,通过环境变量注入即可。restart: unless-stopped能保证机器重启后服务自动拉起。
5.4 部署远程服务:反向代理与鉴权
MCP server 一旦通过 HTTP 暴露到远程,就必须考虑三个问题:SSL、反向代理、访问鉴权。我推荐用 Caddy,因为它自动申请和续期证书,配置也简单。
假设你的 Streamable HTTP 服务跑在 3001 端口,Caddyfile 大致这样:
mcp.example.com { reverse_proxy /mcp/* 127.0.0.1:3001 header { X-Accel-Buffering no } }注意X-Accel-Buffering no,这个头能防止代理缓冲破坏 SSE 或流式响应。
鉴权我建议用简单的 Bearer Token。服务端在接收请求前先校验Authorization头,如果和配置的 token 不一致,直接返回 401。在 SDK 的 Streamable HTTP transport 外层包一个中间件即可:
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; app.post("/mcp", (req, res) => { const auth = req.headers.authorization; if (auth !== `Bearer ${process.env.MCP_API_TOKEN}`) { res.status(401).json({ error: "Unauthorized" }); return; } const transport = new StreamableHTTPServerTransport({ messagingEndpoint: "/mcp", enableJsonResponse: true }); server.connect(transport); transport.handleRequest(req, res); });5.5 部署后的监控与日志
服务上线只完成了一半,另一半是监控和日志。MCP server 的监控重点有三个:进程存活、请求错误率、平均延迟。
进程存活用 Docker 自带的 healthcheck 就能覆盖,关键是错误率和延迟。建议在统一错误包装层里加计数器,把错误类型、工具名称、耗时记录下来,推到 Prometheus 或直接结构化输出到日志系统。
日志格式建议使用 JSON。每一条请求日志包含工具名、入参摘要、耗时、错误码、TraceID。这样在排查问题时,你可以把 Agent 一次完整调用链路上的多条日志串起来。
我自己的经验是:MCP server 出现故障时,“看不清”比“没有监控”更可怕。没有监控至少还能靠日志硬查,但日志是非结构化的,多个并发请求混在一起,基本没法还原现场。从第一天就用结构化日志,后面会省很多事。
6. 常见问题速查与个人经验总结
6.1 故障速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 客户端连接 stdio 后立即退出 | 启动时打印内容污染 stdout | 将日志改到 stderr,手动跑一遍确认 |
| 工具调用一直超时 | 上游服务慢但没有预留充足超时时间 | 给工具加超时控制,并返回可重试错误 |
| SSE 数据延迟到达 | 代理缓冲了事件流 | 设置X-Accel-Buffering: no或关闭代理缓冲 |
| 错误信息无法帮助模型修正 | error message 太笼统 | 补充字段级校验说明和修正提示 |
| 部署后无法访问远程 MCP | 未配置鉴权或反向代理路径不匹配 | 检查代理规则、token、SSL 证书 |
6.2 我从项目里总结的五条原则
第一,MCP server 不是工具集堆砌,而是业务能力的协议化封装。每个工具都应该有清晰的边界、明确的入参规范、稳定的错误结构。
第二,错误处理要站在“调用方”视角设计。你在写错误信息时,想象一下如果对面是一个会自动重试的大模型,它会怎么理解这段文字,会不会陷入循环重试。
第三,流式输出优先考虑进度通知,真正需要事件流时走独立 SSE 通道,不要把 MCP 工具当成无限流量的管道。职责分离能让系统更稳。
第四,TypeScript 严格模式不是负担,而是保障。牺牲一点开发速度,换取的是跨模块协作时的确定性。一个大型 MCP 项目如果没有类型保护,后期改一个共享类型的连锁报错会让你崩溃。
第五,部署方案要尽早确定。不要等到工具都写完了,才发现目标客户端不支持你选用的 transport。选型前先查文档,优先选择目标平台推荐的连接方式。
6.3 最后分享一个小技巧
如果这是你的第一个 MCP 项目,我强烈建议你第一天就搭好“最小可运行闭环”:一个只有 no-op 工具的 server,从本地 stdio 启动,能在客户端里被调用,返回一段固定文本。然后再往上加业务工具、错误处理、流式逻辑、部署配置。
别看这个 no-op 工具简单,它能把整个链路里的坑提前暴露出来:SDK 版本兼容性、模块解析方式、客户端注册格式、环境变量加载。等这个闭环跑通,后面加功能都只是增量工作。我自己每次开新项目,都会从这一步开始,前面省下的调试时间,远比写一个 hello world 的成本多得多。
希望这份指南对你有用。如果你在实操中遇到什么新问题,也欢迎回来交流——踩坑之后留下的经验,常常是文档里找不到的。