ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MCP自定义服务器生产级指南:错误处理、流式输出与TypeScript实践

MCP自定义服务器生产级指南:错误处理、流式输出与TypeScript实践 说实话我一开始对 MCP 是有点不屑的。第一次翻 Model Context Protocol 的文档第一反应是这不就是把 JSON-RPC 包了一层皮吗真正让我转变态度的是我把一个内部查询工具接进 AI 客户端之后看到团队里所有同事开始用自然语言调内部 API 的那一刻。MCP 自定义服务器本质上就是把你手里那些零散的内部接口、脚本、数据库查询统一包装成 AI 能发现、能调用、能理解结果的服务。这篇指南默认你已经跑通过一个最简单的 MCP 服务器——如果你还没跑通过建议先去官方 SDK 仓库把 quickstart 过一遍再回来看。文章聚焦四个绕不开的主题错误处理、流式输出、TypeScript 工程化、以及从本地到线上的部署。这四个主题看着独立实际环环相扣错误处理决定模型能不能自动修正调用流式输出决定长任务的交互体验TypeScript 决定你的维护成本部署决定你能不能真正把它用起来。1. 先确定边界再写代码自定义 MCP 服务器的定位与 TypeScript 选型逻辑1.1 MCP 服务器的能力边界工具、资源、提示词很多人在写第一个 MCP 服务器时容易犯一个毛病把什么都塞进去。MCP 协议定义了三种核心能力边界非常清晰。工具Tools是最常用的对应tools/list和tools/call两个方法。工具是有输入参数、有返回结果的函数模型根据你的描述决定何时调用它。适合封装一切动作型能力查天气、写数据库、调内部接口、执行计算。资源Resources对应resources/list和resources/read是暴露给模型读取的数据走的是类似文件读取的语义。适合放静态配置、文档片段、模板内容。提示词Prompts是预定义好的对话模板模型或用户可以直接选中使用。我见过不少人把本应做成 Resource 的静态数据包装成 Tool结果模型每次都要带一堆参数去查询既浪费 token 又容易出错。选型的判断标准很简单模型不需要参数就能拿到的东西用 Resource需要动态计算或产生副作用的东西用 Tool。拿不准的时候优先 Tool因为 Tool 对模型来说容错空间更大Resource 读出来的内容如果格式不对模型容易直接懵。1.2 为什么选 TypeScript你写的不是脚本是接口契约MCP 官方 SDK 同时提供 TypeScript 和 Python 版本社区里 Python 的教程明显更多因为 FastMCP 那套 API 确实写起来很爽。但我在实际项目中最终把主力语言定在 TypeScript原因有三。第一类型即文档也是契约。MCP 工具的参数定义最终要变成 JSON Schema 发给模型而 TypeScript 的zod可以直接从 schema 推导出类型。模型端看到的参数结构和后端代码里的类型是同一个来源永远不会出现文档和实现脱节的问题。第二如果你所在的团队已经有 Node.js 技术栈MCP 服务器的维护成本极低。它不是一次性脚本而是要长期运行、持续迭代的服务。让一个只会 JavaScript 的同事也能改得动比引入一套 Python 技术栈要现实得多。第三部署链路顺畅。Node.js 在服务器端的进程管理、容器化、CI/CD 配套都非常成熟tsc编译完就是纯 JavaScript随便哪个环境都能跑。这不是说 Python 方案不行而是说你要想清楚定位。如果你只是给自己写个本地小工具Python 完全够用如果这个服务器要面向团队、面向生产环境TypeScript 的长期收益明显更高。1.3 最小骨架高层 API 与底层 API 怎么选官方 SDK 给了一套高层封装McpServer注册工具、资源非常简洁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: internal-query-server, version: 1.0.0, }); server.registerTool( query_orders, { description: 按条件查询订单列表支持时间范围和状态过滤, inputSchema: { startDate: z.string().describe(开始日期格式 YYYY-MM-DD), endDate: z.string().describe(结束日期格式 YYYY-MM-DD), status: z.enum([pending, paid, shipped]).optional().describe(订单状态), }, }, async ({ startDate, endDate, status }) { // 业务逻辑 return { content: [{ type: text, text: JSON.stringify(rows) }], }; } ); const transport new StdioServerTransport(); await server.connect(transport);这套 API 适合 80% 的场景。但等你开始做流式输出、取消事件、自定义通知的时候会发现高层封装不够灵活需要直接操作底层Server对象。我的建议是项目初期用高层 API 快速搭骨架遇到能力瓶颈再往底层迁移不要一开始就陷在底层细节里。这里还有一个经验之谈工具描述一定要写得非常具体。模型是不会看你的代码的它只靠 description 和参数名来理解这个工具该不该调用、怎么调用。我见过很多人写 description 就一句话查询订单结果模型在模糊场景下频繁误调。好的 description 应该包含这个工具是干什么的、什么时候该用、什么时候不该用、有没有副作用。2. 错误处理的三层设计工具返回值、协议错误码与传输异常错误处理是 MCP 服务器进阶路上最容易翻车的地方。因为普通 API 的错误处理只需要考虑调用方是人而 MCP 的调用方是模型——模型会读你的错误信息然后自行决定下一步怎么走。这套逻辑跟面向人类开发者完全不一样。2.1 工具返回的结构化错误让模型自己学会补救很多初学者在工具内部直接throw new Error(...)觉得异常抛给客户端就完事了。在 MCP 里这通常是最差的做法。原因很简单大部分客户端会把异常显示成一句笼统的工具调用失败模型拿到这句话什么都做不了。正确做法是捕获异常把错误结构化之后放进工具返回值里。MCP 的tools/call响应允许返回结构化内容并且有一个isError字段专门标记这次调用是否失败server.registerTool( get_weather, { description: 获取指定城市当前天气, inputSchema: { city: z.string().describe(城市中文名例如北京), }, }, async ({ city }) { try { const data await fetchWeather(city); return { content: [{ type: text, text: JSON.stringify(data) }], }; } catch (err) { return { isError: true, content: [ { type: text, text: JSON.stringify({ code: WEATHER_API_ERROR, message: err instanceof Error ? err.message : 未知错误, retryable: true, suggestion: 请稍后重试或改查其他城市, }), }, ], }; } } );这里的精髓是suggestion字段。模型读到这段 JSON 之后会尝试按照建议调整策略比如换一个城市名重试。如果你只抛一个 Error: 500那模型就是死路一条。你要做的不是向模型隐藏错误而是把错误变成它可以理解和利用的信息。2.2 协议错误码不要所有异常都甩 InternalErrorMCP 协议层沿用了 JSON-RPC 的错误码体系modelcontextprotocol/sdk/types.js里定义了对应的ErrorCode枚举。我审计过不少社区的 MCP 服务器最常见的毛病就是所有异常全用InternalError这跟在 REST API 里把所有错误都返回 500 是一回事。协议层错误码应该这样用import { McpError, ErrorCode } from modelcontextprotocol/sdk/types.js; server.setRequestHandler(CallToolRequestSchema, async (request) { const { name, arguments: args } request.params; if (!args || typeof args ! object) { throw new McpError(ErrorCode.InvalidParams, ${name} 缺少参数); } if (!toolRegistry.has(name)) { throw new McpError(ErrorCode.MethodNotFound, 未找到工具: ${name}); } // 工具内部业务逻辑 });对照关系大概是这样的场景错误码含义参数缺失或格式不对InvalidParams客户端应修正参数后重试请求的方法不存在MethodNotFound客户端可能连了旧版本服务器JSON 解析不了ParseError协议层问题请求本身不合法InvalidRequest状态机不满足协议要求以上都不是InternalError服务端内部故障有一个容易被忽略的细节模型在收到InvalidParams时通常会尝试修正参数再次调用而收到InternalError时往往会直接放弃或换一个工具。所以你区分错误码本质上是在控制模型的行为策略。能用InvalidParams说清楚的问题不要让它变成InternalError。2.3 校验失败与业务失败的边界我习惯把工具调用失败分成三类每类的处理方式完全不同。参数校验失败模型给的参数类型不对、缺字段、枚举值不合法。这类错误应该尽早拦截在真正的业务逻辑执行之前就返回。我用zod做 schema 校验SDK 在调用 handler 之前就会帮我拦截一部分剩余的复杂业务校验在 handler 里做。校验失败的返回值要带上具体是哪个字段、期望什么格式模型才能快速修正。上游依赖失败数据库连不上、第三方 API 超时、缓存服务挂了。这类错误的特点是重试有可能成功所以结构化的错误里一定要有retryable: true标记并尽量告诉模型建议等待多久再重试。业务规则失败比如订单已经发货不能取消。这类错误重试也没用必须明确标注retryable: false并给出替代方案比如可以调用退款接口。模型如果没有替代方案就会开始编造这是最危险的。在代码层面我通常用一个统一的错误返回工具函数保证所有工具返回的错误结构完全一致。模型对不同工具返回的错误格式的兼容性很差你可以在一个工具里用{error: xxx}在另一个工具里用{message: xxx}模型会精神分裂。2.4 上游超时与重试策略MCP 的tools/call是一个同步等待的请求客户端通常有自己的超时时间。如果你的工具内部要去调一个可能跑 30 秒的上游接口而客户端 10 秒就超时了那你这段逻辑写得再好也没用。我的做法是给所有上游调用设置明确的超时控制并把它跟业务超时区分开async function withTimeoutT(promise: PromiseT, ms: number): PromiseT { let timer: NodeJS.Timeout; const timeout new Promisenever((_, reject) { timer setTimeout( () reject(new Error(上游服务超时${ms}ms)), ms ); }); try { return await Promise.race([promise, timeout]); } finally { clearTimeout(timer!); } }实测下来的经验是工具内部的上游调用超时时间建议设置成客户端超时时间的 60% 到 70%。留出的余量用来处理 MCP 协议本身的序列化、传输开销以及客户端处理响应的耗时。这个比例我在多个项目里验证过能显著降低客户端超时但服务端还在跑的尴尬局面。3. 流式输出不是炫技长任务的进度通知、增量返回与取消很多人觉得 MCP 工具调用不就是发个请求等结果吗流式输出有什么好讲的直到你开始封装一个耗时 5 分钟的数据分析任务或者调一个要跑 100 条 SQL 的批量接口你就明白了。3.1 什么时候必须上流式判断标准很简单如果你的工具平均执行时间超过 3 秒就应该认真考虑流式方案。3 秒之内客户端转个圈用户还能忍超过 3 秒用户就会开始怀疑是不是卡死了。模型侧的感知更是如此。很多客户端在工具执行期间模型是不能继续对话的。用户那边的体验就是发了一条消息然后看着加载动画干等三分钟。这时候如果你能持续推送进度正在执行第 5/50 条查询用户的焦虑感会大幅下降。MCP 协议提供了一套标准的进度通知机制核心是一个叫progressToken的东西。客户端发起tools/call请求时可以在_meta里带上一个进度令牌服务端通过notifications/progress通知不断上报进度客户端就能把进度渲染到界面上。3.2 progressToken 与进度通知的实现这里就需要用到底层ServerAPI 了因为高层McpServer封装没有直接暴露进度通知的入口。import { Server } from modelcontextprotocol/sdk/server/index.js; import { CallToolRequestSchema, ListToolsRequestSchema, } from modelcontextprotocol/sdk/types.js; const server new Server( { name: etl-server, version: 1.0.0 }, { capabilities: { tools: {} } } ); server.setRequestHandler(CallToolRequestSchema, async (request) { const { name, arguments: args } request.params; const progressToken request.params._meta?.progressToken; // 模拟一个多步骤任务 const total 10; for (let i 1; i total; i) { // 执行一部分业务逻辑 await runBatch(i); if (progressToken ! undefined) { await server.notification({ method: notifications/progress, params: { progressToken, progress: i, total, }, }); } } return { content: [{ type: text, text: 批量任务完成 }], }; });几个要注意的细节。progressToken 不一定存在。有些客户端就是不传你要做空值判断不要假设它一定在。notification 要带await。如果进度通知积压后面的通知会排队你可以在循环里用setImmediate或者减小通知频率来避免堆积。我的经验是进度不超过 100 步的任务每步都发没问题超过 100 步的按百分比合并到每 1% 发一次。发通知失败不能影响主流程。有些客户端协议实现不完善收到通知可能直接断开。把通知包在 try/catch 里失败就忽略别让进度通知把整个任务搞崩。3.3 取消事件与 AbortController 的配合进度通知解决了看得见的问题接下来是停得下来的问题。MCP 协议里有notifications/cancelled通知客户端可以发这个消息来取消进行中的请求。很多服务端根本没有处理这个通知导致用户点了取消按钮任务还在后台继续跑。对于耗时长的任务这不仅是资源浪费更是生产事故的隐患。处理方案是给每个进行中的请求维护一个AbortControllerconst activeTasks new Mapstring | number, AbortController(); server.setRequestHandler(CallToolRequestSchema, async (request) { const requestId request.id; const controller new AbortController(); activeTasks.set(requestId, controller); try { // 在任务循环里检查 controller.signal.aborted for (const item of tasks) { if (controller.signal.aborted) { return { isError: true, content: [{ type: text, text: 任务已被用户取消 }], }; } await processItem(item, controller.signal); } } finally { activeTasks.delete(requestId); controller.abort(); } }); server.setNotificationHandler( { method: notifications/cancelled }, async (notification) { const requestId notification.params.requestId; const controller activeTasks.get(requestId); if (controller) { controller.abort(); activeTasks.delete(requestId); } } );真实项目中下游请求比如 HTTP 调用、数据库查询能否真正中断取决于你使用的客户端库是否支持AbortSignal。Node.js 原生的fetch支持大部分数据库驱动也支持。对于不支持中断的调用至少要做到不继续发起新的子任务并在返回结果里告诉模型任务已取消。3.4 增量文本返回的限制与替代方案有人会问能不能像 LLM 本身那样把工具的结果也一段一段流式吐给客户端MCP 协议目前的tools/call响应设计是最终一次性返回的进度通知只是过程信号不是最终结果的一部分。如果你真的需要先看到一部分结果再看到后续结果有以下几个替代思路。思路一把大任务拆成多个小工具调用。比如查询所有分区的数据拆成查询指定分区的数据让模型自己在循环里多次调用。这个方案的优点是符合 MCP 的设计哲学缺点是模型可能不会自动循环需要你在 prompt 里引导。思路二用进度通知携带中间摘要。进度通知的 message 字段可以包含文本信息客户端可以把这些信息展示给用户。虽然不是标准的结果通道但很多客户端会显示出来体验上接近流式。思路三让工具返回一个 taskId再提供一个查询进度的工具。这是我在生产环境用得最多的方案适合真正的后台长任务。start_analysis启动异步任务并返回 taskIdget_analysis_status查询进度get_analysis_result获取最终结果。配合进度通知体验相当完整。4. TypeScript 工程化模块方案、类型推导与本地调试的硬性约定4.1 ESM 还是 CJS这一步决定你后面踩多少坑如果你是 2025 年还在新建项目直接用 ESM 就是了。package.json里设置type: moduletsconfig.json用NodeNext模块方案{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: ./dist, rootDir: ./src, strict: true, declaration: true, sourceMap: true, esModuleInterop: true, skipLibCheck: true }, include: [src] }注意两个常见的坑。第一SDK 的导入路径要带.js后缀。modelcontextprotocol/sdk/server/mcp.js这个.js不是写错了是 NodeNext 模块解析的要求。很多从 CJS 转过来的同事会漏掉这个后缀然后编译报错或者运行时报ERR_MODULE_NOT_FOUND。第二ESM 环境下没有__dirname。如果你写的是path.join(__dirname, config)运行时会直接报错。替代方案是用import.meta.dirnameNode.js 20.11或者手动拼import { fileURLToPath } from node:url; import path from node:path; const __dirname path.dirname(fileURLToPath(import.meta.url));我不建议在生产环境用tsx直接跑 TypeScript 源码。开发时用没问题生产环境一定要先tsc编译再跑。原因有两个一是编译产物是纯 JavaScript启动速度快、内存占用低二是你部署到服务器上不需要安装 TypeScript 依赖镜像能小不少。4.2 以 Zod schema 为唯一事实源MCP 工具的inputSchema是给模型看的 JSON Schemahandler 里拿到的参数是你代码里的类型。这两者如果靠手写维护一定会在某次迭代中分叉。解决思路是用zod的describe方法让 Zod schema 既是类型来源又是协议 schema 来源import { z } from zod; const QueryOrdersParams z.object({ startDate: z.string().describe(开始日期格式 YYYY-MM-DD), endDate: z.string().describe(结束日期格式 YYYY-MM-DD), status: z .enum([pending, paid, shipped, cancelled]) .optional() .describe(订单状态不传则查询全部), pageSize: z.number().min(1).max(100).default(20).describe(每页数量), }); type QueryOrdersParams z.infertypeof QueryOrdersParams;SDK 的高层registerTool会自动把 Zod schema 转换成 JSON Schema 发给客户端。这意味着你的类型定义和协议描述完全同源改了一处另一处自动跟上。我跟团队定的规矩是所有工具参数必须用 Zod 定义禁止手写类型和 schema。还有一个实用技巧给枚举类型写清楚取值范围。模型对自由文本输入的把握比较准但对数字范围、枚举值的把握很差。你写z.number()它可能传 0你写z.number().int().min(1).max(500)并加一句 describe根据业务系统限制单页最多 500 条模型就会规范很多。4.3 调试时绝不能在 stdout 打日志这是 MCP 开发者踩得最多的坑没有之一。当你的服务器使用 stdio 传输时stdout 是 MCP 协议数据的专用通道。你在代码里写一句console.log(hello)输出的字符串会被客户端当成 JSON-RPC 消息来解析直接导致协议解析失败、请求断开。而且这个问题很隐蔽——你在终端里手动跑服务器看日志一切正常一旦被客户端拉起就莫名其妙地报错。解决办法有两个。一是所有日志一律走 stderr。Node.js 里console.error是输出到 stderr 的不影响 stdout 的协议数据。SDK 内部也是用 stderr 打印日志的所以你在终端里能看到 SDK 的调试日志但那些日志从不进入协议通道。import { createLogger } from ./logger.js; // 自定义 logger 内部统一走 process.stderr const logger createLogger(); logger.info(查询订单参数, { startDate, endDate, status });二是开发时用 MCP Inspector 而不是手动终端调试。官方提供的modelcontextprotocol/inspector是一个可视化调试工具可以让你手动调用工具、查看 JSON-RPC 原始消息、检查进度通知。启动方式很简单npx modelcontextprotocol/inspector node dist/index.jsInspector 会开一个本地 Web 页面左边是服务器日志来自 stderr右边是请求响应的 JSON 原始消息。我调试协议级问题比如进度通知格式不对、错误码不标准时几乎全靠它。强烈的建议是任何 MCP 服务器在提交给用户之前先用 Inspector 完整走一遍所有工具的正常路径和异常路径。4.4 用 MCP Inspector 做协议层验证接着上面说Inspector 的具体价值在于你能看到客户端视角的真实情况。我每次开发完一个新工具都会在 Inspector 里做三件事正常调用确认返回结构符合预期模型能读到结构化 JSON。故意传错参数确认校验失败返回的是InvalidParams而不是InternalError。查看原始请求的_meta确认客户端是否带了progressToken这决定了你的进度通知代码是否会被执行。Inspector 还支持测试 SSE 传输的服务器npx modelcontextprotocol/inspector --transport sse http://localhost:3000/sse这样用。本地调试阶段把 stdio 和 SSE 两种模式都测一遍能提前发现很多传输层差异。5. 部署形态决定一切stdio、Streamable HTTP 与容器化实操5.1 客户端怎么拉起你的服务器stdio 部署细节MCP 服务器最简单的部署形态就是 stdio客户端比如 Claude Desktop、各种 IDE 插件在本地启动一个子进程通过标准输入输出跟你通信。这种形态下你的部署工作其实就是保证客户端能稳定地拉起你的进程。以 Claude Desktop 为例配置文件claude_desktop_config.json里长这样{ mcpServers: { my-server: { command: node, args: [/home/user/apps/my-server/dist/index.js], env: { API_KEY: xxx, DATABASE_URL: postgres://localhost:5432/app } } } }这里最容易犯的错误是用npx作为 command。npx每次启动需要做包解析和下载检查速度慢不说一旦网络有问题或者 npx 交互式地询问是否安装客户端那边就直接卡死了。生产环境一定要用绝对路径直接指向编译产物或者用npm link做好全局链接。我踩过这个坑之后现在的标准做法是发布一个带完整dist目录的产物用绝对路径明确指定。env字段是注入密钥的好地方但注意这个配置是明文存在的不要把生产环境的敏感密钥长期放在客户端配置文件里。我一般放一个最低权限的 token单独的服务端做授权和审计。还有一点args里不要用相对路径。客户端可能从任意工作目录启动你的服务器相对路径会找不到文件。用绝对路径或者在代码里用import.meta.dirname推导出自己所在的目录再拼资源路径。5.2 远程服务Streamable HTTP 传输与鉴权stdio 形态天然只能服务本机的一个用户。一旦你有多台机器、多个用户要共用同一个 MCP 服务器就必须走 HTTP 传输。MCP 协议演进到今天我推荐直接使用Streamable HTTP传输这是官方 SDK 里streamableHttp模块提供的它统一了之前 SSE 的 GET/POST 分离模型用标准的 POST 请求处理所有消息通过响应体是否Content-Type: text/event-stream来决定是否需要流式读取。相比老的 SSE transport它在反向代理、负载均衡场景下兼容性好得多。import express from express; import { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; const app express(); app.use(express.json()); app.post(/mcp, async (req, res) { // 鉴权校验 Bearer Token const token req.headers.authorization?.replace(Bearer , ); if (token ! process.env.MCP_ACCESS_TOKEN) { res.status(401).json({ error: unauthorized }); return; } const transport new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); await server.connect(transport); await transport.handleRequest(req, res); }); // 可选GET 用于服务发现 app.get(/mcp, async (req, res) { res.json({ name: my-server, version: 1.0.0 }); }); app.listen(3000, () { console.error(MCP server listening on :3000); });关于鉴权多说几句。我见过很多部署直接裸奔任何能访问这个端口的人都能调用你的工具。如果是内部低风险工具还好万一工具能操作生产数据库这就是等着出事的节奏。我的经验是至少做到三层防护端口层面用防火墙或者安全组限制来源 IP只允许内网访问。传输层面走 HTTPS避免 token 明文在网络上传输。应用层面Bearer Token 鉴权每个客户端或每个用户一个 token方便审计和撤销。5.3 容器化与进程守护无论 stdio 还是 HTTP 形态我都会用 Docker 做标准化的交付物。多阶段构建能显著减小最终镜像体积FROM node:20-alpine AS build 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 package*.json ./ RUN npm ci --omitdev COPY --frombuild /app/dist ./dist USER node CMD [node, dist/index.js]这里有几个细节值得注意。不要用 root 用户跑容器。USER node那行不是可选的安全审计和容器扫描工具都会标记 root 运行的问题。npm ci 要用锁文件。package-lock.json一定要提交到仓库否则同一份 package.json 在不同时间 install 出来的依赖可能不一致线上复现不了本地问题。如果你做远程 HTTP 形态要给容器加 HEALTHCHECK。可以简单用一个 HTTP GET 请求/mcp的健康检查路径返回 200 就算存活。这样编排平台能自动拉起挂掉的实例。对于 stdio 形态的进程守护容器化依然有意义——你可以在容器里跑 Node 进程然后把宿主机的命令配成docker run。这样升级版本、回滚都变成镜像操作干净利落。但如果你的客户端就在同一台机器上直接配 systemd 服务也是一种选择看你的运维习惯。核心原则是不要在客户端配置里写裸的node dist/index.js一定要有一个守护机制保证崩溃后自动重启。5.4 配置管理与密钥注入最后聊一下配置。MCP 服务器的配置分成两层协议层配置服务器名称、版本、能力声明和业务层配置数据库连接、API Key、模型地址。前一层写死在代码里没什么问题后一层必须走环境变量或配置管理。我的习惯是所有敏感信息一律从process.env读取启动时集中校验缺了就直接报错退出function loadConfig() { const apiKey process.env.INTERNAL_API_KEY; if (!apiKey) { throw new Error(缺少环境变量 INTERNAL_API_KEY); } return { apiKey, dbUrl: process.env.DATABASE_URL ?? 默认值 }; } const config loadConfig();不要搞配置缺失先用默认值的骚操作。MCP 服务器很多是在后台默默运行的你用了默认值连了错误的数据库可能跑了好几天才被人发现。宁可启动时报错也不要带病运行。另外如果服务器跑在 Kubernetes 里密钥用 Secret 注入环境变量里不要出现明文。跑在 VM 里的话用一个.env文件配合 systemd 的EnvironmentFile加载权限设 600。6. 线上运行半年后回头看客户端兼容性与我踩过的坑6.1 客户端五花八门的行为差异同一个 MCP 服务器接到不同的客户端上行为差异大得让你怀疑自己写的不是标准协议。我实际遇到过的就有某客户端不发_meta进度通知逻辑永远不触发。某客户端对isError: true的响应会终止整个对话另一个客户端却会把它当作普通文本继续聊。某客户端在工具返回超大 JSON 时直接截断导致模型拿到的是不完整的 JSON。这些问题的根源是协议只定义了消息格式没有定义客户端该怎么渲染各种情况。你在开发阶段用 MCP Inspector 测过一遍不代表所有客户端行为都一致。我的应对方案是尽量让工具返回内容的自解释性最强。文本内容用清晰的 JSON并且末尾加一句人类可读的摘要这样即使客户端做了截断模型也能从摘要里拿到关键信息。另外工具返回的文本别超过几千字符超过就考虑拆结果或者提供分页查询工具。6.2 超时、重连与幂等MCP 的 stdio 传输下如果服务器进程崩溃了客户端会尝试重启它吗答案取决于客户端实现。有的客户端会有的不会有的只会傻等。你无法控制客户端只能让自己的服务器更健壮。两个方向的经验。一是让服务器崩溃面尽量小。顶层用一个 process 级的兜底异常捕获记录日志后优雅退出而不是带着半个状态继续跑process.on(uncaughtException, (err) { console.error(未捕获异常准备退出, err); // 必要的清理工作 process.exit(1); }); process.on(unhandledRejection, (err) { console.error(未处理的 Promise 拒绝, err); // 视情况决定是否退出 });二是设计工具时保证幂等。特别是写操作类的工具客户端可能因为网络超时重发同一个请求你的工具要能识别这个数据我已经处理过了。实现思路是让调用方传一个idempotencyKey服务端用一个 KV 存储记录处理过的 key重复请求直接返回上次结果。这个设计在普通 API 里是老生常谈在 MCP 工具里重要性反而更高因为模型可能会因为误解而重复调用同一个工具。6.3 版本锁定与协议演进MCP 协议还在快速演进SDK 几乎每个月都有新版本。线上运行的服务器我强烈建议锁死 SDK 的精确版本不要用^范围匹配。原因很简单协议层的一个小改版可能影响你服务器的行为而你根本不会主动去升级。package.json里明确用精确版本{ dependencies: { modelcontextprotocol/sdk: 1.13.0 } }升级 SDK 时要像升级协议版本一样对待先在本地把所有工具跑一遍再用 Inspector 过一遍原始消息最后到 staging 环境让真实客户端测一轮。不要相信 changelog只相信实际运行结果。协议协商方面SDK 在握手时会和客户端协商双方支持的协议版本一般不需要你手动处理。但如果你直接在底层写协议交互就要注意协议的版本协商字段别假设所有客户端都支持最新版本。兼容性是线上服务的第一原则创新留给本地实验。说到底MCP 自定义服务器的开发没有太多玄学核心就是把协议吃透把错误处理做结构化把长任务用进度通知和取消机制管起来然后老老实实用 TypeScript 的工程化手段保证代码可维护最后把部署当成正式的线上服务来对待。如果你正在从demo 跑通走向生产可用上面这些坑大概率会在未来几个月里一个个遇到。提前设计好比到时候救火要省心得多。我个人的体会是每类问题踩过一次之后后面的项目基本就能一次写对——这也正是我写这篇总结的原因。
返回列表