说实话,很多人把 MCP server 写到“能跑通”就停了。但你把 server 交给真实用户、接到 Cursor、接到自己的 Agent 框架里,问题就全来了:调用失败客户端只会看到一个干巴巴的 error;耗时工具跑几秒都没反馈,用户以为卡死了;TypeScript 类型到处 any,改起来像拆地雷;部署的时候才发现 stdio 和 HTTP 模式根本没想清楚。
这篇文章就是来解决这些问题的。我会结合 MCP 自定义服务器开发的真实场景,把错误处理、流式输出、TypeScript 工程化和部署这四个进阶话题一次性讲透。适合已经会写最基础 MCP server、正打算把它做成能扛真实流量的状态的开发者。所有代码基于@modelcontextprotocol/sdk的 TypeScript 版本,示例都能直接改改就用。
1. MCP 服务器整体设计:别把协议当工具函数包装器
1.1 MCP 到底在解决什么问题
MCP(Model Context Protocol,模型上下文协议)本质上是给 AI 应用(host)和外部工具/数据源(server)之间定的一套统一通信协议。你可以把它理解成“AI 世界的 USB-C 接口”——不管是最热的 Claude、Cursor,还是自己写的 Agent 框架,只要实现了同一个协议,就能无缝接到各个 server 上,不用每对接一个工具就写一套私有 API。
MCP 的调用关系并不复杂:host 负责发起请求,server 负责执行并返回结果,二者通过 transport(传输层)通信。transport 的选择基本就两种:
- stdio:host 直接拉起 server 进程,通过标准输入输出通信。适合本地单机使用,部署到用户电脑或开发机上,生命周期跟着 host 走。
- Streamable HTTP:server 以 HTTP 服务形式运行,host 通过远程请求调用。适合多客户端、跨机器、需要在服务端统一运维的场景。
这个选择直接决定了你后面错误处理、流式输出和部署方案怎么写。我的建议是:工具本身没有任何本地资源依赖的,优先考虑 HTTP 模式;纯本地文件操作、数据库连接、需要访问本机进程的,用 stdio 更自然。
1.2 进阶 server 的分层设计思路
很多刚上手的人会把所有逻辑堆在一个server.tool()回调里,这是典型的 demo 写法。一个要上生产的 MCP server,我习惯分成三层,每层各管各的事:
- 协议层:只负责 MCP 消息的收发、JSON-RPC 编解码、错误码转换。这层由 SDK 解决,但你得理解它的行为,尤其是错误怎么被序列化出去。
- 能力层:定义“有哪些工具、工具的参数 schema 是什么、返回结构是什么样”。这层是 MCP 的契约,写清楚了 host 和模型才能正确调用。
- 业务层:真正干活的地方,比如查数据库、调外部 API、处理文件。这层最容易出问题,也是错误处理、超时控制、进度上报的主战场。
分层做清楚的直接好处是:出问题时你知道该去哪一层排查。我见过太多 server,业务层抛了个TypeError: Cannot read properties of undefined,结果 MCP 客户端收到的是 JSON-RPC internal error,日志里又没打印堆栈,整个排查过程全靠猜。所以设计阶段就要定一个规矩:业务层的所有异常必须被捕获,要么转换成 MCP 协议错误,要么转换成结构化的业务错误返回,绝不能裸奔到协议层。
另外,不要在 server 里塞太多东西。MCP 工具越小越单一越好,宁可拆成 five 个小工具也别做一个“万能工具”。万能工具的参数 schema 会变得极其复杂,LLM 调用时的 token 成本高,出错概率也大,而且错误信息很难收敛成可读形式。这是经验的总结,不是偏好问题。
2. 错误处理:让调用方知道发生了什么
2.1 MCP 错误码模型
MCP 基于 JSON-RPC 2.0,所以错误处理首先要遵守 JSON-RPC 的错误码定义。MCP SDK 直接把错误码封装成了枚举ErrorCode,常用的几个:
| 错误码 | 名称 | 含义 |
|---|---|---|
| -32700 | ParseError | 消息解析失败,通常是协议层数据损坏 |
| -32600 | InvalidRequest | 请求结构不合法 |
| -32601 | MethodNotFound | 调用了不存在的工具或方法 |
| -32602 | InvalidParams | 参数校验失败 |
| -32603 | InternalError | 服务器内部错误 |
| -32000 到 -32099 | ServerError | MCP 预留的服务器自定义错误区间 |
实际开发中,InvalidParams和InternalError是最常出现的。SDK 里提供了一个McpError类,你可以直接throw new McpError(ErrorCode.InvalidParams, "参数 xxx 不能为空"),SDK 会自动把它转成标准的 JSON-RPC 错误返回给 host。
2.2 在哪一层做错误处理
我强烈建议在注册工具的外层做一个统一包装,而不是在每个工具函数内部到处 try/catch。这跟写 Express 中间件的思路是一样的:统一收口,统一转换,统一日志。
下面这个withErrorHandling包装器是我常用的写法:
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; type ToolHandler<T> = (args: T) => Promise<unknown>; function withErrorHandling<T>(handler: ToolHandler<T>): ToolHandler<T> { return async (args) => { try { return await handler(args); } catch (err) { // 协议错误直接透传,保留原始错误码 if (err instanceof McpError) { throw err; } // 参数校验错误统一转成 InvalidParams if (err && typeof err === "object" && "name" in err && (err as { name: string }).name === "ZodError") { const details = (err as { issues?: Array<{ path: string; message: string }> }).issues ?.map((i) => `${i.path.join(".")}: ${i.message}`) .join("; "); throw new McpError(ErrorCode.InvalidParams, `参数校验失败: ${details}`); } // 兜底:记录完整堆栈,对外只暴露通用错误 console.error("[tool:internal_error]", err); throw new McpError( ErrorCode.InternalError, `工具执行失败: ${err instanceof Error ? err.message : String(err)}` ); } }; }这个包装器干了三件事:识别并透传已有协议错误;把 Zod 校验错误转成InvalidParams;其他未知异常打日志后统一转成InternalError。这样 host 端拿到的错误永远是结构化的,不会出现类型是 string 却当对象访问这种低级问题。
2.3 业务错误是抛异常还是当结果返回
这是一个很值得讨论的点。我的经验是分两种情况:
- 参数问题、前置条件不满足:直接抛
McpError,让调用方明确知道这单调用是无效的,不用重试。 - 业务执行失败,但错误信息本身对用户有价值:比如调第三方 API 返回了明确的业务码,我建议把它放在工具返回的 content 里,用结构化文本或 JSON 字符串返回给 LLM,而不是抛异常。为什么?因为 MCP 的错误通道信息量有限,而 LLM 可以阅读返回 content 里的具体说明,进而采取下一步动作(比如换个参数重试、告诉用户原因)。你把业务错误放进 content,模型就能“看到”并理解它。
举个例子,一个查物流的工具,如果单号不存在,我返回的 content 里会明确写:
{ "status": "not_found", "message": "运单号 SF1234567890 不存在,请检查是否输入正确" }而不是抛一个InternalError。前者模型能听懂,后者模型只能回一句“抱歉,系统出错了”。这是 MCP 应用体验的巨大差别。
2.4 超时与取消处理
MCP 协议本身允许 host 给请求设置超时,但 client 断开或超时之后,底层逻辑往往还在跑。这在数据库查询、外部 API 调用上尤其危险——你以为调用方已经不管了,实际上你的 server 还在占用连接和计算资源。
我的处理方案是在工具包装器上加一层取消信号:
import { randomUUID } from "node:crypto"; const tasks = new Map<string, AbortController>(); export function registerTask(handler: ToolHandler<unknown>) { return withErrorHandling(async (args) => { const taskId = randomUUID(); const controller = new AbortController(); tasks.set(taskId, controller); try { return await handler({ ...(args as object), signal: controller.signal }); } finally { tasks.delete(taskId); } }); }配合 server 收到的notifications/cancelled通知,主动 abort 对应任务。虽然不是所有 SDK 版本都默认暴露取消钩子,但自己在工具层保存 AbortController 的方法是通用的。这样至少能保证:客户端超时放弃后,server 端不会留下僵尸任务。
3. 流式输出:不让用户干等
3.1 MCP 里的“流式输出”是什么
先说清楚,MCP 工具调用本身是请求-响应的单向模式,不像 Chat API 那样天然支持逐 token 吐字。但有两个场景你会非常需要“流式”效果:
- 执行时间长的工具:比如跑一个数据分析、批量文件处理、远程构建任务,可能要几十秒甚至几分钟。用户端没有反馈就会觉得服务挂了,甚至触发 host 的请求超时。
- 工具执行过程中需要向用户展示中间结果:比如一个“生成报告”的工具,内部要先拉数据、再渲染、最后上传,每个阶段的进度如果能实时推给用户,体验会好很多。
MCP 协议针对进度通知有原生支持:notifications/progress。它的原理不算复杂——客户端在发起请求时可以在_meta.progressToken里携带一个自定义 token,服务端通过这个 token 向客户端推送进度通知。客户端收到通知后可以在 UI 上展示进度条或阶段文案。
3.2 服务端向客户端推送进度
用低层ServerAPI 实现进度的代码如下:
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { CallToolRequest } from "@modelcontextprotocol/sdk/types.js"; const server = new Server({ name: "demo-server", version: "1.0.0" }, { capabilities: { tools: {} }, }); server.setRequestHandler(CallToolRequest, async (request) => { const token = request.params._meta?.progressToken; const notifyProgress = (progress: number, total: number, message?: string) => { if (!token) return; // 客户端没传 token 就不用推 return server.notification({ method: "notifications/progress", params: { progressToken: token, progress, total, ...(message ? { message } : {}), }, }); }; if (request.params.name === "slow_task") { await notifyProgress(0, 100, "开始执行"); // 模拟阶段1:拉取数据 await doStep1(); await notifyProgress(30, 100, "数据拉取完成"); // 模拟阶段2:处理中 await doStep2(); await notifyProgress(70, 100, "数据处理中"); // 模拟阶段3:收尾 await doStep3(); await notifyProgress(100, 100, "执行完毕"); return { content: [{ type: "text", text: "任务完成" }], }; } throw new McpError(ErrorCode.MethodNotFound, `未知工具: ${request.params.name}`); });注意一个关键点:request.params._meta.progressToken是可选字段。如果 host 没传,你再调用 notification 也只是自己空转,所以一定要在notifyProgress里先判空。另外 notification 的推送时机也不能太频繁,一般每个阶段推一次就够,太细的进度反而让客户端在处理通知时增加额外开销。
3.3 结合 HTTP transport 的流式响应
如果你的 server 走的是 Streamable HTTP 模式,还有一个更接近“字面意义流式输出”的方式:用 SSE(Server-Sent Events)逐块返回数据。SDK 的 StreamableHTTPServerTransport 会生成一个 SSE 流,客户端可以通过这个流持续接收服务端消息。
这个能力最常见的应用场景是:你的 MCP server 作为中间层,转发大模型生成结果。比如我写过一个“通过 MCP 调用本地 Ollama 模型”的工具,流程是这样的:客户端调用chat工具,server 自己请求 Ollama 的/api/chat接口并开启 stream 模式,然后把每个返回的 token 通过 server 的 notification 或 SSE 事件转发给客户端。这样用户能在自己的 MCP host 里看到逐字输出的效果。
用代码表示大概是这样:
server.setRequestHandler(CallToolRequest, async (request) => { if (request.params.name === "ollama_chat") { const stream = await ollamaClient.chatStream({ model: "qwen2.5:7b", messages: request.params.arguments.messages, }); // response 也支持流式累加 let fullText = ""; for await (const chunk of stream) { fullText += chunk.message.content; await notifyProgress(0, 1, chunk.message.content); // 或通过其他方式推送增量 } return { content: [{ type: "text", text: fullText }] }; } });这里要说明一下:因为 MCP 的CallTool响应结构是固定的,目前最稳妥的做法仍然是把完整结果返回给客户端,流式效果用进度通知去呈现增量内容。不要自己发明二进制流协议或者非标字段,SDK 和 host 不认。
3.4 流式输出的客户端适配经验
流式效果能不能生效,很大程度取决于 host 端的实现。有些 host(比如 Cursor 的 MCP 客户端)对 progress notification 支持得不错,会在界面上展示进度;有些则完全不理会,直接把通知忽略掉。
我的建议是:不要把流式当成核心功能,而是把它当成“有则更好”的增强。核心逻辑仍然要靠一次完整的CallTool响应来承载,progress notification 只是锦上添花。你在自己的客户端或 Agent 框架里消费进度事件时,判断逻辑也要写成“监听不到就当不存在”,不要因为等待一条永远可能不来的 notification 而把流程卡死。
4. TypeScript 工程化:类型安全是底线
4.1 用对 SDK,别重复造轮子
MCP 官方提供了 TypeScript SDK,包名叫@modelcontextprotocol/sdk,支持低层的Server类和高层的McpServer类。我的建议是:能用高层就用高层,高层已经帮你封装好了工具注册、参数解析、错误转换这些重复逻辑,写起来清爽很多。
高层 API 的典型用法:
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: "my-tools", version: "1.0.0" }); server.registerTool( "get_weather", { title: "查询天气", description: "根据城市名查询当前天气", inputSchema: { city: z.string().describe("城市名,比如 北京"), }, }, async ({ city }) => { // 业务逻辑 return { content: [{ type: "text", text: `北京的天气:晴,28°C` }] }; } ); const transport = new StdioServerTransport(); await server.connect(transport);zod 的describe方法非常有用,它会自动成为工具描述的一部分,LLM 在决定是否调用工具、传什么参数时会读取这段描述。写 MCP 工具的描述和参数描述,就是在给 LLM 写说明书,值得多花时间。
4.2 工具返回结构的设计
MCP 工具的返回类型是固定结构,核心是content数组,里面可以是TextContent、ImageContent等。这对 LLM 是友好的——它能看到一段纯文本,也能看到图片。
我在设计返回结构时有一个原则:**尽量让text字段包含完整、自解释的文本,而不是返回一个让模型自己去猜含义的代码对象。**比如查询用户信息,我返回:
用户名: 张三 角色: 管理员 邮箱: zhangsan@example.com 最后登录: 2025-06-01 14:30而不是返回{"username":"张三","role":"admin"}这种 JSON。为什么?因为前者模型可以直接读、直接答,后者还要经过一层“解析 JSON 再组织语言”的过程,既容易出错,又浪费时间。如果信息本身结构复杂(比如表格数据),我会返回 Markdown 格式的文本,也可以搭配 JSON 字符串,但一定在文本里说明结构。
4.3 类型收窄与工具参数的处理
高层的McpServer.registerTool里,inputSchema用 zod 定义了之后,回调函数的参数类型会被自动推断出来。此时注意一个 TypeScript 常见的坑:如果你的 schema 定义的是类似z.object({ ids: z.array(z.string()) }),回调里访问args.ids时,TypeScript 能正确推出string[]。但如果写的是z.object({ ids: z.array(z.string()).optional() }),那args.ids就是string[] | undefined,访问前一定要做空值判断。
另一个常见的类型问题是 SDK 导入路径。这个包是纯 ESM,在 tsconfig 里建议这样配置才不会有模块解析问题:
{ "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ES2022", "esModuleInterop": true, "strict": true, "outDir": "dist" } }并且在package.json里写"type": "module"。不然运行时容易遇到ERR_REQUIRE_ESM或者动态导入的报错。
4.4 TypeScript 版本陷阱
SDK 的依赖版本和zod版本经常变动,特别是遇到zod大版本升级(v3 到 v4)时,类型推断行为会有不少变化。比如老代码里z.object({}).strict()之类的写法,升级后可能会报类型错误。我的建议是安装时把zod锁版本:
npm install zod@^3.22.4因为@modelcontextprotocol/sdk目前对 zod v4 的兼容性在不同版本里有差异。同时,Node.js 版本尽量用 18 以上,SDK 依赖了一些较新的 API。如果看到"baseUrl" 已弃用这类编译提示,别慌,把tsconfig.json里的baseUrl删掉,改用相对路径或paths的替代写法就行,这是 TypeScript 7 计划中禁用的旧配置项,早改早省心。
5. 部署:从本机到服务器的完整路径
5.1 先确定部署模式
部署 MCP server 之前,最需要决策的问题是:你的 server 会被谁调用?
- 如果只给本地开发工具用,比如连到本地的 Cursor、Claude Desktop,那用stdio 模式最舒服。host 自动启动进程,不需要管网络、鉴权、端口这些事。但要注意:stdio 模式下 server 的宿主机必须有 Node.js 运行时,用户把 server 装到别的机器上时也得有 Node.js。
- 如果 server 要同时服务多个客户端,或者部署在一台中央服务器上,那用Streamable HTTP 模式。这种模式下 server 是一个常驻服务,需要考虑端口管理、鉴权、健康检查、日志轮转等一系列生产问题。
下面这张表是我做选型时参考的对照:
| 对比项 | stdio 模式 | Streamable HTTP 模式 |
|---|---|---|
| 进程生命周期 | 跟随 host 启动/退出 | 常驻服务 |
| 网络依赖 | 无(本地进程通信) | 需要监听端口 |
| 鉴权 | 无(本地可信) | 需要 Token/API Key |
| 多客户端 | 每个客户端一个进程 | 所有客户端共享服务 |
| 部署要求 | 每个客户端机器装 Node.js | 只需服务端装 Node.js |
| 日志 | 输出到 stdout/stderr | 独立服务日志 |
如果你是自用或小团队用,无脑选 stdio,省去一大半运维工作。多用户、跨团队共享、或者有敏感数据要统一管控,再考虑 HTTP。
5.2 stdio 模式的部署细节
stdio 模式部署看似简单,但有几个细节特别容易踩坑。
首先是package.json里要写好bin字段,让 server 能作为可执行命令被 host 拉起:
{ "name": "my-mcp-server", "version": "1.0.0", "type": "module", "bin": { "my-mcp-server": "./dist/index.js" }, "files": ["dist"], "scripts": { "build": "tsc -p tsconfig.json", "start": "node dist/index.js" } }并且在dist/index.js的顶部加一行#!/usr/bin/env node,构建后给文件加执行权限:
chmod +x dist/index.js另外在本地安装时可以这样挂载到全局,方便 Claude Desktop 或 Cursor 直接写命令名调用:
npm link5.3 Docker 容器部署 HTTP 模式
HTTP 模式部署到服务器,用 Docker 是干净利落的方式。下面这个 Dockerfile 是我在多个项目里验证过的,直接抄问题不大:
# 构建阶段 FROM node:20-alpine AS build WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN npm install -g pnpm && pnpm install --frozen-lockfile COPY tsconfig.json ./ COPY src ./src RUN pnpm build # 运行阶段 FROM node:20-alpine ENV NODE_ENV=production WORKDIR /app # 只拷贝构建产物和生产依赖,镜像体积小很多 COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules COPY package.json ./ # 用非 root 用户运行,安全习惯 USER node EXPOSE 8080 CMD ["node", "dist/index.js"]启动 HTTP server 的入口代码要监听process.env.PORT,方便 Docker 映射:
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; const port = Number(process.env.PORT || 8080); const serverInstance = createMcpServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, }); // 用 Express 或原生 http 把请求交给 transport注意 HTTP 模式下,SDK 对每个连接会建立 session,如果你用官方的StreamableHTTPServerTransport,请求路径要保持一致,且要处理DELETE方法来断开会话。这些细节在 SDK 的示例代码里都有,但首次接的时候容易漏。
5.4 部署后的调试与验证
无论是本地 stdio 还是远程 HTTP,我强烈建议用官方的 MCP Inspector 工具做一轮功能验证。它像一个图形化的调试台,能看到每个请求和响应,对排查错误特别有用:
npx @modelcontextprotocol/inspector node dist/index.js对于 HTTP 模式,可以指定 URL 连接:
npx @modelcontextprotocol/inspector -e http://localhost:8080在 Inspector 里验证三件事:工具列表能不能正确列出、正常参数能不能拿到结果、错误参数能不能返回可读的错误信息。这三关过了,再接到 Cursor 或自己的 Agent 里就不太容易出幺蛾子。
6. 常见问题与排查技巧实录
6.1 stdio 模式:进程启动就退,host 报连接失败
现象:Claude Desktop 或 Cursor 配置 server 后,工具列表加载失败,日志里提示 MCP server process exited。
排查思路:这类问题九成是 server 启动时抛了未捕获异常,进程直接退出。最常见的几个原因:dist目录不存在、入口文件路径配错、代码里用到了浏览器端 API 或 Node 版本不匹配。
解决办法:先在终端手动跑一次node dist/index.js,如果报错就按报错修。修好后用npx @modelcontextprotocol/inspector node dist/index.js验证能否正常握手。记得在入口文件外层加一个全局异常兜底,至少打日志:
process.on("uncaughtException", (err) => { console.error("[uncaughtException]", err); }); process.on("unhandledRejection", (err) => { console.error("[unhandledRejection]", err); });6.2 工具调用返回了,但客户端收到的内容为空或乱码
现象:工具执行成功,服务端日志有输出,客户端却显示空结果或 JSON 解析失败。
原因:多半是返回的content结构不符合 MCP 规范。比如少写了type: "text",或者把普通对象直接塞进 content(应该是字符串),或者返回了undefined。
解决办法:写一个类型安全的返回构造器,强制规范:
function textResult(text: string) { return { content: [{ type: "text" as const, text }] }; }然后所有工具统一走textResult(),就不会漏字段。如果你返回的是结构化数据,用JSON.stringify(data, null, 2)转成字符串后放进text。
6.3 zod 版本冲突,SDK 类型推断报错
现象:安装新依赖后,server.registerTool里回调的参数类型变成any或直接编译报错。
原因:项目里的zod版本和@modelcontextprotocol/sdk期望的版本不一致,导致类型不能正确关联。
解决办法:删掉node_modules和 lockfile 后重新安装,或把zod显式锁到 SDK 依赖的版本。看看node_modules/@modelcontextprotocol/sdk/package.json里的 dependencies,对照设你自己的版本即可。
6.4 HTTP 模式部署到服务器后,外部客户端连不上
现象:本地用curl能访问,但在云服务器上从外部连不上,或者连上了但 Cursor 显示鉴权失败。
排查流程:先确认端口是否监听:ss -lntp | grep 8080;再确认云安全组和防火墙放行了该端口;最后确认 client 配置的 URL 里协议写的是http还是https,以及 token 是否放进了请求头。
如果 SDK 版本较旧,可能还需要检查 CORS 配置。StreamableHTTPServerTransport的构造参数里可以传入 CORS 相关配置,生产环境务必把enableJsonResponse和 CORS 设置成符合实际场景的值,别用默认状态裸奔。
6.5 进度通知发不出去或客户端收不到
现象:代码里调用了server.notification,但没有报错,客户端 UI 就是没有进度显示。
原因:大概率是客户端没传progressToken,服务端又没判空,或者传了但服务端在 notification 的params里写错了字段名(正确是progressToken,不是token;进度字段是progress,不是current)。
解决办法:在notifyProgress函数内部打印一行日志,看token是否存在。加日志是所有异步问题排查的第一步,尤其是 MCP 这种跨进程协议。
6.6 工具执行超时,host 直接掐断连接
现象:工具执行时间大于 host 的超时阈值(常见 30 秒或 60 秒),客户端直接报 timeout。
解决办法:要么优化业务逻辑,把单次执行时间压下来;要么改造成分步执行——第一次调用创建一个异步任务并返回taskId,客户端通过第二个工具查询任务结果。这个“任务模式”才是 MCP 下处理长耗时任务的稳妥方案,比单纯推进度通知更可靠,因为 notification 可能会丢,但你主动轮询结果不会丢。
我这里给一个最简单的任务模式接口设计:
server.registerTool("async_task_start", { ... }, async ({ payload }) => { const taskId = randomUUID(); runInBackground(taskId, payload); return textResult(JSON.stringify({ taskId, status: "running" })); }); server.registerTool("async_task_status", { ... }, async ({ taskId }) => { const task = getTask(taskId); if (!task) throw new McpError(ErrorCode.InvalidParams, `任务不存在: ${taskId}`); return textResult(JSON.stringify(task.getPublicState())); });这样即使单次调用超过 host 超时上限,任务也还能在后台继续执行,用户随时能回来查状态。
6.7 本地能跑,Docker 里跑不起来
现象:Docker 镜像构建成功,但容器启动后日志没输出,或进程直接退出。
排查要点:
- 容器里
USER node之后,很多目录没有写权限。如果 server 要写临时文件,得把WORKDIR的所有者改成 node:COPY --chown=node:node . /app,或者挂载外部卷。 NODE_ENV=production时,某些依赖如果意外被 pnpm 省略,启动就会报模块缺失。构建阶段用pnpm install --prod之前先确认dependencies和devDependencies分清楚了。- 容器日志看不到,多半是 console.log 没有落到 stdout。Node 的 console 默认输出到 stdout,但如果代码里重定向过,就检查
process.stdout.write是否被拦截了。
这些坑我基本都踩过一轮,写在这里就是希望大家都能绕开。
最后聊一点我的体会
MCP server 开发走到进阶阶段,真正拉开差距的不是你会不会调 SDK,而是你有没有把“调用生命周期”这件事想清楚。一次工具调用,从客户端发起、参数校验、业务执行、进度上报、错误转换到结果返回,每个环节都可能出问题,每个环节都需要有明确的设计。好的 server 不是功能堆得多,而是每个工具的行为都可预期、可观测、可排查。
还有一个很实际的建议:开发的时候在 server 里多打日志,哪怕是本地自用也建议用console.error输出到 stderr,这样在 stdio 模式下不会污染 stdout 的协议数据,出问题时又方便追踪。等你上手一段后,再回头看看自己的第一个 demo server,多半会发现现在这个版本在错误处理、类型定义、部署方式上的进步——那就是你真正进阶了的标志。希望这篇指南能省下你一个个文档翻查的时间,早点把 MCP server 真正用起来。