MCP Server 进阶指南:传输选型、错误处理、流式输出与部署实践
2026/9/14 3:25:44 网站建设 项目流程

很多人第一次写完 MCP server,都是照着 cookbook 搭一个 hello world,本地跑通了就觉得自己会了。但等到真把工具丢给 agent 用、部署到服务器上,问题才冒出来:工具一报错客户端根本看不懂、跑了几十秒的长任务让 agent 等到超时、TypeScript 里any泛滥结果运行期直接翻车、好不容易写完代码又不知道怎么部署。这篇我不重复入门教程,只聊真正进阶的四个点:传输方式选型、错误处理、流式输出、TypeScript 类型安全,最后再给一套能落地的部署方案。全文基于@modelcontextprotocol/sdk+ TypeScript,代码可以直接抄,适合已经写过至少一个 MCP server 的开发者。

1. 先选对传输方式再写代码:stdio、Streamable HTTP 与 SSE 的取舍

1.1 stdio 和 HTTP 各自解决什么问题

MCP 官方 SDK 里最常见的两种传输是 stdio 和 Streamable HTTP。很多教程一上来就让你复制一个 stdio server,确实,stdio 方案很优雅,进程间通过标准输入输出传 JSON-RPC 消息,没有端口冲突、没有网络权限问题,本地调试极其顺手。但它的主要使用场景是"本机单客户端"——由 agent 客户端拉起子进程,走完生命周期就结束。你想把它放到一台服务器上让多个 agent、多个团队共享,stdio 会非常别扭,因为你得在每个客户端环境里配置启动命令、管理进程状态。

HTTP 传输解决的是"跨机器调用"的问题。服务独立运行,监听一个端口,任何地方的客户端都能通过 HTTP 来调用。Streamable HTTP 是当前推荐方向,它把 JSON-RPC 消息包装成 HTTP 请求,同时用 SSE(Server-Sent Events)做服务端到客户端的持续推送。注意,它不是 WebSocket,是单向长连接,用来主动推送事件非常合适。

维度stdioStreamable HTTP
适用场景本机、单客户端远程、多客户端、容器化部署
进程模型父进程拉起子进程独立常驻服务
鉴权基本无,继承父进程权限可加 Bearer Token、OAuth
网络要求不需要网络端口需要监听 HTTP 端口
调试方式看 stdout 日志curl 直接发请求
多客户端共享不合适天然支持

这俩不是替代关系,是互补关系。我自己本机调试和给个人 Desktop 客户端用,就走 stdio;一旦要部署到服务器、接入团队内部的智能体平台,就走 HTTP。

1.2 项目入口怎么同时支持两种传输模式

实操里最省心的做法不是维护两个项目,而是写一个入口,用环境变量控制使用哪种 transport。下面这段代码是行为示意,不同 SDK 版本的 HTTP transport 初始化参数会变,最终以你安装的版本文档为准。

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import express from "express"; const server = new McpServer({ name: "my-ops-assistant", version: "1.0.0", }); // 在这里批量注册 tools、resources、prompts registerTools(server); async function main() { const transportType = process.env.TRANSPORT ?? "stdio"; if (transportType === "http") { const app = express(); app.use(express.json()); app.post("/mcp", async (req, res) => { // 每次 POST 实际是一次 JSON-RPC 消息交换 // 具体写法看 SDK 版本,关键是管理好 session const transport = new StreamableHTTPServerTransport(); await server.connect(transport); // 将 req/res 交给 transport 处理 // ... }); const port = Number(process.env.PORT ?? 3001); app.listen(port, () => { console.log(`MCP HTTP server listening on ${port}`); }); } else { const transport = new StdioServerTransport(); await server.connect(transport); } } main().catch((err) => { console.error("fatal error", err); process.exit(1); });

这里有个很容易踩的坑:不要试图在同一个进程里同时 connect 两个 transport。SDK 内部是按 transport 管理 session 状态的,混用会导致消息莫名其妙丢失。我一开始图省事,把两种 transport 全部初始化了,结果 HTTP 客户端连上来能握手但收不到工具结果,浪费了半天排查。

