MCP自定义服务器开发进阶:错误处理、流式输出与部署实战
MCP 自定义服务器开发进阶指南我把这套东西从“能跑”打磨到“敢上生产”踩过的坑比写过的代码还多。最近重写了一个商品信息查询 MCP Server正好把错误处理、流式输出、TypeScript 工程化和部署这几个环节完整沉淀了一遍。网上教程大多止步于“三分钟跑通 demo”但真正做线上服务时关键全在边界处理。这篇笔记适合已经写过 MCP Hello World、想让自己的 MCP 服务器更健壮、更可维护的开发者。1. 先理清 MCP Server 的设计边界1.1 MCP 里的 Server 到底负责什么MCPModel Context Protocol全称是模型上下文协议它解决的核心问题是智能体应用怎么用统一方式访问外部工具、数据源和提示词模板。一个 MCP Server 本质上不是业务系统而是适配层。它把你们内部的 REST API、数据库查询、文件读取等能力包装成符合协议的工具、资源和提示词让大模型在对话过程中能按需调用。很多新手上来就写工具忽略了一个关键点MCP Server 同时服务两个“用户”——一个是下游的智能体框架一个是真正在对话的人。框架关心工具能不能被稳定发现和调用人关心结果准不准、响应快不快。所以设计时不能只盯着“调通接口”还要考虑工具描述是否清楚、错误是否能被大模型理解、长任务是否会让用户干等。在 MCP 协议里Server 提供三类核心能力tools 是让模型主动调用的函数适合“查询订单”“生成报表”这类动作resources 是注入上下文的数据适合“商品类目字典”“知识库文档”prompts 是预置的提示词模板适合把一段复杂指令标准化。我这次做的商品信息查询服务tools 用于查库存和生成摘要resources 用于给模型提供类目说明prompts 用于快速生成运营周报。三者职责清晰模型的行为才会可控。1.2 为什么我选 TypeScript 而不是 Python 或 GoMCP 官方提供了 TypeScript 和 Python 两套 SDK两边都有成熟社区。我最终选 TypeScript有几个现实原因我们团队智能体前端和编排层本来就是 Node 生态共用一套类型定义能减少沟通成本TS SDK 对 zod 校验支持得非常好工具入参可以用 zod 直接生成 JSON Schema这对模型理解参数非常重要另外MCP 的 SDK 升级节奏比较快TypeScript 版本的 API 文档和示例相对更齐全遇到问题搜资料也更快。这不是说 Python 不好如果你的 Agent 平台是 Python 技术栈或者工具需要重度调用 AI 库那么 Python SDK 同样合适。但本文所有代码和踩坑记录都以 TypeScript 为背景。有一点要提醒TS SDK 默认倾向 ESM 模块老项目如果用 CommonJS引入时可能会遇到加载问题新项目建议直接走 ESM。1.3 开始写代码之前先规划四件事动手前我通常会列一个检查清单避免写完再返工。第一是工具粒度。一个 MCP 工具最好不要做太多事。“获取商品库存”和“批量同步商品库存”是两种不同粒度的工具前者适合快速查询后者适合长任务。粒度太大模型难以复用粒度太小上下文里塞满各种工具定义模型选错工具的几率反而升高。第二是输入校验。模型填参数时经常会出现类型错误、缺字段、传空字符串甚至把仓库编码写错。zod schema 就是第一道防线它要保证必填项必须有、类型必须正确、允许枚举的字段要在枚举内。第三是错误语义。不要把所有异常都统一返回“操作失败”。用户在聊天界面看到“操作失败”时真的会崩溃。错误信息应该区分“参数不合法”“上游服务暂时不可用”“权限不足”并且要让大模型能从错误文本中理解接下来该怎么做。第四是传输方式。开发阶段一般用 stdio 传输客户端直接拉起本地进程生产环境通常要改成 HTTP/SSE 或 Streamable HTTP部署到服务器上给多个 Agent 共享。我建议从第一天就把传输封装成可配置的避免开发完再大改。2. 错误处理别把异常堆给客户端2.1 理解 MCP 的 JSON-RPC 错误模型MCP 协议底层是 JSON-RPC 2.0错误响应有固定的结构code、message、data 三个字段。我见过太多人直接把底层系统的异常堆栈抛出去结果客户端看到一堆英文敏感信息模型也没法根据这些内容进行下一步操作。协议层错误码是固定的这部分需要记住-32700 表示 JSON 解析错误-32600 是无效请求-32601 是方法不存在-32602 是参数非法-32603 是内部错误。自定义的服务器错误应该从 -32000 到 -32099 之间取不要占用协议保留段。错误码含义常见触发场景-32700解析错误stdio 传输时消息不是合法 JSON-32600无效请求缺少 jsonrpc 或 method 字段-32601方法不存在客户端调用了一个未注册的 tools-32602参数无效工具入参缺少必填字段或类型不匹配-32603内部错误工具执行过程中发生未捕获异常-32000 到 -32099自定义服务器错误业务侧主动抛出的错误这里有一个非常实用的经验不是所有错误都要抛给客户端。你要想清楚一个场景——智能体拿到一个 JSON-RPC 错误后它只能看到 message 和 data这些信息会影响它接下来选工具、换策略。所以“参数不合法”这类错误更适合直接放在工具返回的文本内容里返回让模型看到“你传入的 SKU 格式不正确请以 A100 开头”模型就可以自动修正参数再试一次。而真正的系统级异常才应该抛给协议层。2.2 工具执行层的错误处理实践我这次重写工具层时给所有工具调用包了一层统一的错误处理函数。核心逻辑是能从异常中恢复的错误转成用户可以理解的文本结果返回不可恢复的错误转成 McpError 抛出并带上操作上下文。import { McpError, ErrorCode } from modelcontextprotocol/sdk/types.js; interface SafeCallOptions { context: string; log?: (level: string, msg: string, data?: unknown) void; } async function safeCallT( fn: () PromiseT, options: SafeCallOptions ): PromiseT { try { return await fn(); } catch (err) { if (err instanceof McpError) { throw err; } const message err instanceof Error ? err.message : String(err); options.log?.(error, [${options.context}] ${message}); throw new McpError( ErrorCode.InternalError, [${options.context}] ${message}, { hint: 请稍后重试或联系服务负责人 } ); } }这里有几个细节要注意。McpError 的第三个参数 data 可以放结构化信息比如错误码、重试建议、关联的 operationId。客户端如果实现得足够好可以把 data 里的内容展示成更友好的提示而不是只把 message 怼给用户。我把每次调用的上下文写在 message 里比如[query_inventory] 库存服务连接超时这样模型可以明确知道是哪个环节出了问题。另外工具回调中如果依赖了上游 HTTP 接口一定要设置超时不能无限等待。Node 默认没有请求超时我用 AbortController 统一管理至少保证工具能在规定时间内返回不会把整个 Agent 的响应拖死。2.3 资源和提示词读取的异常怎么处理很多人只把错误处理放在工具上忽略了 resources 和 prompts 的异常。实际上如果 resource 读取时抛异常可能导致整个初始化或上下文注入失败。我的做法是resource 读取失败时返回一个空内容的结果同时在服务端日志记录详细原因。对于文本类 resource可以返回“数据暂时不可用”至少让模型知道这段上下文缺失了。prompts 的异常处理同理。prompt 本质上是一段模板如果模板渲染报错多半是必要参数没传全。这种情况应该在错误信息中明确指出缺少哪个变量而不是抛一个晦涩的模板语法错误。还有一点日志别用裸的 console.log 一通输出。至少要做结构化日志我会把日志统一成一行 JSON包含 timestamp、level、toolName、operationId、durationMs、errorCode。每次工具调用生成一个 operationId客户端报错时只需要带上这个 ID我就能在日志里快速定位到那一次完整调用链路。这个习惯在排查生产问题时帮了我大忙。2.4 错误处理最容易踩的三个坑第一把所有业务错误都抛成 InternalError。结果就是模型永远拿不到有效信息只能一遍遍重试同一个错误工具白白消耗 token。正确做法是把能恢复的错误写进工具返回文本只有系统级错误才抛异常。第二把内部服务的连接信息拼在错误信息里。比如 “Connection refused at 10.0.0.1:5432”这种信息如果被模型当上下文再传给其他工具或者出现在日志里都存在泄露风险。我通常会脱敏只保留服务名不暴露 IP 和端口。第三忽略取消信号。客户端发起工具调用后如果用户手动取消了操作服务端还在傻傻地跑任务结果就是把无效结果硬塞给一个已经关掉的连接。后续在流式输出部分我会细讲如何用 AbortSignal 做端到端取消。3. 流式输出让智能体交互不再“卡住”3.1 先澄清一个误区MCP Server 流式输出到底是什么很多刚接触 MCP 的开发者会问我的工具能不能像大模型一样流式吐字这里要分清楚MCP Server 本身不是推理服务它不能像 LLM 那样逐 token 输出。MCP 的“流式输出”主要体现在三个层面长任务执行中的进度通知、HTTP 传输层的 SSE 事件推送、以及通过异步任务模式分阶段返回结果。我用一个具体场景来解释。我做的商品信息服务器里有个“批量同步库存”工具上游系统一次同步可能要跑几十秒甚至几分钟。如果 Agent 调用这个工具后只能干等用户的体验就是对话框转圈。更合理的做法是工具启动后立刻返回“同步任务已开始taskIdabc123”之后通过进度事件或者另一个状态查询工具持续告诉客户端“已处理 1000 条”“已处理 2000 条”最后再返回完整结果。这三个层面并不是只能选一个。最稳妥的方案是组合使用MCP 协议层面的进度通知负责实时反馈短连接或 SSE 事件负责让客户端能持续监听异步任务架构负责解耦长耗时逻辑。3.2 TypeScript 中实现进度通知MCP 协议定义了进度通知机制。在 TypeScript SDK 中工具回调的第二参数会传入一个包含控制能力的对象。我在项目里封装了一个 progress helper通过可选链调用防止 SDK 升级后方法名变化导致编译失败。server.tool( batch_sync_products, { warehouse: z.string().optional() }, async ({ warehouse }, extra) { const sendProgress extra.progress ?? (async () {}); await sendProgress(0.1, 开始拉取商品清单); const items await fetchProductList(warehouse); await sendProgress(0.4, 共拉取 ${items.length} 个商品); const reported await reportToUpstream(items); await sendProgress(0.8, 正在生成同步结果); const summary summarize(reported); await sendProgress(1.0, 同步完成); return { content: [{ type: text, text: JSON.stringify(summary) }] }; } );这段代码解决的最核心问题是让客户端能感知“工具还活着而且知道它进行到哪一步了”。我在实际使用中发现一个简单的进度条对用户心理的安抚作用非常大尤其是上游接口偶尔要等 15 秒以上的场景。要注意的是进度事件不应该发得太密集。如果每秒发几十条进度客户端和协议层都会产生不必要的开销。我在代码里做了节流最早也要 500 毫秒才发一条整个任务最多 20 条进度信息足以覆盖用户感知又不会刷屏。3.3 取消、超时与背压长任务的拦路虎流式输出的最大敌人不是慢而是无纪律。我碰到的第一个问题是客户端已经取消了任务服务端却还在跑。后来我在每个工具回调里拿到 AbortSignal然后在长任务循环中每隔一段就检查一次。server.tool( generate_sales_report, { month: z.string() }, async ({ month }, { signal }) { if (signal.aborted) { throw new McpError(ErrorCode.RequestCancelled, 报表生成已取消); } const chunks []; for (const part of [订单, 品类, 渠道]) { if (signal.aborted) { throw new McpError(ErrorCode.RequestCancelled, 报表生成已取消); } chunks.push(await calcPart(month, part)); } return { content: [{ type: text, text: renderReport(chunks) }] }; } );这只是一个示例。真正的工程里工具内部还要处理“取消链”如果工具内部调用了上游 HTTP 接口HTTP 请求也要绑定同一个 AbortSignal这样客户端取消时会从 Agent 一路传递到 MCP Server再传递到上游服务。整条链路一起停而不是只停最外层。超时也是一种背压。一个工具不是等得越久结果越好超过合理时间结果大概率是错的。我在服务端给每类工具都设了不同的超时时间查询类 10 秒同步类 60 秒报表类 120 秒。超时之后工具抛出 InternalError并提示客户端“请在任务中心查看异步进度”。这一步很重要它把超时从异常变成了可恢复的流程。SSE 推送时还要考虑背压。如果服务端推送给客户端的消息太多太快而客户端处理不过来连接缓冲会增长最后内存溢出。我的策略是事件按重要性分级进度事件只保留最近一条关键节点事件才逐条推送。3.4 流式输出与异步任务如何搭配我这次最有价值的重构是把所有可能耗时超过 20 秒的工具全部改成了异步模式。具体做法是提供三个配套工具——start_batch_sync用于启动任务并返回 taskIdget_sync_status用于查询进度get_sync_result用于获取最终结果。模型的调用流程从“一个长任务”变成了“启动-轮询-取结果”的标准三步。方案优点缺点适用场景同步长任务实现简单协议天然契合用户体验差易超时耗时在几秒内的工具进度通知用户体验好实时性强无法处理“执行到一半崩溃”耗时较长且有明确阶段异步任务三步式最稳定可恢复可审计需要多一次状态查询耗时超过 20 秒的任务我在生产环境里的组合是耗时短的查询工具走同步中等耗时工具走进度通知超过 30 秒的同步任务走异步三件套。这套组合让 Agent 不再因为某个工具长时间无响应而被整体卡死。这里再强调一个实操细节异步任务的结果要设置合理的过期时间。我设置的是 24 小时超过这个时间get_sync_result会返回“结果已过期请重新发起任务”。这样避免任务结果无限堆积把存储打爆。4. 用 TypeScript 开发 MCP Server 的工程细节4.1 SDK 选型与项目初始化官方 TypeScript SDK 的包名是modelcontextprotocol/sdk。新项目我建议直接跟随官方的 ESM 模式避免 CommonJS 带来的兼容性问题。初始化步骤其实很少mkdir mcp-inventory-server cd mcp-inventory-server npm init -y npm install modelcontextprotocol/sdk zod npm install -D typescript tsx types/node npx tsc --initpackage.json 里需要设置type: moduletsconfig 的几个关键项要按照 Node ESM 的要求配{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: dist, rootDir: src, strict: true, sourceMap: true } }不要小看sourceMap: true。生产环境跑容器时报错堆栈如果全是 dist 目录压缩后的代码排查起来非常痛苦。打开 sourceMap部署时连--enable-source-maps一起用报错行号能直接指到 src 下的原文件这条路我在第 5 节部署里还会再提。SDK 运行时分成两层modelcontextprotocol/sdk/server/mcp.js里的 McpServer 是高级封装适合快速注册工具modelcontextprotocol/sdk/server/index.js里的 Server 是底层类适合需要深度控制协议的场景。我建议大多数人直接用 McpServer它能帮你省掉大量协议细节。4.2 注册工具、资源和提示词用一个最小示例展示三种能力的注册方式import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { z } from zod; const server new McpServer({ name: inventory-server, version: 1.2.0 }); server.tool( query_inventory, { sku: z.string().min(1).describe(商品SKU例如 A100-01), warehouse: z.enum([east, west]).optional().describe(仓库编码) }, async ({ sku, warehouse }) { const result await queryStock(sku, warehouse); return { content: [{ type: text, text: JSON.stringify(result) }] }; } ); server.resource( schema://products, 商品类目字典, async (uri) ({ contents: [{ uri, text: JSON.stringify(categoryDict) }] }) ); server.prompt( weekly_report, 生成商品运营周报, async () ({ messages: [ { role: user, content: { type: text, text: 请根据我的商品数据生成一份运营周报重点分析库存周转和缺货情况。 } } ] }) );zod 的 describe 方法值得多说一句。SDK 会把 zod schema 转成 JSON Schema而大模型读取工具定义时主要就是看这个结构。你在 describe 里写“商品SKU例如 A100-01”效果远好过一个粗暴的 string 类型。这相当于你在教模型怎么填参数填错的概率会大幅下降。resources 的 URI 协议后缀一开始觉得不习惯比如schema://products但它可以让你设计出类似 REST 路径的层级结构。prompts 则适合固化一些业务分析模板让不同部门用同一种口径生成报告。4.3 类型定义与数据边界别在类型上偷懒TypeScript 的优势就是类型但前提是你真的用了它。我见过很多 MCP Server 的工具回调里只用 any 接收参数这等于把 TS 最强的防线给拆了。我给业务数据都定义了明确的接口interface InventoryItem { sku: string; stock: number; warehouse: string; updatedAt: string; } interface QueryResultT { total: number; items: T[]; }工具返回的内容必须是纯 JSON 对象。MCP 传输层要序列化结果Date 会变成字符串BigInt 会直接抛错undefined 字段会被丢掉。所以我在工具内层就完成转换保证返回的永远是可序列化的普通对象。类型还有一个容易被忽视的作用约束工具 schema。注册工具时 schema 和实际的参数类型如果对不上模型在那里传了半天类型回调里却完全不校验会出现“工具调用成功但结果异常”的诡异问题。我的建议是schema 即契约回调里的第一个参数类型应该和 schema 严格对应。4.4 测试不只靠 MCP Inspector官方提供 MCP Inspector一条命令就能把本地服务器拉起来可视化调试npx modelcontextprotocol/inspector node dist/index.js这个工具能查看工具列表、调用工具、查看原始 JSON-RPC 消息是我开发阶段的主力。但它不适合自动化回归。我后来又写了一个基于 stdio 的冒烟测试脚本核心思路是手动模拟客户端发三条 JSON-RPC 消息initialize、notifications/initialized、tools/list。printf %s\n%s\n%s\n \ {jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2024-11-05,capabilities:{},clientInfo:{name:smoke,version:1.0.0}}} \ {jsonrpc:2.0,method:notifications/initialized} \ {jsonrpc:2.0,id:2,method:tools/list} \ | node dist/index.js如果这个命令能正常列出工具说明服务器至少能完成基础握手。我在 CI 里把这条命令作为每次提交后的最小回归一旦工具注册时报错构建阶段就能发现不用等部署到生产才爆炸。如果是 HTTP 传输还可以直接用 curl 请求 endpoints 做连通性测试。但 stdio 的冒烟测试更接近协议原始形态排查问题时更高效。5. 部署从本地到生产环境5.1 部署形态怎么选stdio、SSE 还是 Streamable HTTPMCP Server 的部署形态直接影响运维复杂度我建议先想清楚使用场景。stdio 传输模式下MCP Server 是被客户端当子进程拉起的。好处是零网络暴露、协议简单、开发调试直观坏处是每次客户端启动都要拉起一个进程做不到多客户端共享也不适合远程调用。这种模式适合 Claude Desktop、本地 IDE 插件这类桌面级应用。HTTP/SSE 和 Streamable HTTP 传输模式是把 MCP Server 变成一个独立的 HTTP 服务。这样多个客户端可以共用同一个服务可以部署到服务器、挂负载均衡、接监控。代价是要自己处理鉴权、CORS、限流、进程守护这些问题。传输方式部署方式典型场景优点注意事项stdio客户端本机进程桌面客户端、本地 Agent安全、简单无法远程、无法共享SSE over HTTP独立服务远程 Agent 平台可共享、可独立扩缩容需要鉴权和 CORS 配置Streamable HTTP独立服务新版本协议兼容 SSE支持传统 HTTP 语义需要确认客户端版本支持我这次是把服务分为两种启动模式本地开发默认 stdio线上通过环境变量切换到 HTTP 传输。两种模式共用业务代码只有入口处不同。这样开发时体验本地调试的便利线上又能获得独立服务的好处。5.2 用 Docker 构建最小可发布镜像生产环境我不建议裸奔 node 进程。容器化是必须的但镜像别做太大也别把源码的构建环境带进生产。我用的多阶段构建方式FROM node:20-alpine AS builder 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 --frombuilder /app/dist ./dist COPY --frombuilder /app/tsconfig.json ./ USER node EXPOSE 3000 CMD [node, --enable-source-maps, dist/index.js]这里每一步都有讲究。builder 阶段用npm ci而不是npm install保证依赖版本完全可复现。运行阶段npm ci --omitdev只装生产依赖镜像体积能小很多。COPY package*.json放在COPY src之前充分利用 Docker 缓存层业务代码频繁改时不会每次重装依赖。最后从 node 官方镜像切换到非 root 用户运行用默认的 node 用户而不是 root减少容器逃逸后的风险。如果服务需要连接数据库镜像里不需要装任何数据库客户端应用服务通过环境变量配置连接串外部依赖解耦干净。5.3 进程管理与环境变量别再用 nohup容器部署下进程管理交给 Docker 的 restart 策略即可。但如果你还没完全容器化而是在虚机上用 nohup 或裸 node 跑那一定要换成 pm2 或 systemd 托管。单纯 nohup 遇到崩溃后不会自动拉起半夜服务挂了你都不知道。pm2 配置建议pm2 start dist/index.js --name mcp-inventory --max-memory-restart 512M pm2 save pm2 startup--max-memory-restart 512M是防止内存泄漏拖垮整个机的保险。日志用 pm2 自带的 logs 功能并配置日志轮转。环境变量管理上不要硬编码任何密钥。本地开发用 .env生产环境我放在 Docker 的 environment 配置里更敏感的信息通过 docker secrets 或配置中心注入。至少要把数据库连接串、上游 API Key、服务端口、日志级别都做成环境变量做到“代码不动配置可变”。5.4 安全加固MCP Server 是 Agent 的手MCP Server 本质上是把外部能力暴露给大模型代理安全边界一定要收敛。我给自己定的几条铁律第一最小权限。查询类工具只做查询不提供删除和全量导出能力。工具内部即使有权限去操作数据库也不要让模型通过一个参数就触发“删除全部”这类危险操作要么单独拆工具要么走二次确认。第二输入校验不可跳过。zod 是防线但校验规则一定要用严格模式。比如字符串长度、枚举值、数字范围都要定义好不要让模型传入一个超长 SQL 文本然后被你直接拼进数据库查询。第三HTTP 传输必须有鉴权。最基础也要校验 API Key更稳的是按客户端分发不同 Key方便审计谁在调用。CORS 配置只允许受信任的前端域名不要用通配符。第四限流。给每个工具设置 QPS 上限尤其那些会打上游系统或数据库的查询。一次 Agent 的编排循环里模型可能会反复调用同一工具没有限流时一个用户就能拖垮上游服务。第五审计日志。对“生成报表”“批量同步”这类关键工具除了正常日志还要记录调用者、参数、耗时、结果状态。出了问题可以回放当时的完整调用链。5.5 监控、日志和版本更新策略HTTP 部署形态下健康检查是必须有的。我用一个/healthz端点返回服务进程状态和关键依赖的连通性。Dockerfile 里可以加 HEALTHCHECK但更推荐在编排平台配置健康检查探针。指标采集用 prom-client 暴露metrics端点至少要采集工具调用次数、工具错误率、工具耗时分布、MCP 连接数。有了这些指标再配置告警规则比如“错误率超过 5% 持续 5 分钟”就报警比等用户投诉强一百倍。版本更新方面MCP Server 的接口和常规 REST API 一样要遵守兼容性。新增工具不删除旧工具工具改名时保留旧别名至少两个版本schema 变更尽量做到新增参数可空。我吃过一次亏把query_inventory的warehouse参数从可选改成必填结果所有旧客户端全部调用失败。从那以后接口变更一律走“先新增后废弃再删除”的节奏。6. 常见问题与排查技巧实录6.1 客户端连接后工具列表为空这是新手上线遇到最多的问题。服务启动了客户端也连上了但工具列表始终是空的。原因一般有三类注册工具时报错被吞、编译产物是旧的、stdio 握手顺序不对。先用最原始的方式排查直接执行node dist/index.js手动发 initialize 和 tools/list 请求看服务端是否有输出。如果 tools/list 返回空数组说明注册代码有 bug很可能是某个 zod schema 抛了异常。如果返回正常说明是客户端配置问题。我用一个经验法则先绕过所有客户端用脚本直连 MCP Server确认 Server 本身正常再去查客户端配置。6.2 流式输出中断或乱序HTTP/SSE 场景下流式输出中断九成和代理有关。如果服务前面挂了 Nginx需要关闭代理缓冲location /sse { proxy_pass http://mcp-server; proxy_buffering off; proxy_cache off; proxy_set_header Connection ; proxy_http_version 1.1; }乱序问题则通常出在进度事件。服务端如果并发推送多条进度客户端可能按到达顺序显示而不是按发送顺序。我后来在每条进度事件里都带一个递增的 sequence 字段客户端渲染前先按 sequence 排序从源头避免乱序。6.3 部署后进程频繁重启进程反复重启优先看启动日志。最常见的三类原因环境变量没配齐、端口被占用、内存超额被杀。Docker 环境中docker logs是第一排查入口。如果是内存问题docker stats能直观看到容器内存占用结合 pm2 的--max-memory-restart或者 Docker 的 memory limit 一起调整。我踩过一次比较隐蔽的坑服务启动时依赖数据库连接数据库没起来应用反复重启。后来在启动逻辑里加了“等待依赖就绪”的步骤并把健康检查探测时间拉长才稳定下来。6.4 错误信息被客户端吞掉怎么办很多客户端对 MCP 工具错误的处理很粗暴只展示 error.message 的一小段。此时即使服务端返回了很完整的 data 信息用户也看不到。我的解决方法是把关键信息同时拼到 message 里。不要只写“调用失败”要写成“调用失败原因库存服务连接超时本次操作编号 op_8f3a请稍后重试”。这样即使用户只看 message也知道发生了什么、怎么处理、怎么反馈问题。另一个技巧如果错误本身是“可引导的”比如参数格式错误直接写清楚给模型看。模型能读工具返回的文本内容会根据错误信息自动调整参数重试。所以对于参数类错误我通常不抛协议异常而是返回一段包含“正确格式示例”的文本结果。说实话把 MCP Server 写到“能跑”不难难的是在错误处理、流式输出和部署这些边界问题上稳定落地。我这次重写最大的体会是要把工具当成对外 API 设计错误信息必须人机可读长任务必须要有进度和取消机制日志必须从一开始就结构化。开发阶段用 stdio 快速调试线上放到容器里走 HTTP 传输看起来前期多写了一倍代码后续维护省下的时间远不止这点。最后分享一个我踩过多次坑之后养成的习惯每次升级 SDK 前先跑一遍工具列表冒烟测试再回归几个核心工具的调用。MCP 生态更新很快很多行为在版本之间会变“能跑”只是起点“稳定跑”才是真正要维护的资产。