前三篇写的都是能在本地跑起来的代码: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。