最近这段时间总有人拿着能跑通的 MCP server 来问我“工具注册没问题日志也正常怎么一接到真正的 Agent 里就各种奇怪问题——要么超时要么报错信息又长又乱要么数据半天出不来”说实话一个 MCP server 能跑和它能稳定地跑在生产业务里中间隔着的不是“多写几个工具”而是错误处理、流式输出、TypeScript 工程化和部署这套进阶组合拳。这篇指南是我在做自定义 MCP 服务器过程中积累的实操笔记适合已经用modelcontextprotocol/sdk写过入门 demo、准备把项目推到真实环境里的开发者。我会把最容易被忽略又最容易出问题的四个环节拆开讲每个环节都给出可复现的代码、配置和避坑经验。不管你是给内部 Agent 提供工具调用还是做面向外部客户的服务端这套方法论都通用。1. MCP 自定义服务器到底在解决什么问题1.1 先花三十秒重新理解 MCP 的价值MCPModel 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_ENVproduction WORKDIR /app COPY package*.json ./ RUN npm ci --omitdev COPY --frombuild /app/dist ./dist # 使用非 root 用户运行 USER node EXPOSE 3001 HEALTHCHECK --interval30s --timeout3s --start-period10s \ 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 的成本多得多。希望这份指南对你有用。如果你在实操中遇到什么新问题也欢迎回来交流——踩坑之后留下的经验常常是文档里找不到的。
阅读完成 · 觉得有帮助?