MCP最近是真的火,从Figma到蓝湖再到通达信,连CATIA、NXOpen这种工业软件都开始提供MCP Server。可要是你只在用现成server,那还谈不上“开发”。这篇不是新手入门,我默认你已经跑通过hello world,知道MCP Server本质上就是给AI模型提供工具、资源和提示的适配层。我重点讲四个进阶方向:错误处理、流式输出、TypeScript工程化、以及部署。这四块是我在把自定义server接入Cursor、Claude Desktop、MCP Inspector过程中反复踩坑总结出来的,照着做能少走很多弯路。
1. 整体设计思路:先把架构想清楚再写代码
1.1 为什么还需要自己写MCP Server
官方生态里现成的MCP Server确实不少,Figma能读设计稿,蓝湖能提资源,Gitee能管仓库,甚至通达信这类股票软件都有社区版MCP。但对于企业内部来说,这些远远不够。比如你想让模型查公司内部的订单数据、调用自主研发的推荐服务、或者把一套遗留系统的REST接口发布成AI工具,现成server根本指望不上。
我见过不少团队一上来就直接在handler里堆业务代码,结果工具一多就乱成一锅粥。这里有个关键认知:MCP Server本质上是把“AI客户端”和“业务能力”解耦的一层适配器,它不负责核心业务逻辑,而是负责把工具的输入输出翻译成模型能理解的schema,并管理调用生命周期。所以写自定义server时,重点不应该放在业务实现上,而是放在协议层、校验层、业务调用层的分离上。
从调用链来看,客户端通过JSON-RPC发来tools/call请求,server解析工具名和参数,执行对应的handler,然后把结果序列化返回。整个过程里,参数校验、错误返回、进度通知、日志推送都是协议层面的事;真正查数据库、调外部API、处理文件是业务层面的事。把这两件事放在同一个文件里,短期内很爽,长期维护就是灾难。
1.2 技术选型:为什么选 TypeScript 而不是 Python
MCP官方SDK提供了Python和TypeScript两套,选型上我推荐TS。理由有几点:一是类型系统对协议字段的约束特别友好,工具入参校验、错误对象结构在编译期就能暴露一堆问题;二是和前端/Node生态的CI、Docker体系衔接顺滑,现在很多团队后端就是Node;三是部署轻量,一个node进程就能跑,不需要虚拟环境。
Python版也不是不行,特别是数据类、AI类项目本来就围绕Python,比如对接本地大模型、做RAG检索,Python生态确实更方便。但如果你写的是对接业务系统的server,TS会让参数schema的可维护性高很多。zod的类型推断、JSON Schema自动生成这一套组合,写起来非常顺手。后面第4部分我会展开讲,这里先提一句:别在TS项目里硬写JSON Schema,用zod生成才是正道。
1.3 项目结构:协议层、校验层、业务层三层分离
直接给一个我常用的目录结构:
mcp-server/ ├── src/ │ ├── index.ts # 入口,启动stdio或HTTP transport │ ├── server.ts # 创建Server实例,注册工具委托 │ ├── tools/ # 工具注册定义(schema + handler) │ │ ├── weather.ts │ │ └── report.ts │ ├── handlers/ # 业务处理器,不关心协议细节 │ │ ├── weather.ts │ │ └── report.ts │ ├── errors.ts # 错误码、业务错误类 │ ├── logger.ts # 结构化日志 │ └── config.ts # 环境变量校验 ├── package.json ├── tsconfig.json ├── tsup.config.ts └── Dockerfile协议层在server.ts和index.ts,负责和客户端聊JSON-RPC;工具层在tools目录,只做schema定义和参数映射;业务处理器在handlers目录,不出现任何MCP相关类型。这样做的好处是:业务逻辑可以脱离MCP独立测试,后续如果协议升级或者换SDK,改动集中在最外层。如果你想让团队里的Java、Go同事也能维护业务代码,这种分层尤其重要,他们根本不需要懂MCP。
2. 错误处理的工程化设计:别让大模型替你猜
2.1 MCP错误协议和错误码
MCP基于JSON-RPC 2.0,错误必须走error响应,而不是在result里塞一个字符串。SDK提供了McpError,你应该在handler里主动throw,而不是返回一个诡异对象。先看错误码列表:
| 错误码 | 名称 | 典型场景 |
|---|---|---|
| -32700 | Parse error | JSON解析失败,通常客户端问题 |
| -32600 | Invalid Request | 请求结构不对 |
| -32601 | Method not found | 工具名不存在 |
| -32602 | Invalid params | 参数校验失败 |
| -32603 | Internal error | 未捕获异常 |
| -32000~-32099 | Server error | 自定义错误范围 |
这里必须说一个我踩过的坑:很多教程写handler时只会返回结果,从不throw。结果工具内部报错了,客户端看到的response还是200,模型拿到一个success:false字段,还得自己去猜哪里出了问题。到了生产环境这非常难受,因为模型会把错误信息当成业务数据来“编故事”。所以我现在的做法是:业务处理中的所有异常,统一转为McpError或自定义Error,保证错误的语义是显式的。
2.2 自定义业务错误码
在errors.ts里定义一套业务错误,复用JSON-RPC的server error段(-32000到-32099),同时给错误附加一个bizCode字符串字段,方便客户端和日志系统做程序化处理。
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types"; export class AppError extends McpError { constructor( public readonly bizCode: string, message: string, data?: unknown ) { super(ErrorCode.InternalError, message, { bizCode, ...(data as object) }); } } export const Errors = { ExternalAPIUnavailable: () => new AppError("EXTERNAL_API_UNAVAILABLE", "上游接口暂时不可用"), Timeout: (ms: number) => new AppError("TIMEOUT", `请求超过 ${ms}ms 未响应`), InvalidConfig: (key: string) => new AppError("INVALID_CONFIG", `配置项 ${key} 缺失或非法`), };这样设计之后,丢给客户端的错误结构是稳定的:code、message、data,data里带bizCode。AI客户端拿到message可以做自然语言反馈,运维拿到bizCode可以做监控告警,两边各取所需。如果你只是throw一个普通Error,SDK虽然也会包装,但错误信息很可能被截断或者结构不符合预期,模型理解起来也更费劲。
2.3 统一异常中间件(wrapper)
在Node的SDK里,工具注册是server.registerTool({ name, description, inputSchema, handler })。如果每个handler里都自己try/catch,代码会非常啰嗦。我封装了一个wrapTool函数,把通用的异常处理、日志、计时全收进去:
function wrapTool<T extends Record<string, unknown>>( toolName: string, handler: (args: T) => Promise<unknown> ) { return async (args: T) => { const start = Date.now(); logger.info(`tool invoked`, { tool: toolName, args }); try { const result = await handler(args); logger.info(`tool success`, { tool: toolName, duration: Date.now() - start, }); return result; } catch (err) { if (err instanceof McpError) { logger.warn(`tool mcp error`, { tool: toolName, err }); throw err; } // 非MCP错误,打完整堆栈,但抛出去时只保留安全信息 logger.error(`tool failed`, { tool: toolName, error: err }); throw new AppError("INTERNAL_ERROR", "工具执行失败,请稍后重试"); } }; }注意这里有个安全设计:完整堆栈只在服务端日志里,丢给客户端的信息绝不包含内部路径、数据库连接串、第三方API密钥。因为模型可能把这个错误信息原样打印给用户,甚至写进生成的文件里。一旦泄露内部IP或token,后果很严重。
2.4 错误日志与追踪
错误处理不只是“抛个异常”,还包括记录。我建议为每个请求生成requestId,在handler入口埋入日志,结束和异常时各打一条。这样排查问题会舒服很多:直接在MCP Inspector或者客户端日志里看到requestId,再回服务端日志grep。
import crypto from "node:crypto"; export function createRequestId(): string { return crypto.randomUUID(); }在wrapTool里,尽量把requestId注入到日志上下文中。如果没有日志平台,也可以简单地在服务端打印出来。我通常会把args、耗时、错误码都打进去,这样大多数问题不需要复现就能定位。这里提醒一下:参数里如果包含敏感信息(比如用户token、密钥),打日志前要脱敏,写个sanitize函数处理一下。
2.5 边界情况:超时、取消与流中断
请求超时是自定义server最常见的故障来源。如果tool要去调用外部REST接口,务必给所有fetch加AbortController超时。不做超时控制,模型很可能被一个慢接口卡到连接断开,客户端那边表现成“工具无响应”。
async function fetchWithTimeout(url: string, timeoutMs = 10_000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { signal: controller.signal }); } finally { clearTimeout(timer); } }另外,MCP的请求是可以被客户端取消的,如果handler里持有长任务,需要监听取消信号,及时释放资源。SDK内部对取消通知有处理,但你自己的业务循环里要定期检查,比如每处理100条数据就判断一下是否被中止,避免做无用功。
3. 流式输出:让长文本和大任务不再“卡死”
3.1 为什么需要流式:进度反馈 vs 内容流
MCP场景里的流式输出有两个维度。一是进度可视化:工具在跑一个10分钟的数据处理任务,客户端需要知道你执行到哪一步;二是长文本输出:工具返回一个几万字的报告,一次性塞进JSON response里既不现实,用户在UI上也看不到中间过程。这两个问题不解决,自定义server一遇到大任务就会像个黑盒,体验很糟糕。
3.2 用notifications/progress做进度反馈
SDK的server实例有sendNotification方法,可以在handler执行过程中推送通知。看下面这个例子:
await server.sendNotification({ method: "notifications/progress", params: { progressToken, progress: 30, total: 100, message: "正在拉取数据", }, });progressToken是从客户端请求参数里带过来的,别自己造。客户端(比如MCP Inspector)会在界面上显示进度条。长任务里建议把进度计算独立成一个辅助函数,不要散落在业务代码里。有一个容易忽略的点:不是所有客户端都会传progressToken,如果没传,你推送进度通知可能无效甚至报错,所以要判空。
3.3 用logMessage把中间日志发给客户端
除了进度,还可以用notifications/message推送结构化日志。这个在调试时特别有用:客户端界面直接能看到你服务端的执行日志,不用每次都戳服务器看文件。
await server.sendLogMessage({ level: "info", logger: "report-tool", data: "开始生成报告,共5000行", });注意有些客户端对logMessage的渲染不一定很友好,所以不要把敏感信息发过去,只发必要的过程信息。我在调试阶段会用这个功能,生产环境下默认不开启,或者只发WARN和ERROR级别,避免日志刷屏。
3.4 工具结果的流式返回:ReadableStream 与分块
再说长文本。现在的SDK支持工具结果返回ReadableStream,以Streamable HTTP传输时尤其友好。如果你的文本可以分批生成,就返回一个流,客户端可以边收边渲染。
import { Readable } from "node:stream"; await server.registerTool({ name: "stream_report", description: "流式生成一份超长报告", inputSchema: { type: "object", properties: { chunks: { type: "number", description: "分块数量" } }, }, handler: async (args) => { const chunks = args.chunks ?? 10; const stream = new ReadableStream({ async start(controller) { for (let i = 0; i < chunks; i++) { controller.enqueue({ type: "text", text: `第${i + 1}块内容\n` }); await new Promise((r) => setTimeout(r, 100)); } controller.close(); }, }); return { content: stream }; }, });这里最好先确认目标客户端是否支持流类型,因为有些客户端实现得早,只认纯数组content,遇到流会直接报错。我一般默认做兼容策略:小结果返回数组,大结果返回流。具体阈值可以根据你的场景定,比如超过20KB就切流。
3.5 流式节奏与背压控制
流式输出不是无脑enqueue。如果生成速度比客户端消费速度快太多,内存会爆。比较稳妥的做法是做节流:比如每100ms enqueue一条,或者限制队列长度。这个和你对接的LLM API、下游数据库都有关系,需要压测后定。个人经验是:先小批量(每512字符或每200ms)推,观察客户端表现,再逐步放大。
还有一个细节:流结束后的清理工作要做好。如果中途出错,要调用controller.error(err)而不是直接close,让客户端知道这次流不完整。不然客户端等半天,只拿到一半数据,还以为是自己网络问题。
4. TypeScript 工程化:类型即文档,校验即边界
4.1 为什么TS写MCP体验更好
MCP的inputSchema本质上是一个JSON Schema,而TS的类型和zod schema可以做到几乎无缝映射。入参校验这个环节,zod比手写JSON Schema要舒服很多,而且报错信息对模型非常友好。模型需要在工具列表中读懂“这个参数是什么意思、取值范围是什么”,如果schema里都是晦涩的描述,它就会填错。
另外,TS还有declare global、命名空间这些特性可以做类型增强,不过对MCP server来说最常用的还是zod + 泛型这套组合。类型即文档,校验即边界,这就是TS能提升MCP开发效率的核心原因。
4.2 用zod做参数校验并与SDK对接
MCP SDK的inputSchema需要的是JSON Schema对象,所以要么手写JSON Schema,要么把zod对象转成JSON Schema。官方推荐借助zod-to-json-schema这类工具:
import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; const WeatherArgs = z.object({ city: z.string().describe("城市名,例如北京"), unit: z.enum(["celsius", "fahrenheit"]).default("celsius"), }); await server.registerTool({ name: "get_weather", description: "查询实时天气", inputSchema: zodToJsonSchema(WeatherArgs, "WeatherArgs"), handler: async (args) => { const parsed = WeatherArgs.parse(args); // typed: { city: string; unit: "celsius" | "fahrenheit" } // ... }, });zod的好处是默认值、枚举、嵌套元组都表达得很清楚,生成给模型看的schema可读性也高。描述里要写清楚枚举的取值含义,模型才能正确填参。比如unit字段,你不写celsius和fahrenheit的语义,模型可能会传一个“C”。
4.3 泛型工具函数减少样板代码
实际项目里,很多工具注册逻辑是重复的。我推荐封装一个工厂函数:
export function defineTool<T extends z.ZodTypeAny>( name: string, description: string, schema: T, handler: (args: z.infer<T>) => Promise<unknown> ) { return { name, description, inputSchema: zodToJsonSchema(schema, name), handler: async (args: unknown) => { const safe = schema.parse(args); return handler(safe); }, } as const; }然后每个工具文件就只声明“输入是什么、做什么”,不再重复build流程。比如这样:
export const getWeatherTool = defineTool( "get_weather", "查询实时天气", WeatherArgs, async ({ city, unit }) => { // 这里city和unit都有类型了 return { content: [{ type: "text", text: `天气信息:...` }] }; } );代码量至少降一半,而且后续切SDK或调整校验逻辑时只改包装函数,读者和维护者都会感谢你。
4.4 编译与打包:ESM、CJS、tsup
MCP SDK新版本对ESM/CJS都支持,但部署环境不同,踩过的坑也不少。我现在的做法是用tsup同时产出ESM和CJS,并生成d.ts。配一个tsup.config.ts:
import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts"], format: ["esm", "cjs"], dts: true, sourcemap: true, clean: true, target: "node20", });注意:如果你的server要给别人通过npx运行,建议在package.json里加上bin字段,并且标记"type": "module"或"commonjs"要和产物对应,否则会出现ERR_REQUIRE_ESM或require报错。另外,包里不要带上ts-node,部署时直接跑编译后的js。我在早期犯过把ts-node当运行时用的错,结果一上生产环境就各种坑。
4.5 调试技巧:MCP Inspector + VSCode
写MCP server时调试是非常关键的。官方提供了@modelcontextprotocol/inspector命令行工具,可以图形化测试server。用法大致是执行npx @modelcontextprotocol/inspector node dist/index.js,然后浏览器访问调试端口。
调试小技巧:在index.ts里加一个环境变量开关,让server支持stdio和HTTP两种模式。本地调试用stdio + inspector,远端部署用Streamable HTTP,同一套代码都能跑。默认情况下,如果检测到环境变量MCP_SERVER_TRANSPORT=http,就启动HTTP模式,否则走stdio。这样不用维护两套入口,而且非常方便在VSCode里打断点调试。
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; const server = createMcpServer(); if (process.env.MCP_SERVER_TRANSPORT === "http") { // 挂到Express等HTTP框架上 } else { const transport = new StdioServerTransport(); await server.connect(transport); }5. 部署与运维:从本地跑到公网,别忽略传输和鉴权
5.1 本地开发:stdio 模式
本地调试时,MCP server通常以stdio传输启动。客户端(Cursor、Claude Code、Cline)会在子进程里启动你的node入口。这里有一个关键约束:不能往stdout打印任何业务日志,因为stdout是JSON-RPC通道。之前有个同事把console.log写在handler里,结果客户端直接解析失败,排查了半天才发现是日志污染了协议通道。所有调试日志都要走stderr或logger。
如果你用PM2或systemd管理stdio进程,也要注意这一点。有些进程管理器会把stdout重定向到文件,如果这个日志文件被误读,客户端一样会出问题。
5.2 远程部署:Streamable HTTP / SSE
如果想让server被远程客户端调用,就得用HTTP传输。MCP新版推荐Streamable HTTP Transport,它比老SSE多了“可写”能力。实现一般是这样:
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import express from "express"; const app = express(); app.use(express.json()); const server = createMcpServer(); app.post("/mcp", async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: (sessionId) => { console.log(`session started: ${sessionId}`); }, }); res.on("close", () => transport.close()); await server.connect(transport); await transport.handleRequest(req, res); });注意一个永久性的坑:HTTP传输是有状态session的,客户端必须带上session id,你不能每次请求都new transport然后把老连接顶掉。生产环境建议用内存或Redis保存session。如果你发现客户端连接一多就频繁断,大概率就是session管理出了问题。
5.3 Docker化部署
Docker部署建议用多阶段构建,减小镜像体积:
FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-alpine WORKDIR /app COPY --from=build /app/dist ./dist COPY package*.json ./ RUN npm ci --omit=dev ENV NODE_ENV=production EXPOSE 3000 CMD ["node", "dist/index.js"]然后docker-compose里记得做健康检查。如果server本身没提供/healthz接口,可以在入口多加一个路由,或直接用tcp对端口探测。
services: mcp-server: build: . ports: - "3000:3000" environment: - MCP_SERVER_TRANSPORT=http - MCP_API_TOKEN=${MCP_API_TOKEN} healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"] interval: 30s timeout: 5s retries: 3有一点要注意:alpine镜像里默认没有wget,健康检查会失败,所以要么在Dockerfile里装busybox-wget,要么用node命令做HTTP请求。
5.4 环境变量与鉴权
远程部署一定要做鉴权。最简单的是在应用层检查Authorization Bearer token,或者用Nginx做basic auth。环境变量不要写死在代码里。启动时用zod校验环境变量:
const configSchema = z.object({ PORT: z.coerce.number().default(3000), MCP_API_TOKEN: z.string().min(32), DATABASE_URL: z.string().url(), }); const config = configSchema.parse(process.env);配置不对直接启动失败,比运行到一半再报错要舒服。而且我建议在README里写清楚每个环境变量的用途和示例值,不然过几个月你自己都会忘。
5.5 反向代理与公网发布
如果要暴露到公网,不要让Node进程直接面对公网,建议前面挂Nginx:
server { listen 443 ssl; server_name mcp.example.com; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 300s; } }这里proxy_read_timeout是重点。我踩过很久的坑:默认60秒,工具执行一旦超过60s,Nginx就返回504,客户端只能看到空响应。如果你流转的任务可能跑几分钟,这个值要相应调大。另外,如果启用了HTTP/2或gzip,注意有些老客户端对SSE或流式响应在压缩层表现不佳,最好对/mcp路径关闭gzip。
5.6 客户端适配与兼容
不同客户端对MCP server的支持程度不一样,比如Cursor、Claude Desktop、Cline、MCP Inspector对传输方式、session机制的支持有差异。建议在README里写明:本地通过stdio使用,远端通过URL+token使用。另外,如果客户端支持自定义请求头,配置时把Authorization头填好。Figma MCP为啥要问token在哪获取,就是因为很多MCP server用HTTP模式部署时都靠token鉴权,这一步绕不开。
6. 常见问题与排查技巧实录
6.1 工具就是不出现在客户端里
大概率是注册工具时没有正确实现listToolsSchema,或者服务器没有正确启动。排查顺序:先用MCP Inspector跑一遍,确认工具列表正常;再看客户端进程的stderr日志是否有错误;再看是否因为缓存导致旧的工具列表被客户端保留,重启客户端再试。还有一个容易被忽略的原因:工具名和协议保留关键字冲突,比如名字叫“connect”或“close”,可能导致客户端过滤掉。
6.2 参数校验总是失败,模型传参乱来
模型对schema的理解取决于描述质量。JSON Schema里的description要写清格式和示例,比如日期字段写上“格式YYYY-MM-DD”,枚举字段写明含义。必要时用zod的.transform()先做归一化再进业务。如果你的工具要接收数组,务必在schema里定义items类型,否则模型可能传一个字符串进来。还有一点:给schema加上additionalProperties:false,能防止模型传多余的字段被静默忽略,也避免意外副作用。
6.3 长任务一执行就连接中断
先检查超时设置,Node侧、Nginx侧、客户端侧都有各自的超时。如果用的是stdio传输,客户端子进程可能因为stdout缓冲溢出被杀掉,这时要减少一次性输出,改用流式。如果是HTTP模式,检查proxy_read_timeout和SDK内部的timeout配置。再有一点就是心跳机制,部分客户端会定期发ping,如果server长时间不响应,客户端会主动断开。
6.4 部署后发现客户端收不到流式输出
确认transport版本与客户端兼容,MCP的动态端点格式(POST /mcp)需要客户端支持。如果客户端比较老,可能需要退回到SSE模式。还有一点:流式输出时别手动关闭HTTP连接,应该让流结束自动触发。很多人习惯在handler末尾加res.end(),这会把流截断,务必注意。
6.5 日志打了一堆,业务还是难定位
建议在每条日志上带requestId,并在工具入口、出口、异常三处都打点。日志级别也要设计好:info记录调用,debug记录参数和中间过程,error记录异常堆栈。生产环境把日志收集到统一的日志平台,不然排查效率极低。我个人的习惯是给每个工具一个单独的子日志logger,这样过滤起来非常方便。
6.6 一些小坑速查
- 永远不要把console.log写到stdout,会污染JSON-RPC通道。
- JSON Schema里字段建议加additionalProperties:false,防止模型传多余字段进来。
- 工具返回的content数组里每种type(text/image/resource)的格式要符合SDK定义,一个字段顺序错了客户端就可能解析失败。
- 升级SDK版本时务必看CHANGELOG,MCP协议还在快速演进,很多API会破坏性变更。
- 如果用stdout做stdio传输,测试时避免用console.log输出对象,对象会被强制转字符串,JSON结构就坏了。
最后说句实际的:MCP自定义服务器开发没有太多玄学,把协议错误处理、流式输出、工程化、部署这四块吃透,生产环境的99%问题都能解决。我在实际项目里最大的体会是,不要一门心思堆业务工具,先花时间把错误模型和传输层搞稳,后面加工具只是体力活。另外,MCP生态还在高速变化,SDK版本更新很快,建议动手前先看官方仓库的README和CHANGELOG,也多在社区看看成功的case。踩过几次坑之后,你会发现自己对协议的理解会深很多,再去看什么Figma MCP、蓝湖MCP的实现,基本一眼就能看出他们的架构设计思路。