1.3 选型判断清单

我后来把选型收敛成几个问题,照着判断就不会纠结:

  • 客户端是运行在用户本机的桌面 Agent 吗?是 → stdio 优先,配置简单。
  • 服务需要部署到 Docker、K8s 或虚拟机吗?是 → HTTP。
  • 服务会被多个团队、多个 Agent 同时调用吗?是 → HTTP。
  • 需要做细粒度鉴权、访问控制、审计日志吗?是 → HTTP 或至少前面挂一层网关。
  • 只是自己写脚本测试?是 → stdio,最快。

不要因为"HTTP 更高级"就强行上 HTTP,本机调试用 stdio 能省掉大量端口、鉴权、session 的麻烦。反过来,服务一旦要长期运维,就别用 stdio 硬撑,进程没人拉起、日志没人收集、崩溃没人知道,那才是灾难。

2. 错误处理:如何把异常翻译成 MCP 协议能听懂的语言

2.1 协议层错误和业务层错误是两码事

MCP 底层是 JSON-RPC 2.0,协议层的错误码是固定的:-32700解析错误、-32600无效请求、-32601方法不存在、-32602参数无效、-32603内部错误。这些错误表示"请求在这个协议层面就没被正确理解或执行",SDK 会在底层捕获并返回 error response。

但业务层错误是另一回事。比如用户查一个不存在的订单号、调用第三方 API 返回 401、生成报告超时。这些请求本身是合法的 JSON-RPC 请求,协议层不会报错,只有业务逻辑里知道"这个订单号不存在"。很多开发者把这两类混在一起,全部 throw 一个Error("something went wrong"),客户端只能看到一个模糊的 internal error,根本没法判断是参数问题、权限问题还是服务端炸了。

2.2 用 McpError 抛协议层错误

对于协议层的参数校验失败、请求格式错误,建议直接用 SDK 导出的McpError,不要自己拼一个普通 Error。

