从 Function Calling 到 MCP(四):把 Server 部署上线前,先处理好这五件事

0 阅读13分钟

前三篇写的都是能在本地跑起来的代码:stdio 的 Server、自己写的 Client、三个原语怎么配合。跑起来都很顺利,因为本地只有一个用户,就是你。

但第二篇我们把传输层从 stdio 换成了 Streamable HTTP。从那一刻起,性质就变了:你的服务暴露在了一个任何人都能 POST 的端口上。  这篇文章讲把它真正部署出去之前要处理的五件事——鉴权、会话、可观测性、超时控制、工具名治理。

五件事都不是协议难点,但每一件都能在半夜把你叫起来。

版本说明:Node 20+、@modelcontextprotocol/sdk 1.30、Express 5。鉴权部分用到 SDK 自带的 server/auth 模块。

第一件:没有鉴权的 HTTP 服务等于裸奔

stdio 时代不存在这个问题。Server 是 Host 拉起的子进程,能启动它的人已经有权限了。HTTP 时代,任何知道地址的人都能 tools/call——如果那个工具是 delete_file 或者 send_email,画面太美。

MCP 规范对授权的定义是 OAuth 2.1:服务端在 401 响应里通过 WWW-Authenticate 头告诉客户端去哪里拿 token,客户端走完授权流程再带 Authorization: Bearer xxx 回来。这是给公开服务用的完整方案,SDK 提供了 mcpAuthRouter 把授权端点全套配好。

但对内部服务、个人部署、或者网关后面挂着鉴权的场景,完整 OAuth 是过度设计。绝大多数时候你只需要校验一个 Bearer Token。SDK 为此单独提供了一个中间件:

