尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

MCP服务器生产级实战:错误处理、流式输出与TypeScript部署指南

发布时间:2026/9/14 20:27:55

资讯中心
01
ARTICLE

MCP服务器生产级实战:错误处理、流式输出与TypeScript部署指南

MCP服务器生产级实战:错误处理、流式输出与TypeScript部署指南
聊到 MCP 服务器开发网上最不缺的就是“用 Python 三行代码起一个 server”的教程照着跑确实很爽可一旦到了真实业务里你会发现“工具能跑”和“工具好用”完全是两回事。错误怎么回才能让 Claude Code、Cursor 这类客户端精准暴露问题长任务要不要流式输出怎么流才能不明显影响体验TypeScript 项目怎么配才不会被 SDK 的 ESM 限制卡脖子部署上去之后连接、鉴权、日志又该怎么处理这些问题我在从零写 MCP 自定义服务时都踩过一遍有些坑官方文档根本不会写。这篇就按错误处理、流式输出、TypeScript 工程化、部署这条线把实际落地的思路和代码直接摆出来。已经写过基础 server、想往生产级靠拢的开发者这篇应该能帮你少走不少弯路。1. 项目概述与整体设计思路1.1 MCP 自定义服务器到底在解决什么问题MCPModel Context Protocol模型上下文协议本质上是在做一件事给大模型应用提供一个标准化的“工具接口”。如果没有这层协议我们每接一个 AI 应用就得为它单独写一套工具调用、权限管理、数据返回的逻辑有了 MCP模型应用变成了客户端业务能力变成了服务器两边通过统一的 JSON-RPC 消息进行交互。自定义服务器的价值在于把现有系统的数据、接口、内部能力封装成模型可直接调用的工具。比如企业内部的知识库检索、订单查询、代码仓库分析都可以通过 MCP server 暴露给 AI 助手。我这边实际做的最多的一类是把已有的 REST 接口包一层 MCP让 AI 客户端能直接按语义调用省掉了让模型去记忆各种 URL 和鉴权头的工作。这套进阶指南的核心就是解决自定义服务器真正上线时躲不开的四个环节程序跑挂了怎么告诉调用方、长任务怎么让调用方感知进度、TypeScript 项目怎么稳定构建、以及服务怎么部署到远程环境让客户端能够访问。1.2 为什么选 TypeScript 而不是 Python选 TypeScript 写 MCP server不是因为它比 Python 更“高级”而是它有几个非常实际的好处。第一类型系统能直接约束工具的参数结构MCP 工具的参数校验天然适合用 Zod 这类库来定义TS 和 Zod 配合起来几乎是无缝的。第二如果团队原本就是 Node.js 技术栈MCP server 可以直接复用现有的 npm 生态、日志体系、监控库不需要额外引入一套 Python 运行时。另外MCP SDK 官方对 TypeScript 的支持非常完整modelcontextprotocol/sdk包不仅提供了低层协议实现还封装了McpServer这类高层工具注册接口。相比 Python 版 SDKTS 版在流式传输和 Streamable HTTP 的支持上也更早、更稳。我之前先用 Python 跑通了一个 demo但碰到要复用一个内部 Node 服务时还是果断用 TypeScript 把逻辑又写了一遍——代码量反而更少因为类型直接把参数错误挡在了编译期。1.3 总体架构与工程目录设计一个经得起折腾的 MCP 自定义服务器代码结构不能全塞在index.ts里。我的习惯是这样的mcp-server/ ├── src/ │ ├── index.ts # 入口负责创建 server 和选择传输方式 │ ├── tools/ │ │ ├── user.ts # 具体工具注册 │ │ └── repository.ts │ ├── utils/ │ │ ├── errors.ts # 统一错误处理 │ │ └── logger.ts # 结构化日志 │ └── types/ │ └── tool-params.ts # Zod schema 集中定义 ├── tsconfig.json ├── package.json └── Dockerfile这样的结构最大好处是每个工具文件保持独立参数模型、业务逻辑、错误处理各管一摊后面加新工具时不需要去翻主文件。入口文件只做三件事创建 server 实例、注册所有工具、选择传输通道。后面几节我会逐步把这个骨架填实。2. 错误处理让调用方看得懂你的失败2.1 先理解 MCP 的错误模型协议层与应用层MCP 的通信基于 JSON-RPC 2.0这意味着有两层错误需要分别处理。协议层的错误通常是传输、解析、方法名不存在这类问题SDK 内部已经帮我们处理掉了应用层的错误则是工具在执行过程中抛出的业务异常比如“用户不存在”“接口超时”“参数越权”这部分需要我们自己定义和抛出。很多初写 MCP server 的人容易犯一个毛病工具函数里throw new Error(something failed)结果客户端看到的信息要么是笼统的“Internal error”要么直接断连。原因在于 JSON-RPC 要求错误必须带标准错误码而 SDK 需要把普通异常转换成McpError才能保留详细信息。换句话说你不主动收口错误SDK 就只能给一个模糊的兜底。2.2 用 McpError 收口所有业务异常SDK 提供了现成的McpError和ErrorCode我们可以在工具内部对异常统一包装。下面是我常用的写法import { McpError, ErrorCode } from modelcontextprotocol/sdk/types.js; function toMcpError(err: unknown): McpError { if (err instanceof McpError) return err; const message err instanceof Error ? err.message : Unknown error; return new McpError(ErrorCode.InternalError, message); }包装之后在工具 handler 里这样用server.tool( get_user, { userId: z.string() }, async ({ userId }) { try { const user await userService.findById(userId); if (!user) { return { content: [{ type: text, text: 用户 ${userId} 不存在 }], isError: true, }; } return { content: [{ type: text, text: JSON.stringify(user) }] }; } catch (err) { throw toMcpError(err); } } );注意isError这个字段这是 MCP 的结果级标记。对于业务上的“正常失败”比如资源不存在、参数冲突我推荐返回isError: true而不是直接抛异常。这样客户端能拿到完整的文本内容同时明确知道这次调用是失败的不会把错误信息误当正常结果继续处理。2.3 参数校验错误怎么映射到 JSON-RPC 错误码参数校验是工具层最容易出错的部分。MCP SDK 在高层封装里已经用 Zod 做了转换但默认情况下校验失败返回的是参数结构错误客户端往往只知道“参数不对”不知道到底哪个字段不对。我的做法是在注册工具时给每个参数 schema 加上.describe()说明同时在 handler 里做二次校验把业务约束比如“日期不能早于今天”“分页大小不超过 100”从结构校验中拆出来。这样结构错误归 SDK 管业务约束错误归我们管两边信息都清晰。const queryOrdersSchema { startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe(起始日期格式 YYYY-MM-DD), endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe(结束日期格式 YYYY-MM-DD), pageSize: z.number().min(1).max(100).default(20).describe(分页大小1-100), };如果校验确实需要返回标准的InvalidParams错误可以这样catch (err) { if (err instanceof z.ZodError) { throw new McpError( ErrorCode.InvalidParams, 参数校验失败: ${err.issues.map(i ${i.path.join(.)} ${i.message}).join(; )} ); } throw toMcpError(err); }这个细节在实际联调中非常救命。客户端 AI 在看到准确的字段错误信息后会自动修正调用参数而不是反复用同一个错误参数请求几十次。2.4 错误处理里的三个隐藏坑第一个坑是不要把底层数据库或外部服务的堆栈直接返回给客户端。我在早期版本里直接输出过err.stack结果 AI 客户端把内部路径当成重要信息继续追问既暴露了服务器细节又浪费了一次交互。生产环境应该对异常做裁剪只保留错误类型和适合对外展示的描述。第二个坑是超时错误要单独处理。工具调用如果请求了外部 API记得设置明确的超时时间并在超时后抛出带语义的错误。我见过不少 MCP server 挂起现象最后看日志全是外部接口迟迟不返回而工具 handler 又没有做超时控制。加一层Promise.race或者AbortSignal.timeout()就能解决。第三个坑是不要吞异常。有的开发者喜欢在 catch 里console.error之后返回一个空结果这非常隐蔽——客户端以为调用成功了但实际拿到的是空白内容。正确的姿势是能返回isError就返回isError必须抛异常就抛出带McpError包装的异常保证错误信息始终沿着统一通道传递。3. 流式输出让长任务从“憋大招”变成“边跑边报”3.1 哪些场景必须用流式输出MCP 工具调用天然适合做“一次性问答”但一旦工具内部是耗时的长任务比如批量数据分析、大文件处理、多步骤 Agent 编排客户端就会面临一个尴尬处境点击调用之后屏幕上一直转圈几十秒后一次性吐出一个大结果。这种体验对大模型应用尤其不友好。用户在等待时不知道任务跑到哪一步更无法判断是正常执行还是卡死了。流式输出的价值就是让服务器在处理过程中持续向客户端推送进度信息客户端可以实时展示“正在读取数据”“正在生成报告”“已完成 70%”让整个调用过程透明可控。从我实际接触的场景看下面这几类工具必须考虑流式处理内部集成了多步骤 Agent 流程每个步骤耗时超过 5 秒需要调用外部大模型接口做二次分析等待时间不可控返回内容很大比如长文档总结需要分块生成工具会触发异步任务队列需要持续反馈任务状态。3.2 Streamable HTTP 传输与进度通知的落地写法MCP 目前推荐的 HTTP 传输方式是 Streamable HTTP它基于 SSEServer-Sent Events实现服务端向客户端的单向实时推送。如果你的服务器要部署到远程供 Claude Code、Cursor 这类客户端使用通常都会选择 Streamable HTTP 而不是 stdio。服务端入口可以这样创建import express from express; import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; const app express(); app.use(express.json()); const server new McpServer({ name: long-task-server, version: 1.0.0, }); app.post(/mcp, async (req, res) { const transport new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, onsessioninitialized: (sessionId) { console.log(session initialized: ${sessionId}); }, }); res.on(close, () { transport.close(); res.end(); }); await server.connect(transport); await transport.handleRequest(req, res); }); app.listen(3000, () { console.log(MCP server listening on 3000); });在工具内部我使用 SDK 提供的进度通知能力。McpServer 的 tool handler 第二参数里能拿到progressToken和server实例通过它们发送notifications/progress通知server.tool( run_analysis, { datasetId: z.string(), steps: z.number().min(1).max(100).default(10) }, async ({ datasetId, steps }, extra) { const token extra.progressToken; if (!token) { // 客户端不支持进度通知时至少还能正常执行 const result await doAnalysis(datasetId, steps); return { content: [{ type: text, text: result }] }; } const report: string[] []; for (let i 1; i steps; i) { await new Promise((r) setTimeout(r, 300)); report.push(step ${i} done); await extra.server.notification({ method: notifications/progress, params: { progressToken: token, progress: i, total: steps, message: 完成第 ${i} 步, }, }); } return { content: [{ type: text, text: report.join(\n) }] }; } );这个写法实测下来很稳。客户端如果支持进度展示会逐条渲染通知内容如果不支持也不影响最终结果返回兼容性比想象中好。3.3 流式输出的边界什么时候不适合流需要泼一盆冷水的是MCP 工具的最终结果仍然是一次性返回的过长的文本依然会出现在单次响应里。所以“流式”在目前的 MCP 上下文中更多是“进度流式”而不是“结果内容流式”。如果你的核心诉求是让模型逐字看到生成内容那更合理的方案是让工具返回一个任务 ID客户端再去轮询任务状态或通过另一个工具获取分片结果。我在实践中是这样处理的启动异步任务时立即返回taskId同时用进度通知汇报状态客户端可以调用get_task_result工具来拉取最终产物。这样任务编排灵活也不会把单个响应体撑得过大。另一个边界是消息大小。SSE 通道虽然可以承载长期连接但代理层、网关通常有超时限制。我自己遇到过的场景是部署到带 Nginx 的环境后连接超过 60 秒就被切断。排查下来是网关的proxy_read_timeout设置太短。解决办法有两个一是调大网关超时二是把长任务改成异步模式加轮询。生产环境我倾向于后者因为传输层超时不可控异步化反而更可靠。4. TypeScript 工程化落地细节4.1 第一关SDK 的 ESM 限制modelcontextprotocol/sdk目前发布的是纯 ESM 包这意味着你的项目要么使用 ESM要么在 CommonJS 项目里通过动态import()调用。如果直接在.ts文件里import { McpServer } from modelcontextprotocol/sdk/server/mcp.jstsconfig 又没配好编译后运行时会报“Cannot use import statement outside a module”这种错。我的建议是干脆把项目整体切到 ESM。具体做法是package.json里声明type: moduletsconfig 的module设为NodeNext。Node.js 16 对 ESM 的支持已经非常完善MCP SDK 本身也是按 ESM 设计的顺着它的生态走问题最少。4.2 tsconfig 的关键配置参考下面这套配置我从多个项目验证过可以直接抄{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true }, include: [src] }strict: true必须开。MCP 工具的参数校验依赖类型精确性如果关掉严格模式Zod schema 推导出来的类型会失去许多约束能力等于自废武功。skipLibCheck: true是为了避免第三方包的类型声明互相打架尤其装了多个 SDK 相关依赖时能省不少心。还有一个容易踩的点SDK 内部有些类型是异步迭代器、ReadableStream 这类 Web API 类型Node 18 对这些类型的支持也会影响编译。建议 Node 版本锁定在 18 以上最好直接用 20 LTS。4.3 工具注册时的类型安全方案McpServer 的tool()方法接收 zod schema 后handler 的参数类型会自动推导出来。这意味着参数名写错、类型不匹配在编译期就会报错不需要等运行时才发现。import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { z } from zod; const server new McpServer({ name: typed-server, version: 1.0.0 }); const paramsSchema { repoPath: z.string().describe(仓库绝对路径), depth: z.number().min(1).max(10).default(3).describe(分析深度), }; server.tool(analyze_repo, paramsSchema, async (params) { // params.repoPath 是 stringparams.depth 是 number // 写错属性名时编译期直接报错 const result await analyze(params.repoPath, params.depth); return { content: [{ type: text, text: result }], }; });我在项目里习惯把每个工具的 schema 独立导出方便单元测试直接引用来构造测试用例。类型和 schema 放一起维护后续改动参数时能同步更新。4.4 npm scripts 与调试技巧工程化配置里构建脚本也需要注意。下面是我常用的 scripts{ scripts: { build: tsc, dev: tsx watch src/index.ts, start: node dist/index.js, typecheck: tsc --noEmit } }开发时用tsx watch做热重载改完代码立即自动重启比手动tsc node效率高很多。本地联调 MCP server 时我经常直接用 Claude Code 或 Cursor 指向本地地址。如果用 stdio 模式可以先用node dist/index.js启动然后在客户端配置里填本地启动命令即可。typecheck建议加到 CI 流程里提交前先跑一次类型检查很多错误处理、参数类型的问题能在合并前就暴露出来。5. 部署从本地联调到生产可用的完整路径5.1 三种部署形态怎么选MCP server 的部署形态主要分三种。第一种是 stdio 模式由客户端进程直接拉起服务器。部署时只需要把编译后的代码放到目标机器配置好启动命令。简单轻量但只适合同一台机器上的客户端使用不适合多用户远程共享。第二种是 Streamable HTTP 模式服务器作为独立的 HTTP 服务运行客户端通过网络连接。这是目前最推荐的远程部署方式Claude Code、Cursor 等都支持通过 URL 配置远程 MCP server。第三种是混合模式同时支持 stdio 和 HTTP。我在开发调试阶段会用 stdio上线后用 HTTP所以会在入口处做一个环境变量判断const transportType process.env.MCP_TRANSPORT || stdio; if (transportType http) { const transport new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, onsessioninitialized: (id) logger.info(session ${id} started), }); // 挂载到 express 路由 } else { const transport new StdioServerTransport(); await server.connect(transport); }5.2 使用 Docker 做镜像发布把 MCP server 容器化能解决部署环境不一致、依赖版本混乱这些常见问题。我常用的多阶段构建 Dockerfile 如下FROM node:20-alpine AS build 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 --frombuild /app/dist ./dist COPY --frombuild /app/node_modules ./node_modules COPY package.json ./ EXPOSE 3000 CMD [node, dist/index.js]注意npm ci会严格按照package-lock.json安装依赖避免本地和容器里依赖版本不一致。生产镜像里只复制编译后的dist和node_modules源码不会暴露到运行环境也顺便减小了镜像体积。再用docker-compose.yml编排一下services: mcp-server: build: . ports: - 3000:3000 environment: - NODE_ENVproduction - PORT3000 - LOG_LEVELinfo - MCP_SERVER_SECRET${MCP_SERVER_SECRET} restart: unless-stopped5.3 环境变量、访问控制与日志部署后第一件事就是别把敏感信息写死在代码里。数据库连接串、内部 API Token、密钥都应该通过环境变量注入。这里分享一个判断标准凡是换一个环境就要改的信息一律走环境变量不进代码库。远程 MCP server 建议加一层访问控制。MCP 协议没有强制要求鉴权方式但作为对外服务你可以在 HTTP 层校验Authorization头。简单做法是服务器启动时读取MCP_SERVER_SECRET中间件统一校验app.use(/mcp, (req, res, next) { const secret process.env.MCP_SERVER_SECRET; if (!secret) return next(); const auth req.headers.authorization; if (auth Bearer ${secret}) return next(); res.status(401).json({ error: unauthorized }); });更严谨的方案可以接 JWT、OAuth但大多数内部工具场景一个随机的 Bearer Token 已经足够。客户端配置里填入相同的 Token 即可。千万不要把没有任何鉴权的 MCP server 暴露到公网因为一个开放的/mcp接口本质上等于允许任何人调用你的内部工具。日志方面我在生产环境使用pino这类结构化日志工具JSON 格式能直接对接日志平台。工具每次调用的请求参数、耗时、错误码都记一条排查问题时会发现这是最值钱的信息。尤其多客户端并发调用时没有结构化日志几乎无法定位是哪个请求出了问题。5.4 部署后验证清单新环境部署完不要急着接到客户端上。先跑一遍我整理的验证清单HTTP 健康检查用curl http://localhost:3000/mcp确认端口有响应工具列表拉取用 MCP 客户端连接后执行list_tools确认所有工具都正确注册错误路径验证故意提交一个非法参数确认客户端能收到明确的InvalidParams错误长任务验证触发一个耗时超过 10 秒的工具确认进度通知能正常送达鉴权验证去掉Authorization头试一次确认会被拒绝。这套流程走完基本可以放心交付给用户了。6. 常见问题速查与排障实录6.1 客户端连不上服务器或连接后立刻断开先分清是 stdio 模式还是 HTTP 模式。stdio 模式下最常见的问题是启动命令写错或工作目录不对。使用tsx启动源码和启动编译后的dist目录效果不同别在客户端配置里混着写。HTTP 模式下我遇到过最多的是地址绑定问题。Express 默认监听所有接口还好但如果你在代码里写了app.listen(3000, 127.0.0.1)客户端从另一台机器或容器内访问就会失败。部署到 Docker 时最好直接让进程监听0.0.0.0端口映射交给 Docker 或前置网关去处理。还有个隐蔽问题使用nodemon或tsx watch启动时进程会额外孵化子进程而 MCP 握手阶段要求传输层保持稳定进程重启会直接导致连接中断。生产环境永远用node dist/index.js启动开发环境用tsx watch但不要拿去对接正式客户端。6.2 流式输出不生效客户端一直不显示进度如果你用了 Streamable HTTP 但进度通知没出来先检查客户端是否支持streamable-http传输类型。部分客户端默认走 stdio或者后端代理把 SSE 流给缓冲了。还有一个常见问题进度通知里必须有progressToken才能被客户端识别这个 token 是初始化时带过来的如果你的代码写死了 token 或者没有从extra.progressToken拿通知就发不出去。另外如果服务端前面有 Nginx注意关闭 bufferlocation /mcp { proxy_pass http://127.0.0.1:3000; proxy_buffering off; proxy_cache off; proxy_read_timeout 600s; }proxy_buffering off是 SSE 场景的必选项否则内容会被 Nginx 攒在缓冲区里直到任务结束才一次性推给客户端。6.3 错误信息在客户端被吞掉了只显示“Internal error”这个我在早期踩过很深。原因是工具 handler 里抛出的错误如果不符合McpError格式SDK 会把错误信息简化成内部错误码丢失原始描述。排查思路很简单把toMcpError的包装函数统一应用到所有工具 handler并在最外层加一个兜底 try-catch确保所有异常都能转换成McpError。另一个可能性是isError分支里提前return了导致 SDK 看不到异常。记住isError: true是“结果级错误”的标记对客户端而言这不是异常而是“有内容的失败结果”。如果客户端还是把失败当正常结果用检查一下你返回的文本内容是否足够明确——最好在文本开头直接写“错误”或“失败”给模型一个明确信号。6.4 生产环境延迟偏高或内存持续增长延迟问题先看外部依赖再看传输层。如果工具每次调用都去请求外部 API而外部 API 响应很慢那怎么调 MCP 层都没用。建议在工具内部做好超时和并发控制必要的时候把高频查询的数据做一份本地缓存。内存增长则要怀疑 SSE 长连接没有正确释放。Streamable HTTP 的 transport 在请求结束时需要调用transport.close()如果漏掉连接资源会一直挂着。我的排查方法是在日志里打印 session 的创建和销毁如果只增不减基本就是这个原因。另外Docker 容器里要注意设置--memory限制避免内存吃满拖垮整台机器。就我个人的使用感受MCP 自定义服务器真正考验人的不是协议本身而是工程化细节。错误处理决定了一个客户端能不能自动纠正调用流式输出决定了用户长任务等待时的体验TypeScript 配置决定了后续迭代的效率部署方式决定了服务能跑多稳。把这四块按这篇的顺序逐一过一遍你的 MCP server 质量会明显上一个台阶。一点小技巧排查连接问题时优先开 debug 日志MCP SDK 提供一个DEBUG*环境变量能看到完整的 JSON-RPC 报文比你猜半天原因快得多。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。