ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

MCP自定义服务器实战:从错误处理到部署的全链路指南

2026/9/19 6:52:42 拓冰建站 浏览量
MCP自定义服务器实战:从错误处理到部署的全链路指南 最近帮团队做了一个内部数据查询工具想着让 Cursor 直接能查到这些数据就顺手把它接成了 MCP 自定义服务器。折腾完才发现网上讲 MCP 入门的文章一大堆讲错误处理、流式输出、TypeScript 类型和部署的确实少。正好这几天又陆续收到几条私信都在问 MCP 服务器写出来之后怎么才能在真实项目里站稳所以把四个方向上最容易踩的坑一次性梳理清楚也算是对这段时间踩坑记录的一次复盘。这篇东西的目标读者是已经跑通过官方示例、正准备把 MCP 服务器接到真实业务里的开发者。我会把 MCP 服务端的协议错误体系、长任务场景下的流式输出方案、TypeScript 类型设计以及从本机调试到容器化部署的完整路径逐个过一遍。文里涉及的代码都是我在实际项目里验证过、可运行的写法不是那种「官方文档复制粘贴版」。1. 开发前先把底座打牢MCP 服务器的通信模型与初始化1.1 MCP 协议到底是怎么通信的MCP 全称是 Model Context Protocol本质上一套基于 JSON-RPC 2.0 风格的消息协议。客户端比如 Cursor、Codex、Trae 这类 AI 编程工具或者你自己写的应用和服务端之间靠三类消息做交互请求、响应、通知。请求需要带 id服务端必须回一个带相同 id 的响应通知不需要回复适合做日志推送、进度上报这类单向消息。很多人第一次写 MCP 服务端时容易把注意力全放在「工具怎么写」上忽略底层传输方式结果部署到远端就懵了。传输层目前最常见的有三种stdio 模式客户端启动一个本地进程通过标准输入输出收发 JSON 消息。本机调试、跟 Cursor 这类桌面端集成最方便不需要开端口。SSE 模式服务端作为一个 HTTP 服务客户端通过 Server-Sent Events 接收消息。适合部署在一台机器上、让多个远程客户端访问。Streamable HTTP 模式比 SSE 更完整的 HTTP 传输方式支持服务端主动推送多条消息这也是最近生态里越来越主流的方案。开发初期我建议只做 stdio把核心逻辑跑通再考虑网络化。因为 stdio 模式调试链路最短你本地 node 进程跑起来客户端直接拉起这个进程任何一步出问题都容易定位。等业务确实需要跨机器访问了再往上包一层 HTTP 传输心智负担小很多。1.2 用 TypeScript 初始化一个最小可运行的服务器MCP 官方 TypeScript SDK 的包名是modelcontextprotocol/sdk当前几个版本里推荐的写法是在项目里创建src/index.ts然后这样起一个最小的服务器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: internal-data-query, version: 1.0.0 }); server.tool( get_user_by_id, { userId: z.string().describe(用户 ID) }, async ({ userId }) { return { content: [ { type: text, text: 查到用户: ${userId} } ] }; } ); const transport new StdioServerTransport(); await server.connect(transport);这里有两个容易踩的坑。第一个modelcontextprotocol/sdk的导入路径是带.js后缀的因为它是 ESM 包你本地如果是 CommonJS 项目直接在 tsconfig 里设module: NodeNext然后所有相对导入都要补.js否则 Node.js 运行时找不到模块。很多人第一次跑起来就是挂在这一点上。第二个zod是必装的依赖MCP SDK 内部用 zod 做工具参数校验。官方文档里有的示例直接用了z.object({...})但没写清楚zod是独立依赖你npm install的时候就会漏掉启动后一注册工具就报错。一个能直接跑的最小项目 tsconfig 配置也贴出来省得在配置里浪费时间{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, strict: true, esModuleInterop: true, skipLibCheck: true, outDir: ./dist }, include: [src/**/*] }本地开发时我建议用tsx做热更新npx tsx src/index.ts就能跑不用每次编译。真要发布时再用tsc编译到dist/。1.3 为什么我推荐 TypeScript 而不是 PythonMCP 官方有 Python SDK很多做 AI 的人也更熟 Python但我的体感是如果这个服务器要长期维护、跟 AI 编程工具配合使用TypeScript 的优势会越来越明显。一是类型即文档。工具入参用 zod schema 定义之后SDK 自动把类型推导给客户端AI 编程客户端能看到字段说明生成的调用代码明显更准确。二是跟前端/Cursor 的生态天然衔接调试时可以直接在同一个进程里打印数据不用跨语言联动。三是 SDK 对 TypeScript 的支持最积极新功能往往 TS 版本最先落地。当然 Python 也不是不行如果你的服务器里要跑一堆数据科学代码那 Python 肯定更顺手。我的建议是「看核心逻辑归属」核心逻辑在 TS 生态就 TS核心逻辑在 Python 生态就 Python不要为了统一技术栈硬搬。2. 错误处理从协议层到业务层的三层体系MCP 的容错设计直接决定你这个服务器敢不敢给别人用。我见过太多示例代码里工具函数就是throw new Error(xxx)看着简单实际上客户端拿到的只是一个通用内部错误代码细节全丢了。正确做法是从协议层、业务层、可观测性三个层面分别处理。2.1 协议层错误模型与错误码映射MCP 错误本质上是 JSON-RPC 错误标准错误码的含义和项目里常见的原因对应关系大概是这样的错误码协议名称实际触发场景-32700Parse error客户端发的 JSON 文本无法解析-32600Invalid Request请求 JSON 合法但结构不符合协议要求-32601Method not found客户端调的 method比如工具名不存在-32602Invalid params工具入参校验失败-32603Internal error工具处理函数内部抛出未捕获异常-32000 到 -32099Server error 保留段业务自定义错误比如数据库不可用MCP 的 TypeScript SDK 会把工具处理函数里扔出来的普通Error自动转成-32603把content里的错误信息一并返回。但这里有个隐患如果你把内部异常直接抛出去很可能把数据库连接串、内部文件路径这些敏感信息一并带到客户端。AI 客户端会把这些信息拿来做下一步决策安全边界就不受控了。2.2 用自定义错误类做业务错误透传我在项目里习惯定义一个McpToolError把「业务错误」和「系统内部错误」明确区分开export class McpToolError extends Error { constructor( public code: number, message: string, public details?: Recordstring, unknown ) { super(message); this.name McpToolError; } }然后在工具函数里server.tool( query_orders, { dateRange: z.string().describe(日期范围格式 yyyy-mm-dd~yyyy-mm-dd) }, async ({ dateRange }) { try { const data await db.query(dateRange); return { content: [{ type: text, text: JSON.stringify(data) }] }; } catch (err) { if (err instanceof McpToolError) { throw err; } throw new McpToolError( -32001, 订单查询失败, { stage: db.query, hint: err instanceof Error ? err.message : String(err) } ); } } );这样客户端收到的错误响应里会带上-32001这个业务错误码同时带一个details字段描述具体阶段。AI 看到这个信息以后能在下一次调用中自动避开同样的问题比如把日期格式修正后再请求一次而不是对着一个「Internal error」干瞪眼。这里要补一句MCP SDK 各小版本的错误对象携带自定义 data 的写法略有差别有的版本直接用ErrorWithMetadata有的版本只靠Error的cause传递。建议以你实际安装版本的 TypeScript 类型提示为准核心思路都是「自定义错误码 结构化 details」别照抄老版本代码硬跑。2.3 错误日志与可观测性让故障能被复现工具函数「不漏错」只是第一步真正的痛苦在于线上出问题时你连日志都找不到。MCP 服务器通常是常驻进程建议所有日志直接打到 stdout/stderr且用结构化 JSON 格式方便被 systemd、Docker 日志驱动收集。我在项目里会做一层极简的 logger 封装function log(level: info | warn | error, event: string, data?: unknown) { console.log(JSON.stringify({ ts: new Date().toISOString(), level, event, data })); }工具入口处统一加 try/catch无论工具执行成功还是失败都把「工具名、入参、耗时、错误码」打一条日志。这样出问题时你可以直接从日志索引里定位到具体是哪次调用出了问题思路是「宁可多打不可漏打」。还有一个容易被忽略的点客户端连接时的握手过程initialize/finalized会暴露你服务器的协议版本。AI 客户端比较严格版本不匹配直接拒绝连接。遇到「客户端连不上」的问题第一步先开DEBUG*环境变量看握手日志比对着客户端配置瞎猜高效得多。3. 流式输出把等待时间变成持续反馈3.1 流式输出的需求来源与协议限制MCP 服务器的工具响应在设计上是「一次调用返回一个完整结果」的。如果某个工具需要长时间执行比如调大模型生成一段长文本、查询海量日志、跑一次数据分析客户端就只能在原地等。以 AI 编程客户端的体验来说一个工具跑了 30 秒没有任何中间反馈用户会以为程序卡死了甚至会直接终止对话。所以流式输出的思路本质上是把一次请求拆成「进度反馈 最终结果」两个阶段。MCP 协议原生支持两种「中途反馈」机制一通操作下来我建议这样组合进度通知调用server.sendProgress()推送 0 到 100 的进度值适合有明确百分比的任务。日志通知调用server.sendLogMessage()逐段推送文本片段适合把 AI 生成的 token 一段段「喂」给客户端展示。3.2 在工具函数里实现流式效果的推荐写法我实际项目里比较稳的流式实现是工具函数内部调外部大模型 API 时把它的真实流式输出接到 MCP 的通知机制上server.tool( stream_generate_text, { prompt: z.string() }, async ({ prompt }, extra) { const stream await llm.stream(prompt); let accumulated ; let chunkIndex 0; for await (const chunk of stream) { accumulated chunk.text; chunkIndex 1; // 每拿到 5 个 chunk推送一段日志通知给客户端 if (chunkIndex % 5 0) { await extra.server.sendLogMessage({ level: info, logger: stream-tool, data: { type: partial, delta: chunk.text, accumulated } }); } } return { content: [{ type: text, text: accumulated }], structuredContent: { fullText: accumulated } }; } );注意extra参数MCP SDK 会在注册工具的 handler 里把server实例传进来这样工具函数内部就能主动发通知了。如果你卡在「工具函数里拿不到 server 实例」这一步检查一下你的 handler 是否声明了第二个参数并且调用server.tool()时传的参数是否跟 SDK 版本匹配。3.3 客户端侧的分段消费与「标签返回未完整」处理流式输出的另一半在客户端。这里有个很典型的坑很多前端同学都问过为什么逐字/逐段渲染 markdown 时标题和强调经常变成乱码对应搜到的一个高频问题「标签返回未完整怎么处理」原因很简单markdown 语法是「块级嵌套」的。比如**加粗**要两个星号都闭合才能正确渲染如果你第一段只收到了**加粗直接丢给渲染器页面就会出现一堆未闭合的星号。处理这个问题我建议用一个「累积缓冲 延后渲染」策略客户端维护一个全局缓冲区收到的每个分段先append进去不急着渲染。每次 append 完对整个缓冲区做一次「闭合性检查」数一下**、、_这类行内标记的数量如果成对出现就渲染不成对就继续等。检测到下一段数据到达后把上一次未完整渲染的部分「取回」重新拼接再渲染。实现上其实就是维护一个字符串栈每次渲染前先跑一个简单的配对算法。如果不想自己写常见的 markdown 渲染库也有配incremental rendering的扩展本质都是延迟渲染到元素闭合为止。还有一个体验细节流式输出时尽量保证 delta 是「增量」而不是「全量」。最后那段日志跟最终结果之间如果出现了文本覆盖客户端会闪烁。所以服务端每次推送时accumulated要附带完整累计文本客户端展示时直接整段替换或者只拿 delta 做追加但标记闭合判断要足够聪明。4. TypeScript 类型安全把协议定义变成「活文档」MCP 的自定义服务器如果只是几个内部工具类型随便写写也能跑。但工具一多你会发现自己反复在做「入参是 string返回值是 object」的体力劳动而且 AI 客户端拿到的工具描述也会越来越模糊。真正提升开发效率的关键是在类型层面把「工具契约」定死。4.1 用 zod 把入参 schema 锁死MCP SDK 的server.tool()第一个参数是工具名第二个参数是 zod schema。这个 schema 会同时承担三重职责运行时校验入参不合法直接返回-32602。静态类型推导z.infertypeof inputSchema直接给你的 handler 使用。生成给客户端看的工具描述通过 zod 的.describe()。我现在的习惯是给每个工具单独建一个types.ts把 schema 和 handler 放一起const GetUserInputSchema z.object({ userId: z.string().describe(用户 ID), includeDeleted: z.boolean().optional().describe(是否包含已删除用户默认 false) }); type GetUserInput z.infertypeof GetUserInputSchema; server.tool(get_user, GetUserInputSchema, async (input: GetUserInput) { // ... });这样工具描述、数据校验、TypeScript 类型三处完全来自同一个定义不会出现「文档写的字段跟代码对不上」的情况。4.2 declare global 扩展与 TypeScript 7 版本兼容坑这里展开几个常见的 TS 特性坑都是我在更新依赖时真实遇到的。declare global 扩展全局对象MCP 服务器有时需要挂一些全局上下文比如数据库连接池、配置对象减少工具函数之间重复创建连接。做法是在src/types/global.d.ts里声明declare global { var dbPool: DatabasePool | undefined; var appConfig: AppConfig; } export {};然后在入口文件里给globalThis.dbPool赋值。注意declare global只能出现在模块文件里文件顶部必须有export {}否则 TS 会报「global 声明不能用在全局脚本里」。这个坑很隐蔽很多新人第一次写就挂在这里。baseUrl 弃用问题如果你是从老项目升级上来的很可能会在 tsconfig 里看到baseUrl: ./配合paths的写法。TypeScript 5.x 时会发弃用警告到 7.0 版本会直接停止运行。迁移方式很简单把baseUrl删掉只保留paths并且路径改成相对tsconfig所在目录的写法{ compilerOptions: { paths: { /*: [./src/*] } } }新版paths不再依赖baseUrl这也是很多项目从 TS 5 升到 6/7 时编译彻底挂掉的常见原因。顺手把项目里所有import /xxx检查一遍确保路径都指向真实文件。类型工具与 TS 版本不兼容相关热搜里有一句「vue 类型工具与现有 typescript 7 不兼容」虽然 Vue 场景我不深入但思路是通用的某些类型操作模板字面量类型、递归类型、const类型参数在不同 TS 大版本间行为有差异。建议把typescript固定在~5.x或你测试过没问题的小版本范围不要直接latest。MCP SDK 本身对 TS 版本较敏感升级 TS 大版本前先在 CI 里跑一遍完整编译。4.3 用工厂函数封装大量相似工具当你有很多结构类似的工具比如一堆 CRUD 接口可以做一个defineTool工厂把「取 context、打日志、错误转译」这些公共逻辑统一收口import { z } from zod; type ToolContext { requestId: string; userId: string; }; function defineToolSchema extends z.ZodType( name: string, schema: Schema, handler: (input: z.inferSchema, ctx: ToolContext) Promiseunknown ) { return server.tool(name, schema, async (input: z.inferSchema, extra) { const ctx: ToolContext { requestId: crypto.randomUUID(), userId: extra.sessionId ?? unknown }; log(info, tool_start, { name, requestId: ctx.requestId }); try { const result await handler(input, ctx); log(info, tool_end, { name, requestId: ctx.requestId }); return { content: [{ type: text, text: JSON.stringify(result) }] }; } catch (err) { log(error, tool_error, { name, requestId: ctx.requestId, error: err }); throw err; } }); } defineTool(get_user, GetUserInputSchema, async (input, ctx) { // 直接写业务逻辑不用管日志和错误处理 return { id: input.userId, requestId: ctx.requestId }; });这样业务逻辑里不再散落 try/catch 和日志代码人肉出错率直线下降。AI 客户端看到的工具描述也清晰统一因为它们会优先理解「返回结构化 JSON、带 requestId」这类稳定契约。5. 从本机到生产MCP 服务器的部署路径5.1 本地运行与调试stdio 模式的正确打开方式本地开发时一般是 stdio 模式把这个服务器进程的启动命令配置到客户端里。Cursor 的配置路径是~/.cursor/mcp.json或项目根目录.cursor/mcp.json常见写法{ mcpServers: { internal-query: { command: node, args: [/absolute/path/to/dist/index.js], env: { NODE_ENV: development, DB_URL: mysql://localhost:3306/mydb } } } }注意command和args一定是绝对路径或者能被客户端 PATH 找到的命令。特别是用npx tsx src/index.ts启动时很多客户端默认继承的 PATH 可能不含 nvm 的 node 目录导致进程一启动就退出。调试时先在终端手动跑一遍确定能正常输出「JSON-RPC 握手响应」再去接客户端。Codex 的配置方式类似一般在~/.codex/config.toml里加[mcp_servers.xxx]指向你的启动命令。如果你用的是 Docker 容器内的服务就需要用远程 transport本地 stdio 连不上。5.2 容器化部署与服务化一旦服务端要变成常驻运行我推荐先用 Docker 把它包起来这样环境差异就不会影响运行。一个实践过的多阶段构建示例# build stage FROM node:20-slim AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY tsconfig.json ./ COPY src ./src RUN npm run build # runtime stage FROM node:20-slim AS run WORKDIR /app ENV NODE_ENVproduction COPY --frombuild /app/package*.json ./ RUN npm ci --omitdev COPY --frombuild /app/dist ./dist EXPOSE 3000 CMD [node, dist/index.js]两个注意点npm ci比npm install更快且更可复现但要求仓库里有package-lock.json。建议这个文件一定进版本库。如果你的服务端口需要暴露成 HTTP/SSE记得在 Dockerfile 里EXPOSE并在启动脚本里监听0.0.0.0只监听localhost会让容器外的客户端永远连不上。服务化进程管理方面本地可以用systemd直接管 Node 进程日志重定向到系统 journal生产环境更推荐 Docker 容器编排。但不管用哪种都要确保「进程退出后能自动重启」因为 MCP 客户端如果发现服务器进程死了不会帮你拉起来。5.3 接入 Cursor / Codex / Dify 等客户端的远程配置细节如果客户端和服务器不在同一台机器上比如服务器部署在公网 VPS客户端在自己电脑上就不能用 stdio要用 Streamable HTTP 或者 SSE。以 Streamable HTTP 为例你需要引入对应的 transportimport { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; const server new McpServer({ name: remote-mcp, version: 1.0.0 }); const transport new StreamableHTTPServerTransport({ enableJsonResponse: true, sessionIdGenerator: undefined, onsessioninitialized: (sessionId) { log(info, session_initialized, { sessionId }); } }); // express/fastify 应用把请求转到 transport 处理客户端侧的远程连接配置本质都是提供一个url和可选的headers。以 Cursor 为例{ mcpServers: { remote-query: { url: https://mcp.example.com/mcp, headers: { Authorization: Bearer token } } } }远程模式下安全是必须考虑的「AI 客户端可以调用服务器上的工具」意味着一种类似 RCE 的授权能力。最小化做法是加一层Authorizationheader 校验并且只允许必要的工具名如果要做细粒度管控就得自己设计权限模型。5.4 本地模型联动Ollama 等本地部署模型的流式整合很多团队会把自家大模型用 Ollama 本地部署让 MCP 服务器调用本地模型做分析。部署时最常见的坑是「MCP 服务器在容器里Ollama 在宿主机上」联不通。Ollama 默认监听127.0.0.1:11434容器内部访问宿主机的地址不一定是localhost。简单解法是让 Ollama 监听0.0.0.0或者容器网络可达的宿主机 IP然后 MCP 服务器里把模型地址配成环境变量const OLLAMA_BASE process.env.OLLAMA_HOST ?? http://localhost:11434; export async function chatWithLocalLLM(prompt: string) { const response await fetch(${OLLAMA_BASE}/api/chat, { method: POST, body: JSON.stringify({ model: qwen2.5:7b, messages: [{ role: user, content: prompt }], stream: true }) }); const reader response.body.getReader(); // 逐段读取 SSE 事件把 delta 传递给上层 return reader; }这样既能借助本地模型的流式能力又不对接任何外部 API数据不出内网安全性和响应速度都有保障。6. 几轮实战踩坑后沉淀下来的稳定性经验6.1 一个真实问题的完整排查链路之前我遇到过一个最经典的远程部署问题服务器已经跑在 VPS 上本地 Cursor 远程连不上日志里也没有任何报错。排查链路是这样的你可以照这个思路复现先验证进程活着curl http://localhost:3000/mcp发一个空请求看有没有响应。没有响应就是进程没起来或端口没监听。再看网络层在本地电脑telnet vps-ip 3000看端口通不通。不通就查云厂商安全组和服务器防火墙。再测协议握手用 curl/postman 发一个 MCP initialize 请求看响应是否正常。这一步能定位是 transport 配置问题还是 SDK 版本问题。最后看鉴权如果返回 401说明是你的Authorizationheader 没配上或 token 不对。那次最终原因是安全组把 3000 端口禁了。这个结论说起来很简单但如果没有从进程、网络、协议、鉴权四层逐层排查很可能折腾半天还在看客户端配置。6.2 流式输出场景下的三个冷门注意点通知风暴如果你的工具在循环里频繁调用sendLogMessage会造成大量通知消息挤爆客户端。建议做节流比如每 N 条或每固定时间窗口发一条。长连接超时远程模式如果工具执行时间很长中间没有发送任何数据HTTP 网关可能会主动断开连接。可以在执行过程里定时发心跳日志带type: keepalive的数据片段保持连接活跃。超大响应分段MCP 协议对单条响应消息没有特别严格的长度限制但客户端处理和显示长文本会有压力。如果你的工具返回内容动辄几十 KB建议在服务端把文本拆成多个content块或者压缩后再返回。6.3 关于工具命名的硬性约束MCP 协议对工具名有约束名字里不能带空格、斜杠、$这类特殊字符而且大小写敏感。接 Cursor 时如果不小心起了带横杠的名字前端调用会一直 404。建议统一用小驼峰命名例如queryUserOrders同时在describe里写清楚用途AI 客户端生成调用代码会准确很多。6.4 一点个人体会折腾 MCP 自定义服务器这段时间最大的感受是别一开始就追求「全平台支持」。先在本机用 stdio 模式把一个真实业务流程跑通再逐步往远程、Docker 演进。错误处理三层体系、流式输出、类型契约这些设计一开始就不到位后面返工成本极高。另一个心得是工具函数的description写得越细AI 客户端的调用成功率越高。比如userId字段如果只写「用户 ID」大模型可能传123但你的系统实际要usr_123如果你在describe里写清楚「用户 ID必须带 usr_ 前缀」调用错误率会肉眼可见地下降。最后一个小技巧所有工具尽量返回结构化 JSON并在返回对象里加一个requestId字段。这样以后出问题排查时你拿着 requestId 直接去日志里查那次完整调用链比用「时间 工具名」模糊匹配高效得多。