import express from "express";
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
import { InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";

const VALID_TOKENS = new Set([process.env.MCP_TOKEN ?? "dev-token-12345"]);

app.use(
  "/mcp",
  requireBearerAuth({
    verifier: {
      async verifyAccessToken(token: string) {
        if (!VALID_TOKENS.has(token)) {
          // 注意:必须抛 SDK 的 InvalidTokenError,原因见坑一
          throw new InvalidTokenError("The access token is invalid");
        }
        return {
          token,
          clientId: "local-dev",
          scopes: ["mcp:tools"],
          expiresAt: Math.floor(Date.now() / 1000) + 3600,
        };
      },
    },
  })
);

不带 token 打过去,返回是这样的:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Missing Authorization header"
Content-Type: application/json; charset=utf-8

{"error":"invalid_token","error_description":"Missing Authorization header"}

三个细节值得注意。

第一,健康检查不要鉴权。  我把 /healthz 放在中间件之前,探活才能过。如果你用 k8s 或者负载均衡,鉴权失败的健康检查会让实例被反复重启。

第二,WWW-Authenticate 头是规范要求,不是可选装饰。  客户端靠它判断"这是认证问题,去拿 token"还是"服务挂了,重试也没用"。SDK 会自动带上,前提是错误类型用对(坑一)。

第三,token 校验要有时效。  上面 expiresAt 是必填的,没有它 SDK 会直接判 token 无效。

真实项目里,Bearer Token 只是兜底方案。如果你要提供的是公开服务,建议直接上 OAuth 2.1,把注册、授权、续期交给 mcpAuthRouter,自己别写。授权这东西,自己写的每一行都是未来的漏洞。

第二件:想清楚你的服务要不要有状态

第二篇我选了 stateless,当时给的理由是"工具只读就够"。这个判断在生产环境要重新审视一次。

stateless 的形态是每个请求新建一套 server + transport,用完即弃。它换来三个好处:部署几个实例都行、重启不丢东西、没有内存泄漏。代价是服务端记不住任何事。

什么时候 stateless 不够用:

  • 需要断线重连续传。  客户端网络抖动后重连,stateless 下前面跑了一半的任务直接消失
  • 需要跨请求的服务端状态。  比如分页浏览、多步向导、进度条
  • 需要精确的日志关联。  没有 sessionId,你只能靠客户端传的 requestId 对日志

上 stateful 的代码比想象中简单,核心是维护一个 Map:

const transports = new Map<string, StreamableHTTPServerTransport>();

app.post("/mcp", async (req, res) => {
  const sessionId = req.headers["mcp-session-id"] as string | undefined;
  const existing = sessionId ? transports.get(sessionId) : undefined;

  if (existing) {
    await existing.handleRequest(req, res, req.body);
    return;
  }
  if (sessionId) {
    // 客户端带了个我们不认识的 sessionId
    res.status(404).json({ error: "session not found" });
    return;
  }

  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () => randomUUID(),
    onsessioninitialized: (id) => {
      transports.set(id, transport);
      console.log(`[session] 新建 ${id}`);
    },
    onsessionclosed: (id) => {
      transports.delete(id);
      console.log(`[session] 关闭 ${id}`);
    },
  });

  transport.onclose = () => {
    if (transport.sessionId) transports.delete(transport.sessionId);
  };

  const server = createNotesServer();
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

// 客户端主动结束会话
app.delete("/mcp", async (req, res) => {
  const sessionId = req.headers["mcp-session-id"] as string | undefined;
  const transport = sessionId ? transports.get(sessionId) : undefined;
  if (!transport) {
    res.status(404).json({ error: "session not found" });
    return;
  }
  await transport.handleRequest(req, res);
});

跑起来的会话日志:

[session] 新建 d0a40ade-30ec-44fb-b421-a9ef62841f83
[session] 关闭 d0a40ade-30ec-44fb-b421-a9ef62841f83

伪造一个 sessionId 打过去,会拿到 {"error":"session not found"},404。这个分支必须自己处理,SDK 不会帮你兜。

stateful 的代价要说清楚:这个 Map 是进程内的,多实例部署时客户端两次请求落到不同机器上,会话就找不到了。真要横向扩展,得把 session 存到 Redis 之类的外部存储,并且处理"连接在 A 机器、消息发到 B 机器"的路由问题。所以我的建议是:先按 stateless 设计,等真的撞到墙再改。  无状态服务的运维成本比有状态低一个数量级。

第三件:让过程看得见

本地调试时,console.log 就是全部的可观测性。上线之后,你需要回答的问题是:这个工具为什么慢、模型为什么乱传参、这次调用到底成功了没有。

MCP 协议本身提供了一个通道:logging 通知。服务端可以在执行过程中主动往客户端推日志,客户端决定怎么展示。它的好处是日志跟着会话走,天然带上了 sessionId 和 requestId 的上下文。

Server 侧只要在 handler 里拿 extra.sendNotification:

server.registerTool(
  "search_notes",
  { /* ... */ },
  async ({ keyword }, extra) => {
    await extra.sendNotification({
      method: "notifications/message",
      params: { level: "info", logger: "search", data: `开始搜索:${keyword}` },
    });

    const hits = /* ... */;

    await extra.sendNotification({
      method: "notifications/message",
      params: { level: "info", logger: "search", data: `命中 ${hits.length} 条` },
    });

    return { content: [{ type: "text", text: /* ... */ }] };
  }
);

Client 侧要声明能力并注册 handler:

const client = new Client(
  { name: "prod-client", version: "1.0.0" },
  { capabilities: { logging: {} } }          // 不声明收不到
);

client.setNotificationHandler(LoggingMessageNotificationSchema, (n) => {
  console.log(`[server log:${n.params.level}] ${n.params.data}`);
});

实际收到的时间线是这样的,注意日志比结果先到:

[connect] sessionId = d0a40ade-30ec-44fb-b421-a9ef62841f83
[tools] search_notes
  [server log:info] 开始搜索:MCP
  [server log:info] 命中 2 条,耗时 74ms
[result] - [n1] MCP 的传输层有三种
- [n2] Function Calling 和 MCP 的区别

对排查问题的价值立竿见影:模型乱传参时,你能看到它收到的工具描述是什么;工具变慢时,你能看到卡在哪一步。比事后翻 Nginx 日志精准得多。

两个提醒。一是日志级别要分级,debug / info / warning / error 四档,info 是默认收发的档位,高频调试信息放 debug,客户端可以选择不接收。二是别把敏感数据塞进日志,这些通知会原样送到客户端,密码、token、用户隐私都不该出现。

如果你的服务只是内部自用,也可以不走协议通道,直接在 Express 上加一个结构化日志中间件,按 sessionId 聚合。两条路不冲突,协议通道的优势是客户端能实时看到过程,适合做产品内的执行进度展示。

第四件:给每次调用一个截止时间

本地调用一个工具,最坏情况是它卡住,你 Ctrl+C 就行。线上不行:一个没有超时的调用会一直占着连接,模型那边看不到结果就以为工具不存在,用户那边看到一个转圈圈的界面。

客户端侧的解法是 AbortSignal。callTool 的第三个参数接收 RequestOptions,里面可以传 signal:

// 这个工具本身要跑 5 秒,客户端 1 秒就不等了
try {
  const r = await client.callTool(
    { name: "slow_task", arguments: { seconds: 5 } },
    undefined,
    { signal: AbortSignal.timeout(1000) }
  );
  console.log(r.content);
} catch (err) {
  console.log(err.code);   // -32001
}

实测两个场景,输出很有说服力:

=== 场景一:客户端超时 1s,工具耗时 5s ===
  [server log:info] 开始,预计 5s
[catch] McpError: MCP error -32001: TimeoutError: The operation was aborted due to timeout
       code = -32001

=== 场景二:客户端超时 10s,工具耗时 2s ===
  [server log:info] 开始,预计 2s
[result] 完成,实际耗时 2s

两件事值得注意。

第一,错误码是 -32001。  这是协议定义的请求超时。把它和 -32603(内部错误)区分开很重要:前者该重试或提示用户重试,后者说明你的代码有 bug。在 Agent Loop 里,-32001 应该转成"工具超时,换个参数或放弃这个思路"喂回模型,而不是让整个循环崩掉。

第二,注意日志的顺序。  场景一里服务端的"开始,预计 5s"先到达客户端,然后才超时。这正是上一节说的 logging 通知的价值:调用失败时,你至少知道它死在哪一步。如果只有一句"超时",你连它有没有开始执行都不知道。

服务端同样需要超时。工具内部调第三方接口、查数据库,都要有自己的截止时间。否则客户端的 AbortSignal 只能切断连接,服务端那个慢任务还在跑,该占的资源一点没省。

关于重试:StreamableHTTPClientTransport 自带一套重连配置(initialReconnectionDelay、maxRetries),但它管的是已建立连接之后的断线重连,不是业务失败重试。业务重试要你自己写,而且要注意幂等——一个 save_note 重试三次就是三条重复笔记。

第五件:工具多了要治理

第二篇提过工具名冲突,当时的方案是加前缀。真到生产环境,这只是一个开始。

前缀规范要定死。  来源__工具名 的格式尽早确定,并且和工具描述一起带过去:

function withPrefix(tools: Tool[], source: string): ChatCompletionTool[] {
  return toModelTools(tools).map((t) => ({
    ...t,
    function: {
      ...t.function,
      name: `${source}__${t.function.name}`,
      description: `[${source}] ${t.function.description ?? ""}`,
    },
  }));
}

然后是白名单。  挂上五个 Server 不等于要把 80 个工具全给模型。按用户角色、按场景过滤,只开放当前需要的:

const ALLOWED = new Set(["notes__search_notes", "notes__get_note"]);
const visible = allTools.filter((t) => ALLOWED.has(t.function.name));

最后是按需加载。  工具清单本身是上下文成本,80 个工具的 JSON Schema 能吃掉几千 token,而且模型的选择准确率会明显下降。成熟做法是分两层:先给模型一个精简的工具目录(名字 + 一句话说明),它需要某一组时再拉详情。

const catalog = allTools.map((t) => `${t.function.name}: ${t.function.description.slice(0, 40)}`);
// 第一轮只给 catalog,模型说"我要用 notes__search_notes"再展开参数 schema

这一步做不做,取决于你的工具数量。十个以内感受不到,超过三十个就能看到明显劣化。

上线前的检查清单

把五件事压缩成一张可以逐条勾的表:

检查项不做的后果
/mcp 端点有鉴权任何人都能调用你的工具
健康检查不鉴权探活失败,实例被反复重启
WWW-Authenticate 头正确返回客户端无法区分认证失败和服务故障
明确 stateless 还是 stateful多实例部署时会话随机丢失
每次调用有超时控制慢任务占满连接,前端一直转圈
错误 token 返回 401 而非 500客户端把认证失败当服务崩溃处理
能区分 -32001 超时和 -32603 内部错误该重试的被报警,该报警的在重试
有 logging 通知或等价日志出问题只能靠猜
工具名有前缀规范多 Server 场景静默覆盖
工具清单有白名单或分页上下文膨胀,选择准确率下降
日志不含敏感信息凭证随通知外泄

踩坑记录

坑一:token 无效返回 500,不是 401

  • 现象:不带 Authorization 头时服务端老老实实返回 401;带一个错误的 token,却返回 HTTP 500 + {"error":"server_error","error_description":"Internal Server Error"}
  • 排查:在 Express 里加错误中间件打印堆栈,发现是 verifyAccessToken 里抛出的普通 Error 冒泡到了 Express 顶层
  • 原因:requireBearerAuth 只处理了"缺少 header"这一种情况。verifier 内部抛出的错误,它不区分类型,普通 Error 直接当服务异常上交
  • 解法:抛 SDK 提供的 InvalidTokenError,中间件会正确转成 401:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The access token is invalid"

同一个位置,抛错类型不同,客户端拿到的语义完全不同。这个坑不自己踩一遍,很容易在监控里把大量 401 看成 5xx 报警。

坑二:verifier 不返回 expiresAt,token 直接失效

  • 现象:token 明明在 VALID_TOKENS 里,客户端却报 {"error":"invalid_token","error_description":"Token has no expiration time"}
  • 原因:AuthInfo 类型里 expiresAt 看起来是可选字段,但 SDK 在校验时会检查它。没有过期时间的 token 被认为不符合 Bearer 规范
  • 解法:返回 expiresAt: Math.floor(Date.now() / 1000) + 3600。如果你用的是自己签发的长期 token,就给一个足够远的过期时间,别留空

坑三:想发 logging 通知,Server 自己也得声明能力

  • 现象:客户端注册好了 LoggingMessageNotificationSchema 的 handler,tools/call 却返回 Server does not support logging (required for notifications/message)
  • 原因:我一口气在 Client 的 capabilities 里加了 logging: {},忘了 Server 侧同样要声明。MCP 的能力是双向协商的,任何一方没声明,对应的请求或通知就会被拒
  • 解法:Server 构造时一起声明:
const server = new McpServer(
  { name: "notes-prod", version: "1.0.0" },
  { capabilities: { logging: {} } }
);

类似的协商还有 Resources 的 subscribe、Prompts 的 listChanged、以及 1.30 新增的 elicitation 和 tasks。加任何一个新能力,都要在两端各声明一次。

总结

  • 传输层换成 HTTP 的那一刻,服务就从"本地脚本"变成了"网络服务",五件事随之而来:鉴权、会话、可观测、超时、工具治理
  • 内部服务用 requireBearerAuth 校验 Bearer Token 就够了;公开服务请用完整的 OAuth 2.1,别自己写授权
  • stateless 优先,撞到墙再上 stateful;有状态服务的横向扩展成本高一个数量级
  • logging 通知是协议自带的观测通道,日志比结果先到客户端,调用失败时能定位到死在哪一步
  • 客户端用 AbortSignal 控制超时,错误码 -32001 要和 -32603 区分开,前者可重试后者是 bug
  • 工具名前缀、白名单、按需加载,做的时机是工具数量超过三十个之前

适用边界:如果你只是把 Server 挂在 Claude Desktop 或 Cursor 里自己用,这篇文章几乎整篇都可以跳过,那些客户端已经替你处理了鉴权和会话。只有当 Server 要部署到网络上、给多人或多个客户端共用时,这五件事才会从"可选"变成"必须"。

四篇写完,从 Function Calling 到 MCP 的主线也算走完了:第一篇写 Server 让工具可被复用,第二篇写 Client 让工具真正被用起来,第三篇补上 Resources 和 Prompts 把协议的三个原语讲全,第四篇处理上线前的工程问题。回头看,MCP 的价值从来不在协议本身有多精巧,而在于它把"AI 应用怎么接外部能力"这件事变成了一个有标准答案的问题——剩下的,都是你产品里能做出差异的地方。

协议与 SDK 迭代都很快,文中代码以官方文档为准:modelcontextprotocol.io。