我最早接触 MCP 自定义服务器,是从官方那个三行代码的示例开始的。注册一个工具,server.tool(...)一写,客户端立刻就能调用,感觉这玩意儿太简单了。直到我把服务器从“能跑”推向“能用”,才意识到真正的坑全在后面:工具抛错时客户端收到的提示莫名其妙,长任务跑起来像卡死一样,TypeScript 类型到处是any凑合,好不容易写完扔到部署环境又连不上。这篇文章就把我在错误处理、流式输出、TypeScript 类型安全和部署落地这四个方向上的实践经验完整拆开讲一遍,都是些官方示例不会写、但真实项目绕不开的进阶细节。适合已经被 MCP 工具的新奇感钩住、正准备把它做成正经服务的开发者参考。
1. 工具报错别只会 throw new Error:MCP 错误处理的三层防线
MCP 的通信底层是 JSON-RPC 2.0,这意味着它的错误机制天生就是结构化的。但很多人写工具函数的时候,还是习惯像写普通 Node 接口一样直接throw new Error("xxx"),这样写不是不行,而是丢掉了把错误语义传达给调用方的能力。AI 客户端收到一个模糊的“服务器内部错误”时,它只能猜,猜错了就给你瞎编一个原因。所以第一层防线,是把错误从“文本描述”升级成“结构化错误码”。
1.1 用 McpError 而不是裸 Error,规范错误码的意义在哪里
@modelcontextprotocol/sdk里提供了现成的McpError类和ErrorCode枚举,路径一般在@modelcontextprotocol/sdk/types.js。我第一次重构错误处理时,把所有throw new Error(...)全部换成了带错误码的McpError,效果立竿见影。
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; // 不推荐:错误信息只有一段人类文本,客户端只能截取片段展示 throw new Error("城市参数不能为空,且必须是合法的城市名称"); // 推荐:错误码 + 可读信息,客户端和 AI 模型都能准确理解 throw new McpError( ErrorCode.InvalidParams, "城市参数不能为空,且必须是合法的城市名称" );你可能会问,反正都是把错误返给客户端,区别在哪?区别在于错误码给了一个“分类锚点”。InvalidParams代表调用方式有问题,客户端可以引导用户修正参数后再试;ToolExecutionError代表参数没问题但工具内部执行失败了,客户端应该把目标转向排查外部依赖。AI 模型在生成下一步决策时,错误码和错误信息都会进入上下文,错误码越精确,模型越容易做出正确判断。
SDK 的ErrorCode枚举在不同版本里略有差异,但我用的 1.x 版本里常用的几个,这里列一张表方便对号入座:
| 错误码常量 | 数值 | 典型使用场景 |
|---|---|---|
InvalidRequest | -32600 | 请求结构本身不合法,比如缺字段 |
MethodNotFound | -32601 | 客户端调用了未注册的工具 |
InvalidParams | -32602 | 工具参数校验不通过 |
InternalError | -32603 | 代码里未捕获的意外异常 |
RequestTimeout | -32001 | 工具执行超过了预设的超时时间 |
ResourceNotFound | -32002 | 按 URI 查找资源时找不到目标 |
InvalidToolArguments | -32004 | 工具入参结构错误,由 SDK 自动抛出 |
ToolExecutionError | -32005 | 工具执行阶段的业务失败 |
还有个小技巧:错误码和错误信息的关系就像 HTTP 状态码和响应体的关系,错误码管机器可读和逻辑分支,错误信息管人(或 AI)可读。我一般会把给模型看的“下一步建议”也写进错误信息里,比如“仓库不存在,请先调用 createRepo 创建仓库”,这样模型拿到报错后能自主决定要不要换个工具继续尝试。
1.2 参数校验交给 schema,不要在业务代码里手写 if
MCP SDK 对工具参数的处理,本质上是用 schema 描述 JSON 结构。你如果不用 schema,而是在回调函数内部写一大堆if (!params.xxx) throw ...,就把自动校验机制活活浪费了。正确做法是把约束直接声明在server.tool()的第二个参数上,让 SDK 在进入业务逻辑之前就把非法参数拦下来。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; const server = new McpServer({ name: "book-query", version: "1.0.0" }); server.tool( "queryBooks", { keyword: z.string().min(1).max(50), page: z.number().int().min(1).max(1000).optional().default(1), pageSize: z.number().int().min(1).max(50).optional().default(10), }, async (params) => { // 走到这里的 params 已经保证是合法的了 // params 的类型是 { keyword: string; page?: number; pageSize?: number } return { content: [{ type: "text", text: JSON.stringify(await searchBooks(params)) }], }; } );很多人在这个阶段踩过同一个坑:以为 zod schema 只是给客户端看“参数长什么样”的说明文档,于是 schema 写得极其宽松,所有业务校验都堆在回调里。结果就是,SDK 自带的参数错误分支(InvalidToolArguments)永远不触发,业务代码里却长满了if (!x) throw new Error(...)这种原始代码。
跨字段校验也不要手写进回调。zod 的refine和superRefine可以在 schema 层就处理“开始日期必须小于结束日期”这类关联校验,这样无论客户端怎么传参,拿到的都是一个结构化的InvalidToolArguments响应,而不是业务层抛出的随机错误。
const dateRange = z .object({ start: z.string().datetime(), end: z.string().datetime(), }) .refine((data) => data.start < data.end, { message: "start 必须早于 end", path: ["start"], }); server.tool("queryLogs", dateRange, async ({ start, end }) => { // 业务实现,无需再判断 start 和 end 的大小关系 });1.3 工具内部异常:捕获、脱敏、超时一个都不能少
schema 帮你挡住了参数层的错误,但真正危险的异常全在回调内部:数据库连接断了、上游 HTTP 接口超时了、JSON 解析炸了。这些异常如果原样抛出去,MJ 有概率把 Node 的堆栈信息直接带进错误响应,然后 AI 模型抓着at /home/user/project/dist/index.js:42这种信息给你编一个修复方案,非常误导。
我的经验是,不要在回调里把所有逻辑塞进一个大try/catch,而是把“外部依赖调用”单独隔离出来,统一做异常翻译。
server.tool( "fetchUserOrders", { userId: z.string() }, async ({ userId }) => { let data; try { data = await userService.getOrders(userId); // 外部依赖 } catch (err) { if (err instanceof TimeoutError) { throw new McpError( ErrorCode.RequestTimeout, "订单服务查询超时,请稍后重试" ); } // 记录完整堆栈到日志,但响应用户的只保留安全摘要 console.error("[fetchUserOrders]", err); throw new McpError( ErrorCode.ToolExecutionError, "订单服务暂时不可用,请联系运维排查" ); } return { content: [{ type: "text", text: JSON.stringify(data) }] }; } );这里有几个细节值得说明。第一,脱敏:服务端日志里可以打完整堆栈,但返回给客户端的错误绝不能包含文件路径、环境变量、内网 IP、请求密钥这些信息。第二,超时:很多工具回调是异步的,如果上游接口一直没有返回,工具调用就会一直挂着,客户端那边表现为“模型一直在思考”。我习惯在回调开头用AbortSignal.timeout()包一层,给外部调用加一个硬超时,超时就按RequestTimeout返回。第三,信号量:回调的第二个参数extra里通常有signal,当客户端取消请求时这个信号会被触发,这时候如果你还在跑重型任务,最好能检查并主动放弃,避免无用计算占着资源不放。
2. 别把“流式输出”做成打字机:MCP 协议活用的关键在进度通知
“流式输出”这个词在 MCP 开发话题里的热度一直很高,尤其被 ChatGPT 那种逐字蹦字的交互模式带偏了。很多人一听说“实现流式输出”,第一反应是让工具结果像打字机一样源源不断吐字符。但实际上,在 MCP 协议 v1.0 里,工具调用的最终结果仍然是一个完整的一次性响应,协议层面上并没有“把一段 text 分割成多次增量推送”的原生机制。
2.1 一个常见预期偏差:MCP 工具结果本来就不是流式的
如果你去翻 MCP 客户端 SDK 的源码,会看到工具调用的返回类型是CallToolResult,内容是一个content数组。这个数组是一次性序列化后返回给客户端的。协议没有提供类似“文本分段发送,客户端边收边渲染”的通道。换句话说,你没法用标准 MCP 协议给 Claude 桌面端做一个“模型逐字输出工具结果”的效果。
那为什么还有那么多关于“流式输出”的需求?因为它们大多来自 Web 场景的聊天机器人:浏览器里一个请求发出去,后端用 SSE 或 WebSocket 把大模型的 token 一段段推给前端,页面打字机一样渲染。但在 MCP 上下文里,工具服务器是给 AI 客户端调用的,不是直接给浏览器用的。AI 客户端本身的思考过程才是打字机,工具返回只是一个“中间停顿点”。
所以我在给团队做技术方案时,通常会先把结论拍死:如果你的目标是“工具返回结果需要打字机效果”,那大概率是产品设计跑偏了。工具的结果应该是结构化、完整、一次性的,让模型去消化;而不是像聊天一样让用户盯着工具输出逐字阅读。纠正了这个预期偏差,后面很多设计就好做了。
2.2 用 notifications/progress 给客户端“进行中”的手感
MCP 协议虽然没有流式的工具结果,但它提供了一个很接近流式体验的机制:进度通知(notifications/progress)。如果你的工具要执行一个耗时任务,比如批量处理文件、拉取多个外部接口、跑一个比较重的算法,千万别让客户端干等。你可以在执行过程中主动发进度通知,让客户端侧展示“任务处理到 XX%”的状态。
在 SDK 里发进度通知很简单,直接在工具回调里调用server.notification()即可:
server.tool( "batchProcessImages", { imageUrls: z.array(z.string().url()).min(1).max(50), }, async ({ imageUrls }, extra) => { const { signal } = extra; const total = imageUrls.length; for (let i = 0; i < total; i++) { // 每处理一张图之前,先检查是否有取消信号 if (signal?.aborted) { throw new McpError(ErrorCode.RequestTimeout, "任务已取消"); } await processOneImage(imageUrls[i]); server.notification({ method: "notifications/progress", params: { progress: i + 1, total, progressToken: "batchProcessImages", // 关联到具体的请求 }, }); } return { content: [{ type: "text", text: `成功处理 ${total} 张图片` }], }; } );这段代码里,progress表示当前完成数,total表示总数。客户端收到进度通知后,一些 MCP 客户端会把当前状态显示成“处理中 12/50”,模型也可以据此感知到任务仍在推进,而不是误判为卡死。
这个机制看起来简单,但有几个实践细节要注意。发通知的频率不要太高,每处理一项就发一次对某些客户端反而是负担,一般控制在每 5%~10% 发一次,或者每 1~2 秒最多发一条。通知和最终结果是异步关系,客户端不保证收到全部通知才能拿结果,所以不要在客户端里把“进度达到 100%”当成“工具必然返回成功”的前置条件,该做的结果判断还是在最终返回里做。在 stdio 模式下也能发通知,SDK 会自动打包成 JSON-RPC 消息通过 stdout 传出去,只是某些宿主可能忽略它,所以不要把所有体验押在进度通知上。
2.3 长文本返回与客户端断连:分页、资源和信号量止损
还有一个和“流式”经常一起被提到的场景:工具要返回的内容特别长,比如一次查出来 10 万行日志。很多人直接JSON.stringify塞进content数组,结果客户端那边要么截断、要么内存暴涨。我见过不少“标签返回未完整”的报错,根源就在这:单次TextContent的内容大小超出了宿主的处理能力上限。
更稳妥的做法是“分页查询 + 资源引用”。具体思路是:工具第一次只返回前 N 条结果,同时给出一个分页标记;模型如果还需要更多,就继续调用带有pageToken参数的下一页工具。这样单次返回体积可控,模型的上下文也不会被一次工具调用灌满。
server.tool( "searchLogs", { keyword: z.string(), pageToken: z.string().optional() }, async ({ keyword, pageToken }) => { const page = await logService.search(keyword, pageToken ?? "first", 200); return { content: [{ type: "text", text: JSON.stringify(page.items) }], // 可以附带下一个分页标记,由模型决定是否继续取 structuredContent: { nextPageToken: page.nextPageToken }, }; } );如果返回的内容本身适合用“资源”来表达——比如一个 CSV 文件、一张表格、一段 Markdown——也可以考虑注册成 MCP 资源,让工具返回一个resource类型的 content,客户端按需去读取。这个设计更符合 MCP 的“工具 + 资源”双模型理念,比硬塞长文本优雅得多。
断连处理是长任务里最容易忽视的一环。客户端如果在长任务执行到一半时关闭了会话,你的工具回调可能还在傻乎乎地跑。务必在回调里监听extra.signal的中断事件,主动终止外部请求、释放连接、记录日志。能优雅退出就好过让整个进程被宿主强制干掉。
3. TypeScript 类型安全不是玄学:从 zod 推导到 tsconfig 的完整配置
MCP 自定义服务器这个主题,天然和 TypeScript 绑得很紧。官方 SDK 就是 TS 写的,而且类型定义相当完整,你要是用any糊弄,等于把这份红利全部丢掉了。但很多人的 TS 工程实践还停在“能编译过就行”的水平,结果开发阶段一时爽,工具一多、SDK 一升级,类型炸起来根本不知道从哪里救。
3.1 让类型从工具 schema 自动长出来,告别手写 interface
写工具时最爽的一点是:你声明了 zod schema,回调参数的 TS 类型就自动生成了。SDK 的类型系统会把z.object({...})的形状直接推断为回调入参的类型,不需要你手动定义 interface,更不需要写类型断言。
server.tool( "generateReport", { reportType: z.enum(["daily", "weekly", "monthly"]), fromDate: z.string().datetime(), toDate: z.string().datetime(), includeSummary: z.boolean().default(false), }, async (params) => { // params 自动推断为: // { // reportType: "daily" | "weekly" | "monthly"; // fromDate: string; // toDate: string; // includeSummary: boolean; // } const { reportType, fromDate, toDate, includeSummary } = params; // 如果你把 reportType 错写成别的字符串,编译期直接报错 } );这样做收益最大的地方在于重构。工具数量增长到十几个以后,参数结构免不了要改。如果是手写 interface 配手写校验的套路,改一个字段可能牵连三四个文件;而 schema 作为唯一事实来源,改完 schema,所有依赖这个工具的地方都会在编译期告诉你哪里不匹配。凡是被“类型推不动”逼到写as any的,大概率是 schema 结构写得太别扭了,这时候该反思 schema,而不是靠断言糊弄过去。
3.2 tsconfig 的 module 陷阱:为什么 NodeNext 才能吃得开
MCP SDK 的包导出结构同时支持 ESM 和 CommonJS,但这不代表你随便配个module: "commonjs"就万事大吉。官方文档推荐 NodeNext,我实际用下来发现这不是推荐,而是必须,否则你会在导入路径上反复碰壁。
先看一个典型的tsconfig.json:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"] }配套地,package.json里要声明"type": "module",这样所有.ts文件编译后自然就是 ESM,与 SDK 的 ESM 导出对齐。之前我看到很多人在社区里问“为什么import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'编译不过”,十有八九就是没开NodeNext,TypeScript 还在用旧的CommonJS解析规则找文件,结果找不到完整路径。
还有个容易被忽略的点:NodeNext 对文件名后缀要求极严格。你在相对导入的时候,前缀必须带.js结尾(比如import { helper } from "./utils/helper.js"),即使源文件是.ts。初次切换到 NodeNext 的人会疯狂报错,这是正常的,改规则很简单。
3.3 SDK 升级导致类型断裂的排查思路,以及 declare global 的用法
SDK 版本升级是类型问题的高发区。我遇到过最典型的场景:从 0.x 升级到 1.x,server.tool的注册方式变了,回调参数从(params)变成了(params, extra),返回类型里structuredContent的约束也变严格了。这种升级造成的类型断裂,错误信息往往很长、很吓人,但核心问题往往就那么几个。
我总结了一套快速排查路径,供参考:
- 看报错文件的 import 路径:SDK 升级后,部分导出路径变了,比如旧版
@modelcontextprotocol/sdk/server/mcp.js和新版内部的类型导出位置不同,先确认所有import是否仍然有效。 - 看回调参数数量:如果报“函数参数类型不匹配”,大概率是新版回调变成了
(args, extra)双参签名,你看一眼类型定义里ToolCallback的签名就清楚了。 - 看返回的
content数组元素:新版对TextContent要求显式带type: "text"字段,如果你简写漏掉了type,类型检查会不通过。 - 看
structuredContent的序列化约束:新版要求structuredContent是 JSON 可序列化的纯数据类型,Date、Map这类对象放进去会直接报类型错误。
再来说说declare global。开发 MCP 服务器时,这个工具主要用于给 Node 全局对象扩展类型,比如你通过process.env读取自定义配置时,希望 TS 能感知到这些变量。直接在代码里写process.env.OPENAI_API_KEY,TS 认知里的process.env只有NodeJS.ProcessEnv的默认索引签名,能过但没有任何类型提示。你可以专门建一个env.d.ts:
declare global { namespace NodeJS { interface ProcessEnv { OPENAI_API_KEY?: string; MCP_SERVER_PORT?: string; DATABASE_URL?: string; } } } export {};这样全项目引用process.env.OPENAI_API_KEY时就有自动补全了。使用declare global的要点是:文件里要么有export要么有import,否则系统会把它当成全局脚本而不是模块,“类型声明只在本地生效”这个坑非常隐蔽,我第一次写的时候就被带偏过。搜索热度里“typescript 命名空间 declare global”排得很靠前,说明大家确实经常卡在这个地方。
4. 部署到生产环境:stdio、HTTP+SSE、Docker 与日志通道的选择
代码写完只是第一步,MCP 服务器真正进入“可用”状态,部署环节至少占一半工作量。而且这里有个和其他后端服务很不一样的地方:MCP 服务器有两种截然不同的传输模式,选错了,后面全部白搭。
4.1 先想清楚谁在调用你的服务:本地工具走 stdio,远程服务走流式 HTTP
MCP 的传输方式大致分两类:stdio和HTTP+SSE(流式 HTTP)。这不是随你挑的,而是由客户端决定的。
如果你开发的是给 Claude Desktop、Cursor 这类本地客户端用的工具,那默认走的一定是 stdio。客户端会在本机拉起你的 Node 进程,通过标准输入输出进行 JSON-RPC 通信。这种情况下,部署形态就是保证进程能被客户端启动起来。Claude Desktop 的claude_desktop_config.json里配置的是command和args,比如:
{ "mcpServers": { "my-server": { "command": "node", "args": ["/absolute/path/to/dist/index.js"], "env": { "OPENAI_API_KEY": "sk-xxx" } } } }这里面有几个极易踩的坑。路径问题:必须写绝对路径,因为客户端启动时的工作目录大概率不是你的项目目录。构建产物问题:要指向dist/index.js而不是src/index.ts,除非你专门用tsx这种运行时去跑 TS 源码,但那样得确保用户机器上有对应依赖。环境变量问题:从客户端拉起的进程,.env文件未必会自动加载,最好把关键配置显式写在env里,或者在代码里显式读取固定路径的配置文件。
反过来,如果你做的是一个需要被别人远程调用的服务,或者要部署在多台机器上的横向扩展服务,那就得走流式 HTTP。SDK 里对应的传输类是StreamableHTTPServerTransport,客户端通过 HTTP 长连接建立 SSE 通道实现通信。
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import express from "express"; const app = express(); app.use(express.json()); app.get("/mcp", async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, }); res.on("close", () => transport.close()); await transport.handleRequest(req, res); }); app.post("/mcp", async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, }); await transport.handleRequest(req, res); }); app.listen(3000);走 HTTP 模式后,你面对的就是一套正经的 Web 服务部署:需要监听端口、处理鉴权、考虑并发和反向代理。MCP 协议本身没有内置鉴权,如果服务暴露在不可信网络,一定得自己在 HTTP 层加一层 Bearer Token 校验或者接入公司的统一认证。有次我把一个未鉴权的 MCP 服务暴露在测试环境,半小时内就收到了大量陌生 IP 的探测请求,这个教训很深刻。
两种传输模式的选型,我这里整理成了一张表,方便照着判断:
| 对比维度 | stdio 模式 | HTTP+SSE 模式 |
|---|---|---|
| 典型场景 | 本地 AI 客户端调用 | 远程服务、多人共享、集群部署 |
| 启动方式 | 客户端拉起子进程 | 常驻 Web 服务 |
| 鉴权难度 | 无网络暴露,天然安全 | 需要自行实现 API Key / OAuth |
| 日志处理 | stdout 被占用,须用 stderr/文件 | 可正常输出到 stdout 或日志框架 |
| 适合部署形态 | 本地构建、直接运行 | Docker、Kubernetes、云服务器 |
4.2 Docker 化部署的关键点:多阶段构建与健康检查
很多教程把 Docker 部署描述得很简单,但实际上,直接把 Node 跑在容器里,再配一个端口映射,离“生产可用”还差得远。多阶段构建是我觉得投入产出比最高的一种方案:构建阶段安装全部依赖并编译 TS,运行阶段只保留编译产物和生产依赖,镜像体积小很多,部署也更快。
一个参考 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 ENV NODE_ENV=production WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY --from=build /app/dist ./dist CMD ["node", "dist/index.js"]健康检查是我见过最容易被跳过但又最重要的配置。如果是 HTTP 模式的 MCP 服务,暴露一个/healthz端点返回{"status":"ok"},容器编排系统靠它做探活非常方便。如果是 stdio 模式,容器化的意义其实很有限——因为本地客户端没法直接“spawn 一个容器里的进程”,stdio MCP 通常不适合往容器里塞。
那 stdio 模式要部署怎么办?我的经验是:保留最朴素的进程形态。用 systemd 或 pm2 守护进程,重点是保证它可以被稳定拉起来、异常退出后能自动重启、日志有地方可查。把一个大容器方案套在一个 stdio 工具上是过度设计,反而会引入很多无关的复杂性。
4.3 stdio 模式下的日志污染坑,以及 MCP Inspector 调试实战
这个坑我几乎每次带新手都会强调:stdio 模式下,stdout是 MCP 协议通信的专属通道,你打一个console.log就会把脏数据混进 JSON-RPC 消息流里。客户端解析不了,轻则工具调用失败,重则整个会话都崩掉。
所以你在工具回调里写任何“帮我看一眼”的调试日志,都绝不能走console.log。要打日志就得走console.error(stderr),或者直接写入文件。
// 错误示范:这条日志会被客户端当成 MCP 消息解析,直接破坏通信 console.log("工具被调用了,参数是", params); // 正确示范:stderr 和 stdout 是分开的,不会污染协议通道 console.error("工具被调用了,参数是", JSON.stringify(params)); // 更工程化的做法:集成 pino 或 winston,输出到文件 logger.info({ params }, "工具被调用了");生产环境的日志建议直接落到文件里,并做好按天轮转。你可以用 pino 配pino/file输出到logs/mcp-server.log,也可以用 systemd 直接接管 stderr。总之,日志通道设计要在第一版就定好,不然排错的时候就等于瞎摸。
调试阶段有一个官方工具是必学的:MCP Inspector。运行方式很简单,stdio 模式用:
npx @modelcontextprotocol/inspector node dist/index.js它会启动一个本地 Web 控制台,你在页面上能看到已注册的所有工具、手动调用工具并查看完整响应、观察通知消息。我每次做完一个工具都会先在 Inspector 里调一遍,确认返回内容和进度通知都符合预期,再交给客户端实际使用。这一步能省下大量“客户端为什么报错”的排查时间。
如果是 HTTP 模式,Inspector 也支持直接填 URL:
npx @modelcontextprotocol/inspector --transport http http://localhost:3000/mcp部署完成后,不管用什么形态上线,我都建议先写一个最简客户端脚本,完整走一遍“连接→调用工具→拿到结果→断开”的链路,确认端到端通了,再宣布服务可用。
还有一个小技巧想分享:MCP 服务器如果部署在局域网服务器,用 nginx 反向代理 HTTP 服务时,必须记得关闭缓冲,否则 SSE 长连接会被 nginx 缓冲住,客户端半天收不到数据。
location /mcp { proxy_pass http://127.0.0.1:3000/mcp; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; }我最初就是因为没关proxy_buffering,本地用 Inspector 调得好好的,一上 nginx 就抽风,卡了将近一天才定位到是缓冲的问题。从那以后,字符串proxy_buffering off已经被我刻进 DNA 里了。
回看这段 MCP 服务器开发经历,最初看官方示例觉得不过如此,真正把一个工具从“最小原型”推到“生产可用”,中间的全是这些文档里不会细讲的坑。错误处理决定了下游模型能不能读懂你的工具,进度通知决定了长任务的使用体验,TypeScript 配置决定了项目能撑到多大规模而不失控,部署模式则决定了服务到底能被谁用起来。尤其是日志这条线,我建议所有刚起步的人先想明白:你的 stdout 是协议通道,调试信息不要跟协议数据抢车道。把这条理顺了,后面的问题大多都能靠日志快速定位;理顺不了,再简单的 bug 都能查到你怀疑人生。