MCP服务器生产级实战:错误处理、流式输出与TypeScript部署指南
2026/9/14 20:27:38 网站建设 项目流程

聊到 MCP 服务器开发,网上最不缺的就是“用 Python 三行代码起一个 server”的教程,照着跑确实很爽,可一旦到了真实业务里,你会发现“工具能跑”和“工具好用”完全是两回事。错误怎么回才能让 Claude Code、Cursor 这类客户端精准暴露问题?长任务要不要流式输出,怎么流才能不明显影响体验?TypeScript 项目怎么配才不会被 SDK 的 ESM 限制卡脖子?部署上去之后连接、鉴权、日志又该怎么处理?

这些问题我在从零写 MCP 自定义服务时都踩过一遍,有些坑官方文档根本不会写。这篇就按错误处理、流式输出、TypeScript 工程化、部署这条线,把实际落地的思路和代码直接摆出来。已经写过基础 server、想往生产级靠拢的开发者,这篇应该能帮你少走不少弯路。

1. 项目概述与整体设计思路

1.1 MCP 自定义服务器到底在解决什么问题

MCP(Model 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 版 SDK,TS 版在流式传输和 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 提供了现成的McpErrorErrorCode,我们可以在工具内部对异常统一包装。下面是我常用的写法:

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,它基于 SSE(Server-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 第二参数里能拿到progressTokenserver实例,通过它们发送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.js",tsconfig 又没配好,编译后运行时会报“Cannot use import statement outside a module”这种错。

我的建议是干脆把项目整体切到 ESM。具体做法是package.json里声明"type": "module",tsconfig 的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 是 string,params.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_ENV=production COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules COPY package.json ./ EXPOSE 3000 CMD ["node", "dist/index.js"]

注意npm ci会严格按照package-lock.json安装依赖,避免本地和容器里依赖版本不一致。生产镜像里只复制编译后的distnode_modules,源码不会暴露到运行环境,也顺便减小了镜像体积。

再用docker-compose.yml编排一下:

services: mcp-server: build: . ports: - "3000:3000" environment: - NODE_ENV=production - PORT=3000 - LOG_LEVEL=info - MCP_SERVER_SECRET=${MCP_SERVER_SECRET} restart: unless-stopped

5.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 或前置网关去处理。

还有个隐蔽问题:使用nodemontsx watch启动时,进程会额外孵化子进程,而 MCP 握手阶段要求传输层保持稳定,进程重启会直接导致连接中断。生产环境永远用node dist/index.js启动,开发环境用tsx watch但不要拿去对接正式客户端。

6.2 流式输出不生效,客户端一直不显示进度

如果你用了 Streamable HTTP 但进度通知没出来,先检查客户端是否支持streamable-http传输类型。部分客户端默认走 stdio,或者后端代理把 SSE 流给缓冲了。还有一个常见问题:进度通知里必须有progressToken才能被客户端识别,这个 token 是初始化时带过来的,如果你的代码写死了 token 或者没有从extra.progressToken拿,通知就发不出去。

另外,如果服务端前面有 Nginx,注意关闭 buffer:

location /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 报文,比你猜半天原因快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询