import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; server.tool( "get_order", { orderId: z.string() }, async ({ orderId }) => { if (!orderId) { throw new McpError( ErrorCode.InvalidParams, "orderId cannot be empty" ); } // ... } );

这样客户端能拿到标准 JSON-RPC 错误结构,而不是一段被包装得面目全非的堆栈。SDK 在工具 handler 内部抛出的普通 Error,最终会被转成-32603internal error,虽然不会让连接断开,但错误信息里往往是堆栈,对客户端来说没有可读性。想让客户端根据错误码做分支判断,就用规范的McpError

2.3 业务错误放进结果而不是异常

真正业务上的"失败"不要抛异常,而是作为工具返回值返回,并在结果里标记isError: true

server.tool( "get_order", { orderId: z.string() }, async ({ orderId }) => { const order = await db.orders.find(orderId); if (!order) { return { content: [{ type: "text", text: `订单 ${orderId} 不存在` }], isError: true, }; } return { content: [{ type: "text", text: JSON.stringify(order) }], }; } );

为什么这样做?因为 agent 拿到工具返回值后,会把它当作"执行结果"来理解,你返回"订单不存在",agent 就知道要换一个参数或者直接告诉用户。但如果你抛一个协议错误,很多客户端会把这次调用标记为"链路故障",agent 可能反复重试同一个请求,体验非常差。

简单记:协议错误表示"请求无法处理",业务错误表示"请求处理了,但结果不满足"。前者用McpError,后者返回值加isError

2.4 兜底异常处理与日志关联

不管你写得再小心,总会有没预料到的异常。我在项目里会为 HTTP transport 加一层全局兜底:

app.post("/mcp", async (req, res) => { try { // 交给 MCP transport 处理 } catch (err) { const errBody = { jsonrpc: "2.0", id: req.body?.id ?? null, error: { code: ErrorCode.InternalError, message: "internal server error", data: { requestId: randomUUID(), }, }, }; logger.error({ msg: "mcp request failed", requestId: req.body?.id, stack: err instanceof Error ? err.stack : String(err), }); res.status(500).json(errBody); } });

重点在于日志里带上requestId,这样用户反馈问题后,你能根据一个 id 快速定位那一次请求发生了什么。日志建议输出成 JSON,方便接入日志平台,别用一行行拼字符串。很多线上事故排查慢,不是因为代码复杂,而是日志里搜不到关联字段。

3. 流式输出:让耗时工具不再是"黑盒等待"

3.1 先破一个误解:工具结果不是"逐字流式"的

很多人在社区里问,MCP 工具能不能像 ChatGPT 那样一个字一个字吐出来?答案非常直接:不能。MCP 里一个 tool call 的最终响应仍然是一个完整的 JSON-RPC 响应,不存在"响应先发一半,再追加另一半"这种机制。你看到 agent 打字机一样的效果,那是 LLM 生成的 token 在流式输出,不是工具结果在流式输出。工具结果对 LLM 来说是一个整体,一次性进入上下文。

所以不要一门心思想着"改造传输层让工具结果流式化",方向就错了。HTTP 的 SSE 确实提供了流式通道,但那是为了服务端主动推送事件、资源变更通知,不是为了把 tool result 切成碎片。

3.2 用进度通知让客户端知道"还在跑"

MCP 协议支持$/progress通知。如果你的工具执行时间达到几秒以上,可以通过服务端发进度通知,客户端就能渲染"正在生成报告 45%"这类反馈。核心代码如下(SDK 不同版本 API 略有差异,思路一致):

server.tool( "generate_report", { topic: z.string() }, async ({ topic }, extra) => { const steps = 10; for (let i = 1; i <= steps; i++) { // 模拟耗时步骤 await sleep(500); // 发送进度通知,total 表示总步数,progress 表示当前进度 extra.server.emitNotification?.({ jsonrpc: "2.0", method: "notifications/progress", params: { progress: i, total: steps, progressToken: extra.progressToken, }, }); } return { content: [{ type: "text", text: "report done" }], }; } );

要注意,进度通知必须发生在 handler 返回之前。因为 handler 一旦返回,这次请求的上下文就结束了,你再想通过同一个连接发消息,SDK 不保证能发出去。实际跑下来,这个机制对客户端体验提升很大,agent 不会把一个没有任何反馈的长请求误判为卡死。

3.3 更稳的长任务模式:异步提交 + 状态查询

如果任务超过 10 秒,光靠进度通知还不够,因为客户端在等待单次 tool call 响应时往往有自己的超时阈值。我在实际项目里推荐拆成两个工具:一个提交任务,一个查询结果。

const jobs = new Map<string, Job>(); server.tool( "submit_report", { topic: z.string() }, async ({ topic }) => { const taskId = crypto.randomUUID(); jobs.set(taskId, { status: "running", progress: 0, createdAt: Date.now() }); // 后台异步执行,不阻塞当前响应 startBackgroundJob(taskId, topic); return { content: [{ type: "text", text: JSON.stringify({ taskId }) }], }; } ); server.tool( "get_report", { taskId: z.string() }, async ({ taskId }) => { const job = jobs.get(taskId); if (!job) { throw new McpError(ErrorCode.InvalidParams, "任务不存在或已过期"); } return { content: [{ type: "text", text: JSON.stringify(job) }], }; } );

这个模式不新鲜,但放到 MCP 场景里很多人想不到。它把一个长任务拆成了多个短请求,agent 拿到 taskId 后可以轮询,等状态变成succeeded再拿结果。对 agent 来说,每次调用都是毫秒级响应,不会超时;对服务端来说,后台任务可以丢到队列里慢慢执行,还能支持任务取消、重试。

3.4 传输层 SSE 到底什么时候才会用上

Streamable HTTP 里的 SSE 流,服务端在收到客户端请求后建立一条长连接,之后可以持续推送消息。这个能力适合哪些场景?资源变化通知、日志实时推送、任务完成事件。比如你有一个"监控服务器状态"的工具,工具本身不返回最终结果,而是注册一个持续推送的通道,让客户端能在状态变化时收到通知——这时候 SSE 才真正派上用场。

所以我的建议是:做长任务先上进度通知,再上任务队列 + 轮询。跑通了这两步,再去研究传输层 SSE 的高级玩法。不要一开始就改造传输层,否则问题会非常复杂。

4. TypeScript 开发中的类型安全细节

4.1 让 zod 成为参数 schema 的唯一来源

写 MCP server 最爽的事是 SDK 原生支持 zod。你用 zod 定义一个对象,既能作为工具参数 schema 自动生成 JSON Schema,又能推导出 TS 类型传给 handler。关键是一定要只维护一份定义,不要手写 interface 和 zod 两套东西。

import { z } from "zod"; const SearchInput = z.object({ keyword: z.string().min(1).describe("搜索关键词"), limit: z.number().int().min(1).max(50).default(10).describe("返回数量"), }); server.tool("search_faq", SearchInput.shape, async ({ keyword, limit }) => { // 这里 keyword 和 limit 的类型已经由 zod 推导出来了 const results = await searchFaq(keyword, limit); return { content: [{ type: "text", text: JSON.stringify(results) }] }; });

我见过不少项目,SDK 的参数类型写了一个 interface,zod 又写一遍,结果改字段的时候漏改一边,工具调用时参数校验不过。单一数据源能直接消掉这类问题。

4.2 外部数据进来先 parse,不要直接 as

调用外部 API 返回的数据,千万不要用as SomeType强转。那只是骗 TypeScript 编译器,运行期该炸还是炸。正确做法是定义 zod schema,用parse做运行时校验:

const ExternalResponseSchema = z.object({ code: z.number(), data: z.array(z.object({ id: z.string(), title: z.string(), })), }); const raw: unknown = await fetch(url).then(r => r.json()); const parsed = ExternalResponseSchema.parse(raw);

这样一旦外部接口结构变了,你的 MCP server 会立刻抛错,而不是把脏数据发给 agent。尤其在 MCP 场景里,agent 会根据工具返回内容生成下一步动作,数据格式一旦漂移,后果会被放大。

4.3 共享 context 不要用可选参数一个个传

MCP server 里很多工具都需要数据库连接、缓存、日志器这类共享依赖。新手容易在 zod 输入里加一堆工具永远不该接收的参数,或者每个 handler 都从闭包变量里拿。我的做法是定义一个AppContext,在服务启动时创建一次,注册工具时用闭包把 context 注入。

export interface AppContext { db: Database; logger: Logger; config: Config; } export function createServer(ctx: AppContext) { const server = new McpServer({ name: "my-app", version: "1.0.0" }); server.tool("get_user", { userId: z.string() }, async ({ userId }) => { ctx.logger.info({ userId }, "get_user called"); const user = await ctx.db.users.find(userId); return { content: [{ type: "text", text: JSON.stringify(user) }] }; }); return server; }

这样的好处是 context 类型明确,测试的时候可以很方便传入 mock 的 db 和 logger,不需要真的连数据库。这个习惯在项目变复杂之后价值非常大。

4.4 tsconfig 不要图省事

MCP SDK 现在以 ESM 为主,tsconfig 里modulemoduleResolution建议直接上NodeNext,同时开strict。下面是常用配置:

{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "outDir": "dist", "rootDir": "src", "sourceMap": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src"] }

CI 里至少跑一次tsc --noEmit,很多低级类型错误在合并前就能拦下来。我见过有人在代码里用require,结果 ESM 项目里运行时直接报require is not defined,这类问题提前做类型检查多少能注意到一些。

5. 从本机到生产:部署 Docker 化、进程管理与安全配置

5.1 多阶段构建一个尽量小的镜像

如果你的 MCP server 走 HTTP,Docker 化部署是最省心的方式。下面这个 Dockerfile 是多阶段构建,第一阶段用来编译 TypeScript,第二阶段只保留运行所需文件和生产依赖。

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 package*.json ./ RUN npm ci --omit=dev COPY --from=build /app/dist ./dist EXPOSE 3001 CMD ["node", "dist/index.js"]

为什么用npm ci而不是npm install?因为npm ci严格按照 lockfile 安装,能保证本地和生产依赖版本完全一致。运行阶段不装 devDependencies,镜像能小不少。如果你是 stdio 类型的 server,容器化意义不大,我会更推荐 systemd 直接跑进程。

5.2 进程守护:别裸跑 node

在裸机或虚拟机上部署 HTTP server,最忌讳的命令就是nohup node dist/index.js &。进程挂了没人拉起来,机器重启了服务不自启。用 systemd 是最原生的方式:

[Unit] Description=MCP HTTP Server After=network.target [Service] Type=simple User=mcp Environment=NODE_ENV=production EnvironmentFile=/etc/mcp-server.env ExecStart=/usr/bin/node /opt/mcp-server/dist/index.js Restart=always RestartSec=3 LimitNOFILE=65536 [Install] WantedBy=multi-user.target

Restart=always保证异常退出后 3 秒拉起,EnvironmentFile把密钥这类敏感配置外置,不要直接写死在 unit 文件里,LimitNOFILE调高文件描述符上限,避免高并发时出现EMFILE错误。

如果你已经用了 Docker,也可以直接在容器里用 pm2-runtime 做进程守护,但大多数情况下 node 单进程 + Docker 自带的 restart policy 就够了,不用再叠一层 pm2。

5.3 鉴权与网络安全

远程部署的 MCP server 默认暴露在网络上,鉴权是必须做的,最简单的方式是 Bearer Token。下面是一个 Express 中间件的思路:

const AUTH_TOKEN = process.env.MCP_API_TOKEN; app.use("/mcp", (req, res, next) => { const auth = req.headers.authorization ?? ""; if (auth !== `Bearer ${AUTH_TOKEN}`) { res.status(401).json({ error: "unauthorized" }); return; } next(); });

有几点生产经验分享:

  • 不要在代码里写死 Token,用环境变量或密钥管理服务。
  • 生产环境建议把 MCP server 放在 API 网关后面,由网关统一做 TLS、限流、审计,MCP server 只在内网监听。
  • 没有 TLS 的话,Token 等于是明文走在网络上,私有网络可能还能接受,公网绝对不能裸奔。
  • 更换 Token 时要考虑客户端重连机制,很多桌面 Agent 会缓存配置,改完 Token 不重载配置就一直 401。

5.4 客户端配置与 HTTP transport 的坑

MCP 客户端侧的配置结构大同小异,很多桌面 Agent 遵循mcpServers这个字段:

{ "mcpServers": { "my-mcp-server": { "url": "https://api.example.com/mcp", "headers": { "Authorization": "Bearer xxxxx" } } } }

本地测试时用http://localhost:3001/mcp一般没问题,但如果在本地用自签证书的 HTTPS,很多客户端会直接校验失败,所以本地开发没必要上 HTTPS,放到生产环境再交给网关处理。

最后分享一个我在 Streamable HTTP transport 上踩过的坑:工具执行时客户端秒断,服务端日志却显示任务执行完成了。排查了很久,发现是 HTTP 路由没有区分处理 GET、POST、DELETE 三类请求。Streamable HTTP 的约定里,GET用于建立 SSE 流,POST用于消息交换,DELETE用于关闭会话。我当时的实现把所有请求都当成普通 POST 处理,初始化请求一进来就被当成消息交换上下文结束了,客户端自然收不到后续结果。解决办法很简单:在一个路由里先判断请求方法,分三支处理。这个细节,比任何"最佳实践"都值钱。

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

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

立即咨询