MCP Server 写起来容易,做好却很难
我接触 MCP(Model Context Protocol)已经有段时间了。一开始我也以为,MCP Server 就是一个把工具包上一层协议壳的东西,写几个 function 就行。但真正动手把服务做到能上线、能扛住真实调用、能被人稳定使用时,才发现里面的坑一个接一个:错误处理没有统一规范,客户端那边拿到的报错信息完全是“天书”;流式输出没设计好,AI 调用一个耗时工具时体验极其糟糕;TypeScript 工程配置不当,构建出来的产物在客户端侧根本无法加载;部署到远程之后,stdio 方式失效,HTTP 传输又有一堆协议细节要处理。
这篇文章就把我踩过的坑、试过的方案、最终沉淀下来的做法整理成一份完整笔记,覆盖错误处理、流式输出、TypeScript 工程化、远程部署这四个进阶方向。不管你是刚入门 MCP 开发,还是已经写了几个工具但总觉得代码不够稳,这篇文章都值得你花十分钟认真读一遍。
1. 开发前必须建立的两个认知框架
1.1 先想清楚:MCP Server 到底在解决什么问题
MCP 本质上是一套“AI 应用与外部工具/数据源之间的标准化通信协议”。它的价值可以类比成 USB-C 接口:过去每个 AI 应用要接入不同的工具,都得单独写适配代码,现在有了统一标准,任何支持 MCP 的客户端(Claude Desktop、各类 Agent 框架、自研应用)都可以用同一套方式去发现和调用你提供的能力。
Server 对外暴露的无非是三类能力:
- Tools(工具):可被模型调用的函数式能力,比如查数据库、调第三方 API、执行计算。
- Resources(资源):可被读取的数据内容,比如文件、文档片段、配置信息。
- Prompts(提示词模板):预定义好的交互模板,帮助模型以正确姿势处理特定场景。
很多人容易把 Agent Skill 和 MCP 混为一谈。我的理解是:Agent Skill 更偏向“给 Agent 封装一项技能或工作流程”,它关注的是“怎么做这件事”;MCP 则是“把现有工具和数据以标准协议暴露出来”,它关注的是“如何被标准化调用”。两者不是替代关系,实际项目中经常配合使用——MCP 负责打通能力,Skill 负责编排用法。
1.2 清醒一点:这几个难点不是“附加题”,是基本功
很多人写第一个 MCP Server 时只关注“怎么把函数暴露出去”,等真正面对真实用户(或真实的大模型调用方)时,才会意识到:
- 错误处理不规范,大模型拿到的错误信息无法理解,无法自主修正,直接导致调用失败。
- 流式输出缺失,面对耗时任务,客户端一直等不到任何反馈,交互体验极差。
- TypeScript 工程化薄弱,类型混乱导致维护困难,构建产物出现各种诡异问题。
- 部署方式选错,本地好好的服务部署到远程就失效,因为 stdio 方式本身就是为“本地进程”设计的。
这四项不是进阶之后才考虑的事情。从第一天写 Server 开始,就应该用生产级的标准要求自己。下面我会把这四项逐一拆开,讲清楚背后的原理和具体的实施方案。
1.3 环境准备:TypeScript 工程的正确初始化姿势
在动手写业务逻辑之前,先把工程基础打牢。我推荐用 pnpm + TypeScript + Node.js 18+ 的组合,SDK 选择官方维护的@modelcontextprotocol/sdk。初始化步骤很简单:
mkdir my-mcp-server cd my-mcp-server pnpm init pnpm add @modelcontextprotocol/sdk zod pnpm add -D typescript @types/node tsx这里我把zod也加进来了,后面讲参数校验时会用到,它和 MCP SDK 的配合非常顺畅。tsx用来在开发阶段直接运行 TypeScript 代码,省去每次改动都要编译的烦恼;最终发布前再用tsc产出干净的编译结果。
目录结构建议按“入口 + 工具注册 + 工具实现 + 公共模块”拆分,避免把一堆工具逻辑堆在index.ts里。基础结构可以这样:
src/ index.ts // 服务入口,负责创建 Server、注册能力 tools/ // 每个工具一个文件,导出工具定义和实现 resources/ // 资源定义与读取逻辑 lib/ // 公共模块:错误码、日志、配置等2. 错误处理的正确姿势:不要让调用方猜谜
2.1 底层逻辑:MCP 走的是 JSON-RPC 2.0,错误码不是随便定义的
MCP 的通信协议建立在 JSON-RPC 2.0 之上,这意味着服务端返回的错误必须符合 JSON-RPC 的规范。JSON-RPC 标准定义了几个核心错误码:
| 错误码 | 含义 | 使用场景 |
|---|---|---|
| -32700 | 解析错误 | 请求不是合法的 JSON |
| -32600 | 无效请求 | 请求内容不符合 JSON-RPC 规范 |
| -32601 | 方法不存在 | 调用的工具/方法未注册 |
| -32602 | 无效参数 | 入参校验不通过 |
| -32603 | 内部错误 | 服务端执行过程中发生了未预期的异常 |
| -32000 及以上 | 服务端自定义错误 | 业务层面的特定错误场景 |
我见过不少 MCP Server 的错误处理就是“一锤子买卖”:所有错误都返回internal error,或者干脆把底层异常堆栈直接抛出去。前者让调用方完全无法定位问题,后者则泄露了服务端内部实现细节,既不安全也不专业。
2.2 推荐的错误处理实践:统一封装,分类透传
在服务端代码里,我建议把所有工具的执行逻辑都包在一个统一错误处理层里,对错误做分级处理。核心原则是:业务可预期错误明确返回,未知异常统一兜底并记录日志。
先定义一个自定义错误类:
export class McpToolError extends Error { constructor( message: string, public readonly code: number = -32000, public readonly details?: unknown ) { super(message); this.name = "McpToolError"; } }然后写一个统一的执行包装函数:
export function withErrorHandling(handler: (args: unknown) => unknown) { return async (args: unknown) => { try { const result = await handler(args); return { content: [ { type: "text", text: JSON.stringify(result) }, ], }; } catch (error) { if (error instanceof McpToolError) { // 业务预期错误:将错误码和信息透出 return { isError: true, content: [ { type: "text", text: `${error.message}` }, ], }; } // 未知错误:记录日志并返回通用错误信息 console.error("[tool_error]", error); return { isError: true, content: [ { type: "text", text: "服务内部错误,请稍后重试" }, ], }; } }; }这里有两个值得展开的设计细节:
isError: true要显式返回。很多初写 MCP Server 的人不知道这个字段,一旦工具内部抛错,客户端拿到的不是结构化的错误响应,而是传输层面的异常,模型完全无法根据错误信息自我修正。- 业务错误和系统错误严格区分。参数不合法、资源不存在这类错误,属于“业务可预期错误”,要给出清晰、具体的信息;数据库连接失败、第三方 API 超时这类错误,属于“系统未知错误”,不要把底层细节暴露给调用方,而是通过日志记录下来,由开发者去排查。
2.3 避坑经验:我犯过的三个错误处理失误
失误一:把底层异常直接抛出
一开始我写工具时,直接让数据库访问的异常向上抛,结果客户端拿到的报错像这样:“SQLite3Error: no such table: users”。这个信息对调用方没有任何帮助,而且暴露了底层存储结构。正确的做法是捕获后转换为“数据访问失败,请检查数据源是否存在”这类业务化信息。
失误二:忽略参数校验
MCP 的inputSchema定义了工具入参的标准,但很多人只定义了类型,不写严格校验。结果模型传进来的参数千奇百怪,服务端执行时才发现缺字段、类型错误。我的实践是:用 zod 定义 schema,在服务端执行业务逻辑之前先做一轮校验,不通过的参数直接返回-32602。
失误三:所有错误都返回同样的信息
如果你把所有失败场景都包装成同一句话,大模型在调用时就没有依据去调整参数或改变策略。比如一个天气查询工具,至少要区分“城市不存在”“API 密钥无效”“上游服务超时”这几种情况,模型才能根据提示做出正确的下一步选择。
3. 流式输出设计:让耗时任务不再“干等”
3.1 流式输出到底指什么
这里要澄清一个关键概念:MCP 中的流式输出,和 OpenAI 那种 token 级流式输出不是一回事。OpenAI 的流式是“文本生成过程中逐字吐出结果”,MCP 的流式输出更接近“长连接上持续推送任务状态和阶段性结果”。
MCP 的 SDK 在设计上允许 Server 通过多次返回“内容块(content block)”的方式,实现渐进式响应。也就是说,工具调用不一定要一次性返回最终结果,可以分多次返回进度信息、中间状态,最后再返回完整结果。
3.2 设计原则:短任务直接返回,长任务边跑边报
我的经验法则是:如果一个任务能在几百毫秒内完成,直接返回最终结果就好,不要画蛇添足做流式;如果一个任务耗时可能是几秒甚至几十秒(比如查一堆数据库、调多个第三方 API、跑一次 RAG 检索),就必须做渐进式反馈。
为什么?因为当 AI 应用调用 MCP Server 时,用户往往盯着界面等待。如果几秒内没有反馈,用户的第一反应是“卡死了”。而在 Agent 自动化调用的场景下,长时间无响应可能导致客户端超时甚至判定任务失败。
3.3 实操方案:从普通工具到流式工具
在 MCP SDK 中,工具返回的结构是content数组。普通工具一次返回最终结果,流式工具则分多次推送。
const searchTool = { name: "deep_search", description: "执行多数据源深度搜索,适合耗时较长的查询任务", inputSchema: { type: "object", properties: { query: { type: "string" }, }, required: ["query"], }, async execute(args: { query: string }, emit: (content: unknown) => void) { emit({ type: "text", text: "已收到搜索请求,开始连接数据源..." }); await searchSourceA(args.query); emit({ type: "text", text: "数据源A检索完成,命中 3 条结果,继续检索数据源B..." }); await searchSourceB(args.query); emit({ type: "text", text: "数据源B检索完成,正在聚合去重..." }); const finalResults = mergeAndRank(args.query); emit({ type: "text", text: `搜索完成,共 ${finalResults.length} 条结果。`, }); return { content: [ { type: "text", text: JSON.stringify(finalResults) }, ], }; }, };这里要特别说明:emit的调用并不意味着立刻返回给客户端,它更像是在任务进行中持续推送“进度事件”,最终的return才是真正的工具调用结果。实际实现时不同 SDK 版本可能细节略有差异,请以官方文档为准。
3.4 流式设计中的几个关键细节
进度信息要有“阶段性”。不要只报“正在处理”,要明确告诉调用方“正在做什么、已经完成了什么”。这不仅是用户体验问题,也是 Agent 调用的上下文质量问题——模型可以从中间信息中判断是否需要继续等待。
中间消息和最终结果要结构清晰。中间进度消息是给人(或模型)看的叙述性文本,最终结果是结构化数据。两者要区分开,不能混在一起,否则模型拿到结果时无法区分“过程”和“结论”。
长任务要有超时和取消机制。流式输出不是“无限等待”的借口。如果某个任务超过设定阈值(比如 60 秒),应该主动结束并返回部分结果或超时信息。另外,客户端如果已经放弃等待,Server 端要能感知到并终止任务执行,避免资源泄漏。
我在实际项目里踩过一个坑:没有实现取消机制,结果某个耗时任务因为上游 API 无响应,导致 Server 的进程一直挂着,最后内存被打满。后来在实现层加了AbortController,把每个任务和取消信号绑定,才彻底解决这个问题。
4. TypeScript 工程化:从“能跑”到“好维护”
4.1 严格模式不是可选配置,是底线
MCP Server 的代码往往涉及各种外部数据源的类型定义,如果 TypeScript 的严格模式没开,类型检查形同虚设,编译期间逃过的问题都会在运行时爆发。
我建议在tsconfig.json里至少开启这些配置:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noImplicitAny": true, "strictNullChecks": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "./dist", "rootDir": "./src", "declaration": true, "declarationMap": true, "sourceMap": true }, "include": ["src/**/*"] }这里我特别提一下exactOptionalPropertyTypes,这是很多人容易忽略的一个配置。默认情况下,TypeScript 允许{ optionalProp: undefined }这种赋值,这在普通应用中问题不大,但在 MCP 工具定义中,如果某个可选属性被显式设为undefined,传输层可能会把这个字段当成存在但无效,导致协议层报错。开启这个配置后,类型系统会强制你正确地处理可选属性,从源头上规避这类问题。
4.2 构建产物:exports 字段决定成败
TypeScript 编译只是第一步,更关键的是package.json里的exports字段。MCP 客户端在加载你的 Server 时,会根据exports找到正确的入口文件。
{ "name": "my-mcp-server", "type": "module", "bin": { "my-mcp-server": "./dist/index.js" }, "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "files": ["dist"], "scripts": { "build": "tsc", "dev": "tsx watch src/index.ts", "start": "node dist/index.js" } }很多人在本地开发时用tsx直接跑源码,一切正常;发布到生产环境后却出现“模块找不到”或“入口文件不存在”的报错,大概率就是exports字段没配好,或者files字段没把dist目录包含进去。
另外一个常见坑是"type": "module"。现代 Node.js 支持 ESM 之后,MCP SDK 的推荐用法也是 ESM。如果你在package.json里没有设置"type": "module",却又在源码里使用了import语法,编译后的.js文件会被 Node 当成 CommonJS 解析,直接报语法错误。这个坑我踩过,一行配置花了我半天时间排查。
4.3 环境变量管理:别让配置散落一地
MCP Server 一旦部署到不同环境(本地、测试、生产),环境变量的管理就成了一个容易被忽视的问题。我见过太多代码直接写process.env.API_KEY,散落在各个文件里,改一个配置要全局搜索。
我的做法是在src/lib/config.ts里统一管理:
import { z } from "zod"; const configSchema = z.object({ logLevel: z.string().default("info"), apiKey: z.string().optional(), databaseUrl: z.string().default("sqlite:./data.db"), maxTaskDurationMs: z.number().default(30000), }); export type AppConfig = z.infer<typeof configSchema>; export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const parsed = configSchema.safeParse({ logLevel: env.LOG_LEVEL, apiKey: env.API_KEY, databaseUrl: env.DATABASE_URL, maxTaskDurationMs: env.MAX_TASK_DURATION_MS ? parseInt(env.MAX_TASK_DURATION_MS, 10) : undefined, }); if (!parsed.success) { throw new Error(`配置校验失败: ${parsed.error.message}`); } return parsed.data; }用 zod 做配置校验的好处是:如果环境变量缺失或类型不对,启动阶段就报错,而不是等到运行时才出问题。这种“快速失败”的哲学,在服务类应用里非常重要。
4.4 运行时健康检查:部署后的第一道防线
MCP Server 对外是常驻服务,必须提供健康检查能力。你可以注册一个health_check工具,返回服务的存活状态、版本号、资源占用等信息。
const healthCheckTool = { name: "health_check", description: "检查服务健康状态,返回版本号和基本资源信息", inputSchema: { type: "object", properties: {}, }, execute: async () => { const memoryUsage = process.memoryUsage(); return { content: [ { type: "text", text: JSON.stringify({ status: "healthy", version: "1.0.0", uptimeSeconds: process.uptime(), memoryMB: Math.round(memoryUsage.rss / 1024 / 1024), }), }, ], }; }, };这个工具在本地开发时看似没用,部署后却是排查问题的利器。我遇到过部署完的新版本服务一直报错,但日志又没输出有效信息的诡异情况,靠着一个health_check工具确认了进程确实活着、能接请求,才把排查方向转到业务逻辑上。
5. 部署上线:从本地到公网,传输方式是第一道分水岭
5.1 两种传输方式:stdio 与 HTTP/SSE
MCP 的传输方式直接决定部署策略。
| 特性 | stdio 方式 | HTTP/SSE 方式 |
|---|---|---|
| 适用场景 | 本地开发、单机使用 | 远程服务、多人共享 |
| 客户端配置 | 填写启动命令(npx/node) | 填写服务 URL |
| 工作方式 | 客户端拉起子进程并通信 | 客户端通过 HTTP 请求调用 |
| 优点 | 简单直观、权限天然隔离 | 支持远程访问、可横向扩展 |
| 缺点 | 只能本机使用 | 需要处理网络、认证、安全 |
很多人的困惑是:本地用 stdio 开发得好好的,部署到服务器后,在客户端配置里填了服务器地址却不生效。原因是你的 Server 根本没有启动 HTTP/SSE 模式的监听服务。
我建议的开发部署路径是:
- 本地开发阶段:用 stdio 方式,配合
@modelcontextprotocol/inspector调试,快速迭代业务逻辑。 - 服务部署阶段:用
SSEServerTransport或StreamableHTTPServerTransport改为 HTTP/SSE 模式,暴露公网地址。 - 客户端接入:将连接方式从“命令启动”改为“远程 URL”。
5.2 远程部署实操:反代 + 守护 + 安全
下面是我实际部署一个生产级 MCP Server 时的完整链路:
第一步:改造启动代码
将服务从 stdio 模式改为 HTTP/SSE 模式。入口代码基本长这样:
import express from "express"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; const app = express(); app.use(express.json()); const server = new Server( { name: "my-mcp-server", version: "1.0.0", }, { capabilities: { tools: {}, resources: {}, }, } ); app.post("/mcp", async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); res.on("close", () => { transport.close(); }); await server.connect(transport); await transport.handleRequest(req, res); }); app.listen(3000, () => { console.log("MCP Server listening on port 3000"); });注意,这里只是一个最小示例,真实生产环境还需要处理会话管理和多请求路由,建议以 MCP SDK 官方文档中的 HTTP 传输实现为准。核心就是:收到 HTTP 请求,建立传输通道,接入 MCP Server 实例。
第二步:配置 Nginx 反代和 HTTPS
强烈建议用 Nginx 做反向代理,承担 TLS 终止、负载均衡、访问控制等职责。MCP 客户端通过公网访问时,要求使用https协议,证书可以免费申请并自动续期。
server { listen 443 ssl; server_name mcp.example.com; ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem; location /mcp { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 300s; } }我这里把proxy_read_timeout设置为 300 秒,是因为 MCP 工具调用可能会有较长的处理时间,默认的 60 秒可能不够。
第三步:进程守护与日志管理
目前我用得很顺手的是 PM2,配置一个ecosystem.config.cjs:
module.exports = { apps: [ { name: "mcp-server", script: "dist/index.js", instances: 1, autorestart: true, max_memory_restart: "500M", env: { NODE_ENV: "production", LOG_LEVEL: "info", }, out_file: "/var/log/mcp-server/out.log", error_file: "/var/log/mcp-server/error.log", merge_logs: true, kill_timeout: 5000, }, ], };这个kill_timeout: 5000是我加上的一个关键配置。MCP Server 在收到关闭信号时,需要时间优雅地断开现有连接、清理资源。如果设置太短,PM2 会强制杀掉进程,正在处理的请求就会中断。
5.3 实际案例:设计稿 MCP 服务的接入方式
最近注意到像蓝湖 MCP 这类产品很火,本质上解决的是“让 AI 读取设计稿标注和数据结构”的需求。这类服务有几个明显特点:
- 需要访问云端设计数据,适合做远程 HTTP/SSE 服务。
- 涉及鉴权(用户在客户端配置 token 或 API Key),Server 侧要做令牌透传和权限校验。
- 设计稿数据量可能很大,单次返回全部信息不现实,需要支持按需查询、分批获取。
在设计此类 MCP Server 时,我会把工具划分得非常细,比如:
list_projects:列出用户可访问的设计项目。get_project_info:获取项目基本信息。get_layer_tree:获取指定页面的图层树结构。get_layer_detail:获取单个图层的详细标注数据。
这样的设计思路是:模型可以根据用户需求,先获取项目列表,再一层层下钻,避免一次性拉取过量数据。这也和前面讲的“流式输出设计”理念互通——把大任务拆成多个小任务,每个小任务都快速返回。
5.4 部署后的常见问题速查
| 现象 | 最可能的原因 | 排查方法 |
|---|---|---|
| 客户端连接远程服务失败 | 未配置 HTTPS 或端口未开放 | 检查网络安全组,确认服务 URL 是否以 https 开头 |
| 访问时提示 404 | Nginx 路径转发配置错误 | 核对 location 路径和 Server 实际路由是否一致 |
| 工具调用超时 | 反向代理超时时间太短 | 调大proxy_read_timeout |
| 进程频繁重启 | 内存超限或未捕获异常 | 查看 PM2 日志,检查max_memory_restart设置 |
| 多个客户端互相干扰 | 未做会话隔离 | 检查是否有共享的全局状态,或在 HTTP 传输中合理管理会话 |
6. 核心流程参考:一个有完整骨架的 MCP Server 实现
前面讲了很多理论和经验,最后给一个可以直接“抄作业”的代码骨架,把错误处理、流式输出、工具注册、配置管理都串起来。
// src/index.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { withErrorHandling } from "./lib/error-handler.js"; import { loadConfig } from "./lib/config.js"; const config = loadConfig(); const server = new Server( { name: "my-mcp-server", version: "1.0.0", }, { capabilities: { tools: { deep_search: { handleWith: async (args, emit) => {...} }, health_check: { handleWith: async () => {...} }, }, }, } );这里需要注意:不同版本的 MCP SDK 对工具注册的 API 形态会有调整,上面的handleWith写法只是示意,实际开发时请参考你所使用版本的类型定义。重点是你已经看到标准的结构:工具定义、参数校验、错误处理、业务实现、结果返回。
如果你用的是 TypeScript,SDK 会提供完整的类型推导。工具的inputSchema建议通过 zod 推理生成,而不是手工写 JSON Schema:
import { z } from "zod"; const DeepSearchSchema = z.object({ query: z.string().describe("搜索关键词"), limit: z.number().optional().default(10).describe("返回结果数量上限"), }); type DeepSearchArgs = z.infer<typeof DeepSearchSchema>;使用 zod 的好处是:类型、默认值、描述三合一,既能得到运行时校验,又能在编译期获得完整的类型支持,还能在生成inputSchema时自动带上各字段的描述信息,帮助大模型正确理解并调用你的工具。
结尾:我自己的一些体会
做 MCP Server 开发这段时间,最大的感受是:这个领域的技术栈并不复杂,真正的难点在于“以生产级标准要求自己”。错误处理的规范、流式输出的设计、TypeScript 的严格配置、部署时对各种细节的把控,每一项单独拎出来都不难,难的是把它们组合在一起,形成一个稳定、可靠、可维护的系统。
如果你也在写 MCP Server,我的建议是:从第一天就把错误处理框架搭好,哪怕只是一个小工具;第一次做耗时任务时就想清楚流式输出的方案;TypeScript 严格模式不要妥协;部署时优先考虑 HTTP/SSE 远程模式,因为这才是 MCP 服务真正的价值放大器——让不同的人、不同的 AI 应用,都能通过标准协议使用你的能力。
踩过几次坑之后你会发现,MCP Server 的开发其实像搭积木,框架搭对了,后面每加一个新工具都很快;框架搭错了,每加一个功能都要回来补债。希望这篇文章能帮你少走一些弯路。