MCP Server进阶实战:错误处理、流式输出与TypeScript工程化部署 说实话很多人把 MCP server 写到“能跑通”就停了。但你把 server 交给真实用户、接到 Cursor、接到自己的 Agent 框架里问题就全来了调用失败客户端只会看到一个干巴巴的 error耗时工具跑几秒都没反馈用户以为卡死了TypeScript 类型到处 any改起来像拆地雷部署的时候才发现 stdio 和 HTTP 模式根本没想清楚。这篇文章就是来解决这些问题的。我会结合 MCP 自定义服务器开发的真实场景把错误处理、流式输出、TypeScript 工程化和部署这四个进阶话题一次性讲透。适合已经会写最基础 MCP server、正打算把它做成能扛真实流量的状态的开发者。所有代码基于modelcontextprotocol/sdk的 TypeScript 版本示例都能直接改改就用。1. MCP 服务器整体设计别把协议当工具函数包装器1.1 MCP 到底在解决什么问题MCPModel Context Protocol模型上下文协议本质上是给 AI 应用host和外部工具/数据源server之间定的一套统一通信协议。你可以把它理解成“AI 世界的 USB-C 接口”——不管是最热的 Claude、Cursor还是自己写的 Agent 框架只要实现了同一个协议就能无缝接到各个 server 上不用每对接一个工具就写一套私有 API。MCP 的调用关系并不复杂host 负责发起请求server 负责执行并返回结果二者通过 transport传输层通信。transport 的选择基本就两种stdiohost 直接拉起 server 进程通过标准输入输出通信。适合本地单机使用部署到用户电脑或开发机上生命周期跟着 host 走。Streamable HTTPserver 以 HTTP 服务形式运行host 通过远程请求调用。适合多客户端、跨机器、需要在服务端统一运维的场景。这个选择直接决定了你后面错误处理、流式输出和部署方案怎么写。我的建议是工具本身没有任何本地资源依赖的优先考虑 HTTP 模式纯本地文件操作、数据库连接、需要访问本机进程的用 stdio 更自然。1.2 进阶 server 的分层设计思路很多刚上手的人会把所有逻辑堆在一个server.tool()回调里这是典型的 demo 写法。一个要上生产的 MCP server我习惯分成三层每层各管各的事协议层只负责 MCP 消息的收发、JSON-RPC 编解码、错误码转换。这层由 SDK 解决但你得理解它的行为尤其是错误怎么被序列化出去。能力层定义“有哪些工具、工具的参数 schema 是什么、返回结构是什么样”。这层是 MCP 的契约写清楚了 host 和模型才能正确调用。业务层真正干活的地方比如查数据库、调外部 API、处理文件。这层最容易出问题也是错误处理、超时控制、进度上报的主战场。分层做清楚的直接好处是出问题时你知道该去哪一层排查。我见过太多 server业务层抛了个TypeError: Cannot read properties of undefined结果 MCP 客户端收到的是 JSON-RPC internal error日志里又没打印堆栈整个排查过程全靠猜。所以设计阶段就要定一个规矩业务层的所有异常必须被捕获要么转换成 MCP 协议错误要么转换成结构化的业务错误返回绝不能裸奔到协议层。另外不要在 server 里塞太多东西。MCP 工具越小越单一越好宁可拆成 five 个小工具也别做一个“万能工具”。万能工具的参数 schema 会变得极其复杂LLM 调用时的 token 成本高出错概率也大而且错误信息很难收敛成可读形式。这是经验的总结不是偏好问题。2. 错误处理让调用方知道发生了什么2.1 MCP 错误码模型MCP 基于 JSON-RPC 2.0所以错误处理首先要遵守 JSON-RPC 的错误码定义。MCP SDK 直接把错误码封装成了枚举ErrorCode常用的几个错误码名称含义-32700ParseError消息解析失败通常是协议层数据损坏-32600InvalidRequest请求结构不合法-32601MethodNotFound调用了不存在的工具或方法-32602InvalidParams参数校验失败-32603InternalError服务器内部错误-32000 到 -32099ServerErrorMCP 预留的服务器自定义错误区间实际开发中InvalidParams和InternalError是最常出现的。SDK 里提供了一个McpError类你可以直接throw new McpError(ErrorCode.InvalidParams, 参数 xxx 不能为空)SDK 会自动把它转成标准的 JSON-RPC 错误返回给 host。2.2 在哪一层做错误处理我强烈建议在注册工具的外层做一个统一包装而不是在每个工具函数内部到处 try/catch。这跟写 Express 中间件的思路是一样的统一收口统一转换统一日志。下面这个withErrorHandling包装器是我常用的写法import { McpError, ErrorCode } from modelcontextprotocol/sdk/types.js; type ToolHandlerT (args: T) Promiseunknown; function withErrorHandlingT(handler: ToolHandlerT): ToolHandlerT { return async (args) { try { return await handler(args); } catch (err) { // 协议错误直接透传保留原始错误码 if (err instanceof McpError) { throw err; } // 参数校验错误统一转成 InvalidParams if (err typeof err object name in err (err as { name: string }).name ZodError) { const details (err as { issues?: Array{ path: string; message: string } }).issues ?.map((i) ${i.path.join(.)}: ${i.message}) .join(; ); throw new McpError(ErrorCode.InvalidParams, 参数校验失败: ${details}); } // 兜底记录完整堆栈对外只暴露通用错误 console.error([tool:internal_error], err); throw new McpError( ErrorCode.InternalError, 工具执行失败: ${err instanceof Error ? err.message : String(err)} ); } }; }这个包装器干了三件事识别并透传已有协议错误把 Zod 校验错误转成InvalidParams其他未知异常打日志后统一转成InternalError。这样 host 端拿到的错误永远是结构化的不会出现类型是 string 却当对象访问这种低级问题。2.3 业务错误是抛异常还是当结果返回这是一个很值得讨论的点。我的经验是分两种情况参数问题、前置条件不满足直接抛McpError让调用方明确知道这单调用是无效的不用重试。业务执行失败但错误信息本身对用户有价值比如调第三方 API 返回了明确的业务码我建议把它放在工具返回的 content 里用结构化文本或 JSON 字符串返回给 LLM而不是抛异常。为什么因为 MCP 的错误通道信息量有限而 LLM 可以阅读返回 content 里的具体说明进而采取下一步动作比如换个参数重试、告诉用户原因。你把业务错误放进 content模型就能“看到”并理解它。举个例子一个查物流的工具如果单号不存在我返回的 content 里会明确写{ status: not_found, message: 运单号 SF1234567890 不存在请检查是否输入正确 }而不是抛一个InternalError。前者模型能听懂后者模型只能回一句“抱歉系统出错了”。这是 MCP 应用体验的巨大差别。2.4 超时与取消处理MCP 协议本身允许 host 给请求设置超时但 client 断开或超时之后底层逻辑往往还在跑。这在数据库查询、外部 API 调用上尤其危险——你以为调用方已经不管了实际上你的 server 还在占用连接和计算资源。我的处理方案是在工具包装器上加一层取消信号import { randomUUID } from node:crypto; const tasks new Mapstring, AbortController(); export function registerTask(handler: ToolHandlerunknown) { return withErrorHandling(async (args) { const taskId randomUUID(); const controller new AbortController(); tasks.set(taskId, controller); try { return await handler({ ...(args as object), signal: controller.signal }); } finally { tasks.delete(taskId); } }); }配合 server 收到的notifications/cancelled通知主动 abort 对应任务。虽然不是所有 SDK 版本都默认暴露取消钩子但自己在工具层保存 AbortController 的方法是通用的。这样至少能保证客户端超时放弃后server 端不会留下僵尸任务。3. 流式输出不让用户干等3.1 MCP 里的“流式输出”是什么先说清楚MCP 工具调用本身是请求-响应的单向模式不像 Chat API 那样天然支持逐 token 吐字。但有两个场景你会非常需要“流式”效果执行时间长的工具比如跑一个数据分析、批量文件处理、远程构建任务可能要几十秒甚至几分钟。用户端没有反馈就会觉得服务挂了甚至触发 host 的请求超时。工具执行过程中需要向用户展示中间结果比如一个“生成报告”的工具内部要先拉数据、再渲染、最后上传每个阶段的进度如果能实时推给用户体验会好很多。MCP 协议针对进度通知有原生支持notifications/progress。它的原理不算复杂——客户端在发起请求时可以在_meta.progressToken里携带一个自定义 token服务端通过这个 token 向客户端推送进度通知。客户端收到通知后可以在 UI 上展示进度条或阶段文案。3.2 服务端向客户端推送进度用低层ServerAPI 实现进度的代码如下import { Server } from modelcontextprotocol/sdk/server/index.js; import { CallToolRequest } from modelcontextprotocol/sdk/types.js; const server new Server({ name: demo-server, version: 1.0.0 }, { capabilities: { tools: {} }, }); server.setRequestHandler(CallToolRequest, async (request) { const token request.params._meta?.progressToken; const notifyProgress (progress: number, total: number, message?: string) { if (!token) return; // 客户端没传 token 就不用推 return server.notification({ method: notifications/progress, params: { progressToken: token, progress, total, ...(message ? { message } : {}), }, }); }; if (request.params.name slow_task) { await notifyProgress(0, 100, 开始执行); // 模拟阶段1拉取数据 await doStep1(); await notifyProgress(30, 100, 数据拉取完成); // 模拟阶段2处理中 await doStep2(); await notifyProgress(70, 100, 数据处理中); // 模拟阶段3收尾 await doStep3(); await notifyProgress(100, 100, 执行完毕); return { content: [{ type: text, text: 任务完成 }], }; } throw new McpError(ErrorCode.MethodNotFound, 未知工具: ${request.params.name}); });注意一个关键点request.params._meta.progressToken是可选字段。如果 host 没传你再调用 notification 也只是自己空转所以一定要在notifyProgress里先判空。另外 notification 的推送时机也不能太频繁一般每个阶段推一次就够太细的进度反而让客户端在处理通知时增加额外开销。3.3 结合 HTTP transport 的流式响应如果你的 server 走的是 Streamable HTTP 模式还有一个更接近“字面意义流式输出”的方式用 SSEServer-Sent Events逐块返回数据。SDK 的 StreamableHTTPServerTransport 会生成一个 SSE 流客户端可以通过这个流持续接收服务端消息。这个能力最常见的应用场景是你的 MCP server 作为中间层转发大模型生成结果。比如我写过一个“通过 MCP 调用本地 Ollama 模型”的工具流程是这样的客户端调用chat工具server 自己请求 Ollama 的/api/chat接口并开启 stream 模式然后把每个返回的 token 通过 server 的 notification 或 SSE 事件转发给客户端。这样用户能在自己的 MCP host 里看到逐字输出的效果。用代码表示大概是这样server.setRequestHandler(CallToolRequest, async (request) { if (request.params.name ollama_chat) { const stream await ollamaClient.chatStream({ model: qwen2.5:7b, messages: request.params.arguments.messages, }); // response 也支持流式累加 let fullText ; for await (const chunk of stream) { fullText chunk.message.content; await notifyProgress(0, 1, chunk.message.content); // 或通过其他方式推送增量 } return { content: [{ type: text, text: fullText }] }; } });这里要说明一下因为 MCP 的CallTool响应结构是固定的目前最稳妥的做法仍然是把完整结果返回给客户端流式效果用进度通知去呈现增量内容。不要自己发明二进制流协议或者非标字段SDK 和 host 不认。3.4 流式输出的客户端适配经验流式效果能不能生效很大程度取决于 host 端的实现。有些 host比如 Cursor 的 MCP 客户端对 progress notification 支持得不错会在界面上展示进度有些则完全不理会直接把通知忽略掉。我的建议是不要把流式当成核心功能而是把它当成“有则更好”的增强。核心逻辑仍然要靠一次完整的CallTool响应来承载progress notification 只是锦上添花。你在自己的客户端或 Agent 框架里消费进度事件时判断逻辑也要写成“监听不到就当不存在”不要因为等待一条永远可能不来的 notification 而把流程卡死。4. TypeScript 工程化类型安全是底线4.1 用对 SDK别重复造轮子MCP 官方提供了 TypeScript SDK包名叫modelcontextprotocol/sdk支持低层的Server类和高层的McpServer类。我的建议是能用高层就用高层高层已经帮你封装好了工具注册、参数解析、错误转换这些重复逻辑写起来清爽很多。高层 API 的典型用法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: my-tools, version: 1.0.0 }); server.registerTool( get_weather, { title: 查询天气, description: 根据城市名查询当前天气, inputSchema: { city: z.string().describe(城市名比如 北京), }, }, async ({ city }) { // 业务逻辑 return { content: [{ type: text, text: 北京的天气晴28°C }] }; } ); const transport new StdioServerTransport(); await server.connect(transport);zod 的describe方法非常有用它会自动成为工具描述的一部分LLM 在决定是否调用工具、传什么参数时会读取这段描述。写 MCP 工具的描述和参数描述就是在给 LLM 写说明书值得多花时间。4.2 工具返回结构的设计MCP 工具的返回类型是固定结构核心是content数组里面可以是TextContent、ImageContent等。这对 LLM 是友好的——它能看到一段纯文本也能看到图片。我在设计返回结构时有一个原则**尽量让text字段包含完整、自解释的文本而不是返回一个让模型自己去猜含义的代码对象。**比如查询用户信息我返回用户名: 张三 角色: 管理员 邮箱: zhangsanexample.com 最后登录: 2025-06-01 14:30而不是返回{username:张三,role:admin}这种 JSON。为什么因为前者模型可以直接读、直接答后者还要经过一层“解析 JSON 再组织语言”的过程既容易出错又浪费时间。如果信息本身结构复杂比如表格数据我会返回 Markdown 格式的文本也可以搭配 JSON 字符串但一定在文本里说明结构。4.3 类型收窄与工具参数的处理高层的McpServer.registerTool里inputSchema用 zod 定义了之后回调函数的参数类型会被自动推断出来。此时注意一个 TypeScript 常见的坑如果你的 schema 定义的是类似z.object({ ids: z.array(z.string()) })回调里访问args.ids时TypeScript 能正确推出string[]。但如果写的是z.object({ ids: z.array(z.string()).optional() })那args.ids就是string[] | undefined访问前一定要做空值判断。另一个常见的类型问题是 SDK 导入路径。这个包是纯 ESM在 tsconfig 里建议这样配置才不会有模块解析问题{ compilerOptions: { module: NodeNext, moduleResolution: NodeNext, target: ES2022, esModuleInterop: true, strict: true, outDir: dist } }并且在package.json里写type: module。不然运行时容易遇到ERR_REQUIRE_ESM或者动态导入的报错。4.4 TypeScript 版本陷阱SDK 的依赖版本和zod版本经常变动特别是遇到zod大版本升级v3 到 v4时类型推断行为会有不少变化。比如老代码里z.object({}).strict()之类的写法升级后可能会报类型错误。我的建议是安装时把zod锁版本npm install zod^3.22.4因为modelcontextprotocol/sdk目前对 zod v4 的兼容性在不同版本里有差异。同时Node.js 版本尽量用 18 以上SDK 依赖了一些较新的 API。如果看到baseUrl 已弃用这类编译提示别慌把tsconfig.json里的baseUrl删掉改用相对路径或paths的替代写法就行这是 TypeScript 7 计划中禁用的旧配置项早改早省心。5. 部署从本机到服务器的完整路径5.1 先确定部署模式部署 MCP server 之前最需要决策的问题是你的 server 会被谁调用如果只给本地开发工具用比如连到本地的 Cursor、Claude Desktop那用stdio 模式最舒服。host 自动启动进程不需要管网络、鉴权、端口这些事。但要注意stdio 模式下 server 的宿主机必须有 Node.js 运行时用户把 server 装到别的机器上时也得有 Node.js。如果 server 要同时服务多个客户端或者部署在一台中央服务器上那用Streamable HTTP 模式。这种模式下 server 是一个常驻服务需要考虑端口管理、鉴权、健康检查、日志轮转等一系列生产问题。下面这张表是我做选型时参考的对照对比项stdio 模式Streamable HTTP 模式进程生命周期跟随 host 启动/退出常驻服务网络依赖无本地进程通信需要监听端口鉴权无本地可信需要 Token/API Key多客户端每个客户端一个进程所有客户端共享服务部署要求每个客户端机器装 Node.js只需服务端装 Node.js日志输出到 stdout/stderr独立服务日志如果你是自用或小团队用无脑选 stdio省去一大半运维工作。多用户、跨团队共享、或者有敏感数据要统一管控再考虑 HTTP。5.2 stdio 模式的部署细节stdio 模式部署看似简单但有几个细节特别容易踩坑。首先是package.json里要写好bin字段让 server 能作为可执行命令被 host 拉起{ name: my-mcp-server, version: 1.0.0, type: module, bin: { my-mcp-server: ./dist/index.js }, files: [dist], scripts: { build: tsc -p tsconfig.json, start: node dist/index.js } }并且在dist/index.js的顶部加一行#!/usr/bin/env node构建后给文件加执行权限chmod x dist/index.js另外在本地安装时可以这样挂载到全局方便 Claude Desktop 或 Cursor 直接写命令名调用npm link5.3 Docker 容器部署 HTTP 模式HTTP 模式部署到服务器用 Docker 是干净利落的方式。下面这个 Dockerfile 是我在多个项目里验证过的直接抄问题不大# 构建阶段 FROM node:20-alpine AS build WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN npm install -g pnpm pnpm install --frozen-lockfile COPY tsconfig.json ./ COPY src ./src RUN pnpm build # 运行阶段 FROM node:20-alpine ENV NODE_ENVproduction WORKDIR /app # 只拷贝构建产物和生产依赖镜像体积小很多 COPY --frombuild /app/dist ./dist COPY --frombuild /app/node_modules ./node_modules COPY package.json ./ # 用非 root 用户运行安全习惯 USER node EXPOSE 8080 CMD [node, dist/index.js]启动 HTTP server 的入口代码要监听process.env.PORT方便 Docker 映射import { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; const port Number(process.env.PORT || 8080); const serverInstance createMcpServer(); const transport new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, }); // 用 Express 或原生 http 把请求交给 transport注意 HTTP 模式下SDK 对每个连接会建立 session如果你用官方的StreamableHTTPServerTransport请求路径要保持一致且要处理DELETE方法来断开会话。这些细节在 SDK 的示例代码里都有但首次接的时候容易漏。5.4 部署后的调试与验证无论是本地 stdio 还是远程 HTTP我强烈建议用官方的 MCP Inspector 工具做一轮功能验证。它像一个图形化的调试台能看到每个请求和响应对排查错误特别有用npx modelcontextprotocol/inspector node dist/index.js对于 HTTP 模式可以指定 URL 连接npx modelcontextprotocol/inspector -e http://localhost:8080在 Inspector 里验证三件事工具列表能不能正确列出、正常参数能不能拿到结果、错误参数能不能返回可读的错误信息。这三关过了再接到 Cursor 或自己的 Agent 里就不太容易出幺蛾子。6. 常见问题与排查技巧实录6.1 stdio 模式进程启动就退host 报连接失败现象Claude Desktop 或 Cursor 配置 server 后工具列表加载失败日志里提示 MCP server process exited。排查思路这类问题九成是 server 启动时抛了未捕获异常进程直接退出。最常见的几个原因dist目录不存在、入口文件路径配错、代码里用到了浏览器端 API 或 Node 版本不匹配。解决办法先在终端手动跑一次node dist/index.js如果报错就按报错修。修好后用npx modelcontextprotocol/inspector node dist/index.js验证能否正常握手。记得在入口文件外层加一个全局异常兜底至少打日志process.on(uncaughtException, (err) { console.error([uncaughtException], err); }); process.on(unhandledRejection, (err) { console.error([unhandledRejection], err); });6.2 工具调用返回了但客户端收到的内容为空或乱码现象工具执行成功服务端日志有输出客户端却显示空结果或 JSON 解析失败。原因多半是返回的content结构不符合 MCP 规范。比如少写了type: text或者把普通对象直接塞进 content应该是字符串或者返回了undefined。解决办法写一个类型安全的返回构造器强制规范function textResult(text: string) { return { content: [{ type: text as const, text }] }; }然后所有工具统一走textResult()就不会漏字段。如果你返回的是结构化数据用JSON.stringify(data, null, 2)转成字符串后放进text。6.3 zod 版本冲突SDK 类型推断报错现象安装新依赖后server.registerTool里回调的参数类型变成any或直接编译报错。原因项目里的zod版本和modelcontextprotocol/sdk期望的版本不一致导致类型不能正确关联。解决办法删掉node_modules和 lockfile 后重新安装或把zod显式锁到 SDK 依赖的版本。看看node_modules/modelcontextprotocol/sdk/package.json里的 dependencies对照设你自己的版本即可。6.4 HTTP 模式部署到服务器后外部客户端连不上现象本地用curl能访问但在云服务器上从外部连不上或者连上了但 Cursor 显示鉴权失败。排查流程先确认端口是否监听ss -lntp | grep 8080再确认云安全组和防火墙放行了该端口最后确认 client 配置的 URL 里协议写的是http还是https以及 token 是否放进了请求头。如果 SDK 版本较旧可能还需要检查 CORS 配置。StreamableHTTPServerTransport的构造参数里可以传入 CORS 相关配置生产环境务必把enableJsonResponse和 CORS 设置成符合实际场景的值别用默认状态裸奔。6.5 进度通知发不出去或客户端收不到现象代码里调用了server.notification但没有报错客户端 UI 就是没有进度显示。原因大概率是客户端没传progressToken服务端又没判空或者传了但服务端在 notification 的params里写错了字段名正确是progressToken不是token进度字段是progress不是current。解决办法在notifyProgress函数内部打印一行日志看token是否存在。加日志是所有异步问题排查的第一步尤其是 MCP 这种跨进程协议。6.6 工具执行超时host 直接掐断连接现象工具执行时间大于 host 的超时阈值常见 30 秒或 60 秒客户端直接报 timeout。解决办法要么优化业务逻辑把单次执行时间压下来要么改造成分步执行——第一次调用创建一个异步任务并返回taskId客户端通过第二个工具查询任务结果。这个“任务模式”才是 MCP 下处理长耗时任务的稳妥方案比单纯推进度通知更可靠因为 notification 可能会丢但你主动轮询结果不会丢。我这里给一个最简单的任务模式接口设计server.registerTool(async_task_start, { ... }, async ({ payload }) { const taskId randomUUID(); runInBackground(taskId, payload); return textResult(JSON.stringify({ taskId, status: running })); }); server.registerTool(async_task_status, { ... }, async ({ taskId }) { const task getTask(taskId); if (!task) throw new McpError(ErrorCode.InvalidParams, 任务不存在: ${taskId}); return textResult(JSON.stringify(task.getPublicState())); });这样即使单次调用超过 host 超时上限任务也还能在后台继续执行用户随时能回来查状态。6.7 本地能跑Docker 里跑不起来现象Docker 镜像构建成功但容器启动后日志没输出或进程直接退出。排查要点容器里USER node之后很多目录没有写权限。如果 server 要写临时文件得把WORKDIR的所有者改成 nodeCOPY --chownnode:node . /app或者挂载外部卷。NODE_ENVproduction时某些依赖如果意外被 pnpm 省略启动就会报模块缺失。构建阶段用pnpm install --prod之前先确认dependencies和devDependencies分清楚了。容器日志看不到多半是 console.log 没有落到 stdout。Node 的 console 默认输出到 stdout但如果代码里重定向过就检查process.stdout.write是否被拦截了。这些坑我基本都踩过一轮写在这里就是希望大家都能绕开。最后聊一点我的体会MCP server 开发走到进阶阶段真正拉开差距的不是你会不会调 SDK而是你有没有把“调用生命周期”这件事想清楚。一次工具调用从客户端发起、参数校验、业务执行、进度上报、错误转换到结果返回每个环节都可能出问题每个环节都需要有明确的设计。好的 server 不是功能堆得多而是每个工具的行为都可预期、可观测、可排查。还有一个很实际的建议开发的时候在 server 里多打日志哪怕是本地自用也建议用console.error输出到 stderr这样在 stdio 模式下不会污染 stdout 的协议数据出问题时又方便追踪。等你上手一段后再回头看看自己的第一个 demo server多半会发现现在这个版本在错误处理、类型定义、部署方式上的进步——那就是你真正进阶了的标志。希望这篇指南能省下你一个个文档翻查的时间早点把 MCP server 真正用起来。