MCP Server 生产级开发:错误处理、流式进度与部署实践 📅 发布时间:2026/9/18 17:38:40 👁 浏览次数: 说实话过去半年我身边几乎所有做 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 理解成一个库或一个工具这是最大的误区。MCPModel 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 拿到错误后会判断要不要重试retriabletrue 的情况限流、上游 5xx可以带退避重试retriablefalse 的情况鉴权过期、参数业务性错误重试没有任何意义应该引导用户人工介入。你在返回文本里最好也写上建议动作比如“请先更新环境变量中的 API token”模型会把这句话转述给用户体验会好很多。还有一点很多人会忽略不要把系统内部的敏感信息通过错误内容暴露给模型。堆栈、内网地址、SQL 片段都不能出现在返回里。模型会原样转述给用户也可能被记进对话历史。正确做法是在 server 层统一做异常脱敏再返回给 Host。2.4 长任务超时不该靠“报错”来解决最后一个和错误强相关的话题是超时。工具执行时间一长各种问题都会冒出来Client 侧可能设了超时、反代可能掐连接、用户可能以为卡死了。这时候你会发现“报错”并不是最优解——真正该做的是让长任务变得可感知这就是第 3 章要讲的流式进度。如果确实遇到不可压缩的长任务我的做法是两个方案并行一是用进度通知让调用方知道“还没死”二是在协议层面把任务拆成 submit 和 query 两个工具——submit 返回 taskIdquery 轮询结果。后者尤其适合执行要几分钟的任务因为没有任何一个交互式 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 必须在后续的进度通知里原样带上这个 tokenHost 才能把进度关联到正确的请求上。真实代码长这样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 解析失败。所有日志必须走 stderrconsole.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嵌套过深会让模型生成参数的出错率明显上升。我一般把复杂度控制在两层以内特别复杂的结构用字符串传 JSONServer 内部再做解析。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 Mapstring, 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_ENVproduction RUN apk add --no-cache tini COPY --frombuilder /app/dist ./dist COPY --frombuilder /app/package*.json ./ RUN npm ci --omitdev npm cache clean --force USER node EXPOSE 3001 HEALTHCHECK --interval30s --timeout5s --start-period10s --retries3 \ 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: Recordstring, 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 上感触最深的一条经验也是我想留给你的最实在的收尾建议。