说实话,过去半年我身边几乎所有做 AI 应用的人都在聊 MCP。Cursor 里挂 MCP 服务器、Claude Desktop 里配 MCP、本地部署的 Ollama/DeepSeek 也想通过 MCP 把工具调用能力接出来。但有个很现实的问题:用别人写好的 MCP server 很简单,自己动手写一个才发现,网上教程清一色停在“hello world”级别——注册一个 tool,返回一段文本,就结束了。
真正把一个 MCP server 推到生产环境,你会撞上四堵墙:错误处理到底怎么做才不会被 Client 吞掉;长时间运行的 Tool 怎么让用户感知到“我还在干活”;TypeScript 项目结构怎么设计才不越写越乱;以及部署成远程服务时,SSE、鉴权、容器化、Session 这些坑怎么一个个踩平。这篇就把这四件事一次性讲透。内容基于我这几个月用 TypeScript 从零开发并上线一个自定义 MCP server 的实操记录,适合已经跑通过 MCP 示例、准备做真实项目的开发者。
1. 从“用 MCP”到“写 MCP”:先搞清协议里谁在干什么
1.1 MCP 不是新框架,而是一套“调用规则”
很多初学者把 MCP 理解成一个库或一个工具,这是最大的误区。MCP(Model Context Protocol)本身是一套基于 JSON-RPC 2.0 的通信协议,它要解决的核心问题是:AI 应用(统称 Host)如何用统一的方式发现并调用外部能力(Tool),而不是每个 AI 都去对接一套自定义插件接口。你可以把它理解成 AI 世界的“USB-C 接口”——设备端协议统一了,外设就能即插即用。
整个链路里的角色是这样的:Host 是 Claude Desktop、Cursor、自研 Agent 这类应用;Server 是你的代码,暴露 tools、resources、prompts 三类能力;两者之间还有一个 Client 组件,负责把 Host 的意图转成 JSON-RPC 请求发给 Server。写自定义服务器,本质上就是实现“被调用”这一侧的全部语义。
一个完整的 MCP server 开发链路通常走这几步:初始化握手(initialize/initialized)→ Host 通过 tools/list 拿到工具清单 → 用户或 Agent 决定调用某个工具 → Host 发 tools/call 带参数 → Server 执行并返回结构化结果。这里面有个很多新手忽略的点:MCP 的传输层有两种,stdio 和 Streamable HTTP(早期还有独立 SSE 传输,现在官方推荐用 Streamable HTTP 逐步取代)。前者适合本地开发、跟桌面软件配对;后者才是远程服务的正道。两种传输对协议层影响不大,但部署和排错时差异非常大,第 5 章我会专门展开。
1.2 哪些场景值得自己写,哪些不值得
不是所有需求都要自己写服务器。我的判断标准很直接,三条:
- 数据源或动作在本地/内网,社区里没有现成 server。比如要操作公司内部的工单系统,开源生态里显然不会有现成的 MCP server。
- 需要把几个外部服务组合成一个“业务动作”。比如“查库存→算运费→下单”这种多步逻辑,暴露成单个工具比暴露三个原始 API 更符合 Agent 的使用习惯。
- 需要精细控制参数校验、错误语义和流式进度,社区版满足不了你的业务容错要求。
反过来,如果只是调用某个公开 API(天气、搜索、GitHub),我建议先去 MCP 官方仓库和社区找现成的,别重复造轮子。另外,最近很多人想通过自建 MCP server 把本地部署的 DeepSeek、Ollama 模型能力接出来,这个需求本身合理,但注意区分边界:MCP server 是给 Agent 提供工具用的,不是给模型提供对话能力的。模型对话走 OpenAI 兼容接口即可,MCP 的 Tool 是模型在执行任务时主动去“够”外部信息用的。想清楚这一点,你的系统架构会清爽很多。
2. 错误处理:MCP 不是普通 HTTP 接口,错误要分两层设计
2.1 协议错误 vs 业务错误:两个完全不同的出口
刚开始写 MCP server 时,我下意识沿用 REST API 的习惯:出错了就抛异常,让框架统一返回错误。这个思路在 MCP 里只对了一半。原因在于 MCP 的 tools/call 响应本身是允许“成功返回一个失败结果”的。
这里必须先建立两层错误的认知。
第一层是协议层错误。请求格式不对、参数校验不过、方法名不存在、Server 内部崩溃,这些走 JSON-RPC 的 error 结构返回,由 SDK 的 McpError 抛出后统一序列化。协议错误意味着“这个请求根本没被正常执行完”。
第二层是业务层错误。工具被正确调用了,参数也对,但业务逻辑执行失败——比如查不到用户、上游接口 500、数据库连接超时。这种错误在 MCP 的设计里不应该被当成协议错误抛出去,而应该正常返回 tool result,并带上 isError: true 标记。
为什么非要这么分?因为 Host 需要区分“这个工具有问题”和“这次业务没办成”。Agent 收到协议错误时通常会放弃当前工具或直接向用户报错;收到 isError: true 的业务错误时,模型反而能读取错误内容,尝试修正参数后重新调用。如果你的工具总是抛协议错误,Agent 的容错路径就完全废了,这直接影响自动化任务的完成率。
2.2 协议错误的正确用法与错误码对照
MCP 基于 JSON-RPC 2.0,错误码沿用了一套惯例。下面是协议层最常见的错误码,我平时会整理在项目 README 里,方便团队对齐:
| 错误码 | 名称 | 典型触发场景 |
|---|---|---|
| -32700 | 解析错误 | 请求 JSON 不合法 |
| -32600 | 无效请求 | 请求不是合法的 JSON-RPC 对象 |
| -32601 | 方法不存在 | 收到未注册的方法名 |
| -32602 | 参数无效 | 参数缺失、类型错误、不满足约束 |
| -32603 | 内部错误 | 未捕获的异常 |
在 TypeScript SDK 里,协议错误的写法很直接:
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; // 方式一:主动校验,抛协议错误 if (typeof args.projectId !== "string") { throw new McpError( ErrorCode.InvalidParams, `projectId 是必填的字符串参数,实际收到 ${JSON.stringify(args.projectId)}` ); } // 方式二:把未捕获的异常统一归纳为内部错误 try { const data = await fetchData(); return { content: [{ type: "text", text: JSON.stringify(data) }] }; } catch (err) { throw new McpError( ErrorCode.InternalError, `获取项目数据失败: ${err instanceof Error ? err.message : String(err)}` ); }这里有个细节:SDK 内部其实已经对 Zod 校验失败做了处理。如果你用 server.tool() 注册工具并声明了参数 schema,参数不合法时 SDK 会自动返回 -32602。所以我在业务代码里很少手动抛 InvalidParams,那层交给 schema 就好;协议错误主要用来兜底未预期的异常,而不是做日常参数检查。
2.3 业务错误:用结构化内容代替一句“操作失败”
业务错误的关键是“让模型看得懂、能处理”。如果只返回一个“操作失败”,模型根本不知道怎么改。我自己设计了一套业务错误格式,所有工具统一遵守:
function businessError( code: string, message: string, retriable: boolean = false ) { return { content: [ { type: "text" as const, text: JSON.stringify({ ok: false, error: { code, message, retriable, }, }), }, ], isError: true, }; }实际调用时是这样的:
server.tool( "create_work_order", { title: z.string(), priority: z.enum(["low", "medium", "high"]) }, async ({ title, priority }) => { const upstream = await callTicketApi({ title, priority }); if (upstream.status === 401) { return businessError( "AUTH_EXPIRED", "工单系统的 access token 已过期,需要重新授权", false ); } if (upstream.status === 429) { return businessError( "RATE_LIMITED", "工单系统限流,请稍后重试", true ); } if (upstream.status >= 500) { return businessError( "UPSTREAM_DOWN", "工单系统内部错误,请稍后重试", true ); } return { content: [{ type: "text", text: JSON.stringify(upstream.data) }] }; } );把 retriable 单独拎出来是有原因的。Agent 拿到错误后会判断要不要重试:retriable=true 的情况(限流、上游 5xx)可以带退避重试;retriable=false 的情况(鉴权过期、参数业务性错误)重试没有任何意义,应该引导用户人工介入。你在返回文本里最好也写上建议动作,比如“请先更新环境变量中的 API token”,模型会把这句话转述给用户,体验会好很多。
还有一点很多人会忽略:不要把系统内部的敏感信息通过错误内容暴露给模型。堆栈、内网地址、SQL 片段都不能出现在返回里。模型会原样转述给用户,也可能被记进对话历史。正确做法是在 server 层统一做异常脱敏,再返回给 Host。
2.4 长任务超时:不该靠“报错”来解决
最后一个和错误强相关的话题是超时。工具执行时间一长,各种问题都会冒出来:Client 侧可能设了超时、反代可能掐连接、用户可能以为卡死了。这时候你会发现“报错”并不是最优解——真正该做的是让长任务变得可感知,这就是第 3 章要讲的流式进度。
如果确实遇到不可压缩的长任务,我的做法是两个方案并行:一是用进度通知让调用方知道“还没死”;二是在协议层面把任务拆成 submit 和 query 两个工具——submit 返回 taskId,query 轮询结果。后者尤其适合执行要几分钟的任务,因为没有任何一个交互式 Client 会干等那么久。
3. 流式输出:把“我还在干活”变成可见的进度
3.1 MCP 里的“流式”到底指什么
提到流式输出,做过 AI 应用的人第一反应是 SSE 打字机效果。但 MCP 里的流式不完全等价于这个。在 MCP 协议里有两类流式相关能力,需要区分清楚。
第一类是进度通知(notifications/progress)。Server 在执行耗时工具时,主动向 Host 推送进度。这是 MCP 原生的、跨 Client 支持度最好的流式手段。Cursor、Claude Desktop 这类 Host 会把它渲染成进度条或“正在执行”的状态。
第二类是响应本身的流式传输。这依赖传输层。stdio 传输下,一次 tools/call 就是一次输入和一次输出,Server 可以多推送通知,但最终结果还是一次性 JSON;在 Streamable HTTP 传输下,Server 可以借助 SSE 把事件逐个推给 Client,配合进度通知形成接近实时的体验。注意新实现里不要把“进度通知”和“普通 SSE 消息”混为一谈,前者走 MCP 协议内的 notifications/progress,后者是 HTTP 层面的传输机制。
3.2 实现进度通知:理解 progressToken 是关键
SDK 里做进度通知并不复杂,核心是 progressToken。它是 Host 在发起请求时通过 _meta 字段带过来的,Server 必须在后续的进度通知里原样带上这个 token,Host 才能把进度关联到正确的请求上。
真实代码长这样:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; interface ProgressMeta { _meta?: { progressToken?: string | number }; } const server = new McpServer({ name: "demo-server", version: "1.0.0" }); server.tool( "sync_large_dataset", { batchSize: z.number().int().min(1).default(100) }, async (args, extra) => { const token = (extra.request.params as ProgressMeta)?._meta?.progressToken; if (token === undefined) { console.error("客户端未提供 progressToken,无法推送进度"); } const total = 10; for (let i = 1; i <= total; i++) { await new Promise((resolve) => setTimeout(resolve, 500)); if (token !== undefined) { await extra.server.notification({ method: "notifications/progress", params: { progressToken: token, progress: i, total, message: `正在处理第 ${i}/${total} 批数据`, }, }); } } return { content: [{ type: "text", text: "数据集同步完成" }] }; } );三个细节要特别注意。
一是 progressToken 不是必然存在的。有些比较老的 Client 或者直接拿 curl 调用请求就没有 _meta,你的代码要能容忍这个字段缺失,否则会抛异常。
二是进度通知用的是 server.notification(),不要把这个方法 return 给 SDK 当工具结果。我早期写错过,return 了一个 notification 的返回值,SDK 直接把它当工具输出序列化,Client 收到一堆协议内部字段,解析直接崩。
三是 message 字段不是所有 Host 都会展示。别把关键业务信息只放在进度消息里,最终结果一定要完整返回,进度通知只是辅助反馈。
3.3 Server 如何感知调用方已经取消
健壮的长任务,光会推进度还不够,还要能处理取消。MCP 协议本身提供取消机制,SDK 里体现在 extra.signal 上。
我在跑批任务里是这样用的:
server.tool( "batch_process", { items: z.array(z.string()).max(100) }, async ({ items }, extra) => { const results: string[] = []; for (const [index, item] of items.entries()) { if (extra.signal?.aborted) { return businessError( "TASK_CANCELLED", `任务在第 ${index + 1} 个元素处被用户取消`, false ); } results.push(await processItem(item)); await notifyProgress(extra, index + 1, items.length); } return { content: [{ type: "text", text: JSON.stringify(results) }] }; } );这里有个取舍:收到取消信号时,是 throw 还是返回业务错误?我的经验是:用户主动取消的场景,返回带 TASK_CANCELLED 的业务错误比 throw 更好。因为 Agent 能读到“任务被取消了”这个语义,并追问用户是否要回滚或继续;直接 throw 协议错误的话,Agent 拿到的信息更粗糙,只能笼统告诉用户“出错了”。
3.4 进度通知在真实 Client 里的表现差异
这块直接说实测结论。Cursor 对进度通知的支持比较好,开发面板里能看到工具调用过程和进度更新;Claude Desktop 相对保守,进度条不一定每次都渲染,但不会报错。如果你用的是自研 Host,需要自己解析 notifications/progress 事件并更新 UI。
另外,远程 HTTP 传输下还要防一层:反代对 SSE 的干扰。Nginx 默认会缓冲,导致 Server 推送的事件攒一批才到达 Client,看起来就像“假流式”。解决办法是在代理配置里关掉缓冲:
location /mcp { proxy_pass http://127.0.0.1:3001; proxy_set_header Connection ''; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; chunked_transfer_encoding off; }这个坑我踩得很惨:本地连远程 Server 调试,进度通知 30 秒才刷一次,我还以为是代码逻辑问题,查了半天,最后发现是 Nginx 缓冲。所以调试远程 MCP 期间,建议先直连 IP 排除代理因素,这是最省时间的排错顺序。
4. TypeScript 开发:把协议边界“焊死”在类型里
4.1 项目结构与 SDK 选型
我选 TypeScript 作为主语言,原因很简单:MCP 官方 SDK 对 TypeScript 支持最成熟,zod schema 能直接把协议类型和运行时校验打通,一份定义多处使用。项目结构推荐这种:
mcp-server/ ├── src/ │ ├── index.ts // 入口:创建 server 并选择传输层 │ ├── tools/ // 所有工具定义 │ │ ├── work-order.ts │ │ └── user-search.ts │ ├── errors.ts // 业务错误工具函数 │ ├── clients/ // 调用外部 API 的客户端封装 │ └── utils/ ├── package.json ├── tsconfig.json └── Dockerfile依赖其实就三样:@modelcontextprotocol/sdk、zod、tsx(开发期跑 TS)。构建用 tsup,它可以同时产出 ESM 和 CJS,后面 Docker 部署会用到。
有一个新手特别容易掉进去的坑:不要在业务代码里用 console.log 打日志。stdio 传输模式下,stdout 是 MCP 协议数据的专用通道,任何 console.log 输出都会污染 JSON-RPC 通信,直接导致 Client 解析失败。所有日志必须走 stderr(console.error),或者在 HTTP 传输下把协议日志和业务日志分离。
4.2 zod schema:一份定义同时搞定校验和类型
server.tool() 的第二个参数就是参数 schema,它成了连接运行时与类型的桥梁。SDK 会根据 schema 自动做参数校验,不合法直接返回 InvalidParams,不需要你手写 if。同时 TypeScript 能从 schema 推导出 handler 的参数类型:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; const SearchUserParams = z.object({ keyword: z.string().min(1).max(50), department: z.string().optional(), limit: z.number().int().min(1).max(100).default(20), }); // args 的类型由 SearchUserParams 自动推导 server.tool("search_user", SearchUserParams.shape, async (args) => { // args: { keyword: string; department?: string; limit: number } const users = await userClient.search(args); return { content: [{ type: "text", text: JSON.stringify(users) }] }; });从真实开发经验看,有两点优先级很高。
第一,schema 要尽量严格表达约束。min、max、enum、regex 都值得写。这些约束不仅是校验,更是给调用方的“使用说明书”。很多模型会从 schema 约束里学会修正自己生成的参数——比如看到 limit 最大 100,下次它就知道要分页。
第二,schema 不要过度嵌套。MCP 工具参数本质上是 JSON,嵌套过深会让模型生成参数的出错率明显上升。我一般把复杂度控制在两层以内,特别复杂的结构用字符串传 JSON,Server 内部再做解析。
4.3 输出类型也要定下来:别让每个工具返回结构都不一样
很多开发者只定义入参 schema,返回类型完全放飞。这在协议层确实没问题(content 是自由文本),但当你自己的工具变多之后,就会发现“每个工具返回结构都不一样”会让调用方很难统一处理。我给每个工具定义统一的输出类型:
type ToolResult = | { ok: true; data: unknown; } | { ok: false; error: { code: string; message: string; retriable: boolean }; };返回前统一用工具函数包一层。好处是 Agent 拿到的永远是一致的结构,成功失败的判别模式稳定。如果你对接的模型有 tool-use 能力,你会发现稳定结构对模型影响极大——它不用每次去“揣摩”你的工具返回的到底是成功还是失败,决策链路会快很多。
4.4 TypeScript 版本升级的那些“劫”
写 MCP server 的多数人是 2024 年后才把旧项目切到较新 TypeScript 版本,版本升级的痛我太有体会了。最近的典型例子是 TypeScript 7.0 里对 compilerOptions.baseUrl 的弃用警告。
很多老项目的 tsconfig.json 里写着 baseUrl: ".",然后 import 全从根路径写,比如 import { x } from "src/utils/xxx"。TS 7 计划移除这个选项后,这类写法全部要改成相对路径或改用 paths 映射。我的做法是趁早做一次全局改造:把 baseUrl 删掉,import 全部改相对路径,用编译器报错逐文件消。拖得越晚,项目越大越难改。
同批还有一个高频问题:“vue 类型工具与 typescript 7 不兼容”之类。这种问题在 MCP server 项目里其实不太常见(纯 Node 后端),但它提醒了一个通用原则:升级 TypeScript 大版本前,先用官方迁移工具跑一遍,再全量更新依赖。我有过一次惨痛经历:为了新增一个类型特性把 SDK 和 TS 一起升了,结果第三方类型包发布滞后,项目里冒出一大堆类型报错,只能临时用 any 压下去。这种技术债后面偿还的成本非常高。
4.5 用 MCP Inspector 代替“配客户端调试”
TypeScript 开发阶段最大的痛点,是不想每次改代码都去重启 Cursor 或 Claude Desktop 来验证。MCP 官方提供的调试工具 MCP Inspector 能解决这个问题。它本质上是一个本地 Web 界面,可以加载你的 stdio server,也可以连接远程 HTTP server,直接浏览工具列表、调用工具、查看原始请求和响应。
用法很简单:
npx @modelcontextprotocol/inspector node dist/index.js启动后浏览器打开它给出的地址,就能看到 tools/list 和 tools/call 的完整交互。我在开发时基本全用 Inspector 做单测,只有端到端验证才挂真实 Client。这套流程把“配置一堆 Client 才能调试”的周期从几分钟压缩到几秒。
5. 部署:从本地 stdio 到远程 HTTP 服务的完整链路
5.1 本地场景:stdio server 在 Client 里的配置方式
开发阶段优先用 stdio。写好的 Server 编译后,在 Client 配置里指向启动命令。以 Cursor 为例,Claude Desktop 也是类似的 JSON 写法:
{ "mcpServers": { "my-server": { "command": "node", "args": ["/absolute/path/to/dist/index.js"], "env": { "API_TOKEN": "xxx" } } } }这里有两个高频问题。第一,路径必须写绝对路径,~ 这种符号不会展开。第二,改完配置必须重启 Client——很多工具不会热加载 MCP server,这是新手最容易困惑的点:配置改了、代码也改了,但 Client 里工具列表还是旧的,因为 MCP server 的进程句柄还是旧的。
5.2 远程场景:Streamable HTTP 传输与 Session 管理
走到生产部署,就需要把 MCP server 从 stdio 切到 Streamable HTTP。为什么官方推荐用它替代早期的独立 SSE 传输?因为 Streamable HTTP 在一个端点里同时处理普通请求和 SSE,结构更简单,对标准 HTTP 中间件(鉴权、日志)兼容性更好。
在 SDK 里切换到 HTTP 模式很直接:
import express from "express"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; const server = new McpServer({ name: "prod-server", version: "1.0.0" }); registerTools(server); const app = express(); app.use(express.json()); let transport: StreamableHTTPServerTransport | undefined; app.post("/mcp", async (req, res) => { if (!transport) { transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, onsessioninitialized: () => {}, }); await server.connect(transport); } await transport.handleRequest(req, res); }); app.get("/mcp", async (req, res) => { if (transport?.sessionId !== req.query.sessionId) { res.status(400).json({ error: "Invalid session ID" }); return; } await transport.handleRequest(req, res); }); app.delete("/mcp", async (req, res) => { if (transport?.sessionId !== req.query.sessionId) { res.status(400).json({ error: "Invalid session ID" }); return; } await transport.handleRequest(req, res); transport = undefined; }); app.listen(3001, () => { console.error("MCP server listening at http://0.0.0.0:3001/mcp"); });这里面最关键的概念是 session。Streamable HTTP 在 POST /mcp 初始化成功后返回一个 sessionId,后续 GET(接收 Server 推送的 SSE 事件)和 DELETE(断开)都要带这个 sessionId。如果你只维护一个全局 transport,多用户并发初始化时会互相覆盖,先建立的连接全部失效。生产环境必须按 sessionId 维护 transport 映射:
const transports = new Map<string, StreamableHTTPServerTransport>();Session 管理是远程 MCP 最早暴露问题的地方。我见过不少“偶尔连不上”“进度丢失”的案例,排查到最后都是 session 生命周期没管好:transport 被覆盖后,旧 session 发来的消息没人处理。更稳妥的做法是给每个会话单独的 transport,并设置空闲过期机制,长期不活跃的 session 及时回收。
5.3 鉴权与环境变量:远程服务的第一道防线
远程 MCP server 本质上是一个公网可达的 API,绝不能裸奔。我至少会做两件事。
第一件,Bearer Token 鉴权。写一个 Express 中间件,检查 Authorization 头是否匹配环境变量里的 MCP_API_TOKEN。token 通过环境注入,不进代码库;Docker 部署时通过 --env 或 secrets 传入。
app.use("/mcp", (req, res, next) => { const expected = process.env.MCP_API_TOKEN; if (!expected) { res.status(500).json({ error: "MCP_API_TOKEN 未配置" }); return; } const auth = req.headers.authorization; if (auth !== `Bearer ${expected}`) { res.status(401).json({ error: "未授权" }); return; } next(); });第二件,环境变量集中管理。社区里搜索热度很高的 Figma MCP、蓝湖 MCP token 获取问题,本质就是环境变量管理问题:用户需要去对应平台申请 token,然后填进 Client 配置的 env。自研 server 也一样,把 token、密钥、内网地址全部抽到环境变量或独立配置文件,严禁硬编码。我会用一个 config.ts 集中读取并做缺失校验,server 启动时 fail-fast,避免跑起来之后才发现缺配置。
5.4 Docker 化部署的完整配置
推荐多阶段构建,目标镜像用 Node 22 Alpine,注意非 root 用户和健康检查。
FROM node:22-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY tsconfig.json tsup.config.ts ./ COPY src ./src RUN npm run build FROM node:22-alpine WORKDIR /app ENV NODE_ENV=production RUN apk add --no-cache tini COPY --from=builder /app/dist ./dist COPY --from=builder /app/package*.json ./ RUN npm ci --omit=dev && npm cache clean --force USER node EXPOSE 3001 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget -qO- http://127.0.0.1:3001/health || exit 1 ENTRYPOINT ["/sbin/tini", "--"] CMD ["node", "dist/index.js"]这里有几个经验。第一,tini 很重要。Node 容器里信号处理很差,没有 tini 的话 docker stop 会把进程直接杀掉而不是优雅退出,MCP server 里正在执行的长任务会被硬生生打断。第二,健康检查建议单独暴露一个 /health 端点,返回 200 即可,不要用 /mcp 当健康检查,因为后者需要完整的 RPC 交互,不适合做存活探测。第三,wget 在 Alpine 里默认就有,不需要额外装 curl。
5.5 日志、监控:别把 MCP server 当“黑盒”
远程服务的可观测性不能靠“重启试试”。我的日志全部输出为 JSON 行,包含 level、time、tool、sessionId、耗时等字段:
function log(level: string, msg: string, meta: Record<string, unknown> = {}) { console.error(JSON.stringify({ level, time: new Date().toISOString(), msg, ...meta })); }监控方面,如果觉得 Prometheus 全套太重,可以先做两个轻量指标:tools/call 成功率和 P95 耗时,日志里统计即可。等请求量上去了再上 Prometheus + Grafana 也不迟。说实话,不少团队把 MCP server 当成“AI 功能的一部分”而忽略监控,这其实是最不该漏掉的——模型会随时调用你的工具,流量模式比人肉用户更不可预测,没有指标在手,出问题就是睁眼瞎。
6. 落地复盘:我在这套 Server 上踩过的真实坑
6.1 进度通知做好后 Client 不展示,不代表白做
我带过的一个项目里,Host 端是自研 Agent 平台。进度通知做完后,界面上没有任何变化,团队一度想砍掉。排查后才发现 Host 端根本没有处理 notifications/progress,把它当成未知事件丢弃了。解决方案是给 Host 端补上对进度通知的解析,渲染到任务详情里。这个案例给我的启发是:进度通知的价值一半在 Server 端,另一半在 Host 端。如果你控制不了 Host,至少保证 Server 端通知的可用性和规范性,等 Host 端支持时不用再改 Server。
6.2 stdio 模式的日志污染,是最隐蔽的事故源
有一次 Client 反复报“连接中断”,代码看起来完全没问题。排查了很久才发现是开发早期我在工具函数里习惯性写了几行 console.log 打调试信息。stdio 传输下 stdout 被协议占用,任何多余输出都会破坏 JSON-RPC 消息边界。修复方法是把所有日志改成 console.error,并加了一条强制约定:业务代码禁止直接调用 console.log。这条规矩建议写进团队规范,能省下后面所有人排查协议诡异问题的时间。
6.3 Client 升级后工具参数类型批量不匹配
某次 Client 升级后,部分工具突然全部报 InvalidParams,但我们的代码完全没改。最终定位发现是 Client 对参数的序列化方式变了——某个字段从 number 变成了 string 返回。这件事让我意识到:MCP 参数校验虽然主要在 Server 端完成,但 schema 的约束也是给 Client 的“约定”。当两边解析不一致时,以 Server 端 SDK 的实际校验结果为准。所以 schema 里的类型约束一定要严格,不能用宽松的 unknown 代替,否则 Client 解析行为一变,你的工具就可能全线报错。
6.4 设计工具类 MCP 的 token 配置:写清楚 README 是关键
社区里关于 Figma MCP、蓝湖 MCP token 获取的搜索量一直很高,这类问题的本质都一样:用户需要去平台申请 token,然后填到 Client 配置的 env 里。自研 server 时,要在 README 和错误信息里写清楚“去哪申请 token、填在哪个环境变量、如何验证”。我见过最离谱的现象是用户把 token 直接写进 MCP server 代码里然后推到公开仓库。这个坑的防护不在技术,在流程:默认 gitignore 所有 .env 文件,CI 里加 secret 扫描。
6.5 工具变多后的代码组织思路
当你的 server 有几十个工具时,把所有 handler 塞在一个文件里完全不可维护。我的做法是每个领域一个文件,文件里导出 register 函数:
// tools/work-order.ts export function registerWorkOrderTools(server: McpServer) { server.tool("create_work_order", {...}, handler); server.tool("update_work_order", {...}, handler); } // index.ts import { registerWorkOrderTools } from "./tools/work-order.js"; registerWorkOrderTools(server);这个模式在代码量增长后优势很明显:新增一个工具只需要新增一个文件,或者给现有文件加一个 register 调用,入口逻辑基本不动。配合每个工具文件内独立的 schema 和输出类型,代码 review 和单测都能精准定位,不会在几百行的出口文件里翻找。
最后分享一个排错技巧。遇到任何 MCP 交互异常,先用 MCP Inspector 从原始协议层面复现,再做上层排查。MCP 分层很清晰,问题要么在协议层(消息格式、session、传输),要么在业务层(工具逻辑)。从协议层开始看,能省掉大量在 Client UI 和业务代码之间来回猜测的时间。这是我在这套自定义 Server 上感触最深的一条经验,也是我想留给你的最实在的收尾建议。