这篇接着讲 选型总览 里的”工具平台”——那个嵌在 Next.js 里、对外多租户、既给自研 Agent 也给 Claude Desktop / Cursor 这类外部客户端用的 MCP Server。
版本说明(2026-08):下面的
sessionIdGenerator、POST/GET/DELETE 和进程内 Session 是项目基于 MCP 2025 兼容协议的历史实现。MCP 2026-07-28 已移除 Streamable HTTP 的 GET 端点与协议级 Session;新实现应优先采用只使用 POST 的无状态协议。
它和能力适配器(Python 栈那篇)是两个极端:那个是独立容器、翻译现成能力;这个是要嵌进现有平台、要细粒度权限、要审计、要应对不可控的外部调用方。所以选型重心完全不同——这边几乎不用高级框架,而是用官方低层 SDK 把每个工程化环节做扎实。
协议:为什么是 MCP
先回答最底层的问题:为什么用 MCP,而不是直接 REST API 或者 OpenAI Function Calling。
| 方案 | 优势 | 劣势 |
|---|---|---|
| MCP | 标准化工具发现(tools/list)、Schema 驱动、官方 SDK、生态兼容 | 相对新,生态发展中 |
| 自定义 REST API | 简单直接、完全可控 | 无工具发现、每个工具各写一套路由 |
| OpenAI Function Calling | 与 LLM 直接集成 | 绑定 OpenAI 生态,非通用协议 |
| gRPC + Protobuf | 强类型、高性能 | 工具发现要自定义、Agent 生态支持弱 |
选 MCP 的决定性理由是工具发现和生态兼容:外部客户端(Claude Desktop、Cursor、VS Code Copilot)原生支持 MCP,协议层通常不需要再写自定义适配器,但仍要配置服务地址和认证,并验证客户端支持的协议版本与能力;而 tools/list 让 Agent 自动发现可用工具和参数,不用人肉翻文档。
顺带一个设计:我们同时暴露了两种入口——MCP(/v1/mcp,给外部 MCP 客户端)和 REST Skill API(给自研 Agent、定时任务、CLI 这类不方便走 MCP 协议的调用方)。同一个工具,两种协议入口,底层逻辑共享。这避免了”为了 MCP 放弃内部 REST 调用”或”为了 REST 重写一套”。
SDK:v1.29,稳定优先
用的是 @modelcontextprotocol/sdk 1.29,而 2.0 当时还在 beta。
选 v1 的理由和 Python 栈不追 FastMCP v3 是一个逻辑:
- 稳定优先:v1 发布超一年,生产验证充分;
- Next.js 兼容:
WebStandardStreamableHTTPServerTransport基于 Web 标准的 Request/Response,和 Next.js Route Handler 天然契合; - 高层 API 够用:
McpServer类已经够抽象。
v2 的包拆分、多框架适配器,以及面向 2026 无状态协议的迁移能力听着不错,但对当前单实例部署没有直接收益,而且我们已经在 SDK 之上加了一层封装(createMcpServer),包拆分的收益进一步被吃掉。
同样的规律:SDK 版本稳定优先 + 封装隔离。v2 等它正式发布、且某个特性真的解决了当前问题,再评估升级。
传输:Streamable HTTP 单端点
传输选了 Streamable HTTP,具体用的是 WebStandardStreamableHTTPServerTransport。
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: crypto.randomUUID,
onsessioninitialized: id => {
sessionStore.set(id, transport, apiKeyId);
},
});
在项目当时采用的 2025 兼容协议里,POST / GET / DELETE 共用一个 /v1/mcp 路由,分别承担请求、SSE 流和关闭会话。对比旧式 HTTP+SSE 的双端点,这种路由更集中;但它仍是会话式实现,不是 2026-07-28 的当前无状态协议。
如果从零实现当前协议,应移除 sessionIdGenerator、GET/DELETE 路由和 Mcp-Session-Id 依赖,只保留 POST。应用确实需要跨调用状态时,返回显式的任务或资源 ID,由客户端在后续工具参数中传回。
请求体大小限了 1MB(MAX_BODY_BYTES),防止异常大请求把服务拖垮。
传输选型本身(SSE vs Streamable-HTTP)的完整对比,我放到了 第四篇,这里只讲落地形态。
认证:API Key 独占
认证只接受 API Key,没用 OAuth,也没用 Session Cookie。
| 方案 | 优势 | 劣势 |
|---|---|---|
| API Key | 适合机器间调用、与 REST 认证统一 | 无用户身份绑定 |
| OAuth 2.0 | 标准化、支持动态注册 | 实现复杂、对客户端要求高 |
| Session Cookie | 支持 Web 用户 | MCP 客户端通常不支持 Cookie |
API Key 是 Agent 场景最自然的认证方式——它就是个机器凭证,和内部 REST API 的认证模型统一,每个 Key 还能配独立 scope。
认证流程是这样的:
Client Request
→ hashToken(rawToken) // SHA-256,不存明文
→ resolveApiKeyUserByTokenHash // Redis 缓存命中直接返回
// 未命中 → 查库 + 回填缓存
→ 检查 revokedAt / expiresAt // 是否吊销、是否过期
→ { apiKeyUser } // 通过
两个细节值得点出来:token 只存 SHA-256 哈希(泄露了库也拿不到原文),解析结果走 Redis 缓存(避免每次请求都查库)。还有一步会话归属校验——比对当前 sessionId 对应的 apiKeyId 和本次请求的 API Key,防止 A 的 Key 去复用 B 的会话。
权限:Scope-gated 工具注册
这一块是工具平台的精髓,但它和”权限码怎么设计”是两件事。这篇只讲机制层面:每个 API Key 只注册它被授权的工具集。
// 1. 查这个 API Key 有哪些 mcp-tool scope
const toolScopes = await db.apiKeyScope.findMany({
where: { apiKeyId, resourceType: "mcp-tool" },
});
// 2. 只注册被授权的工具集
for (const scope of toolScopes) {
SCOPE_TOOL_MAP[scope.resourceId]?.(server, ctx);
}
默认拒绝:没在 scope 里的工具,McpServer 根本不注册,Agent 连发现都发现不了。这比”注册全部工具再在调用时拦截”安全得多——工具从一开始就不暴露。
至于 scope 内部怎么按 read / write / dangerous 分级、高风险动作怎么走审批状态机,那是权限码设计的事,我专门写过:
→ MCP 权限怎么分级?只读、可操作和高风险动作 → MCP 工具权限码怎么设计?read、write、dangerous 三层模型
会话:进程内存为什么够用
会话存进程内存,用一个 McpSessionStore:Map + 30 分钟 TTL(无活动自动清理)+ 5 分钟清理周期。
这里有个 Next.js 特有的坑:开发模式热更新(HMR)会让模块重新加载,可能产生多个 sessionStore 实例。解法是把单例挂在 globalThis 上,跨 HMR 复用同一个实例。
为什么敢用进程内存,不怕水平扩展?因为当前是单实例部署:
- 零外部依赖,不用引入 Redis 存会话;
- ~90 行代码就搞定,实现极简;
- 内存访问,无网络开销。
已知限制也清楚:这套 2025 兼容实现不支持多实例(需要 sticky session 或共享会话存储),进程重启会丢会话。2026-07-28 协议已经提供了无状态升级方向,但它包含破坏性变化,不能假设只升级依赖就会自动兼容;需要同步改路由、握手与客户端协商。
会话存哪,看规模。单实例够用就别引入外部存储——这是”按当前规模选最简方案”的典型例子,但要同时看清升级路径。
审计:先可靠接收,再异步落库
因为是对外服务,每次工具调用都要留痕。做法是给每个工具 handler 包一层 withMcpCallLogging:
export function withMcpCallLogging(toolName, handler, ctx) {
return async input => {
const start = Date.now();
try {
const result = await handler(input);
await enqueueAgentCallAudit(meta, {
channel: "mcp",
toolName,
input,
resultStatus: result.isError ? "error" : "success",
durationMs: Date.now() - start,
});
return result;
} catch (err) {
await enqueueAgentCallAudit(meta, {/* ... */});
throw err;
}
};
}
关键设计不是 fire-and-forget,而是先确认审计事件已被可靠接收,再把慢速写库异步化。enqueueAgentCallAudit 可以写入持久消息队列、事务 Outbox 或具备确认机制的日志管道;只有拿到接收确认后才返回工具结果。若合规要求“每次调用都留痕”,队列不可用时就应拒绝高风险调用或进入明确的降级状态,不能只写 console.error 后继续。
日志保留期应由实际合规与业务政策确定;90 天只是本项目配置,不是通用标准。入参出参超 4096 字符会截断,因此还要记录摘要、对象 ID 或受控归档位置,避免关键证据丢失。进入异步队列前,应把 IP、UA、requestId、调用者和权限判定等必要字段复制成普通可序列化对象,不依赖框架请求对象的生命周期。
错误处理:协议层与工具层分离
错误分两层,别混:
传输层先使用 HTTP 状态码:缺少或无效凭证返回 401,权限不足返回 403,请求体过大返回 413。进入 JSON-RPC 后,再使用标准错误码:
| 错误码 | 标准含义 |
|---|---|
| -32700 | Parse error,JSON 解析失败 |
| -32600 | Invalid Request,请求对象无效 |
| -32601 | Method not found,方法不存在 |
| -32602 | Invalid params,参数无效 |
| -32603 | Internal error,内部错误 |
-32000 到 -32099 是服务端自定义错误区间。如果项目用 -32000 表示旧协议会话无效,必须在接口文档中注明它是应用约定;认证失败则优先在 HTTP 层返回 401/403,不要把自定义 -32001 写成 JSON-RPC 标准定义。
工具层用 MCP 的 isError: true,把业务错误包在 content 里返回,不抛异常打断协议:
catch (err) {
return {
content: [{ type: "text", text: `发送异常:${msg}` }],
isError: true,
};
}
还有个挺有意思的业务保护:update_user_memory 工具有个合并保护——当新内容长度小于已有记忆的 50%(且已有记忆 ≥ 100 字符),拒绝写入并返回 merge_required。这是防止 Agent 拿一小段内容把一大段已有记忆整个覆盖掉。
架构总览
把上面拼起来,整个 MCP 子系统长这样:
AI Agent / MCP Client
│ JSON-RPC 2.0 over Streamable HTTP
▼
/v1/mcp (Next.js Route Handler)
│
┌───────────┼───────────┐
▼ ▼ ▼
Auth Scope Session
(API Key) (Redis) (In-Memory)
│
▼
McpServer (per-session)
registerToolsForScopes + withMcpCallLogging
│
┌───────────┼───────────┐
▼ ▼ ▼
Message Memory Agent Log
Service Service (durable audit queue)
每一层都对应前面讲的一个选型决策——这张图本身就是这份选型的总结。
动手验证
这篇的载体是 agent-runtime-lab,可以实际跑起来:
pnpm seed生成四档 demo API Key(只读 / 可写 / 危险 / 审批),对应不同 scope;- 换不同 Key 连同一个 MCP Server,能看到的工具、能执行的动作完全不同——这是”scope-gated 注册”最直观的证明;
- 用危险档调删除类工具,能看到完整的两轮审批流程。