AI Agent MCP 服务授权深度实践:从 OAuth 2.1 到事务级 Token
当 Agent 只是帮你查查天气、做做加法时,授权不过是一把粗粒度的门锁——进了门随便逛。但一旦 Agent 能代你退款、改订单、删数据,这把锁就远远不够了。你需要的不再是"这个人能不能进来",而是"这次进来能碰哪个订单、能做什么动作、做完就不能再用这把钥匙"。
这篇文章要聊的,就是如何在 MCP(Model Context Protocol)体系下,给 Agent 的每一次敏感操作套上精确的缰绳。我们会从 MCP 已有的 OAuth 2.1 基座出发,逐层叠加 RFC 9396 Rich Authorization Requests、一次性 Token 消费机制、业务层幂等保障,最后用 Transaction Token 把整条调用链串起来。
OAuth 2.1:MCP 已经铺好的地基
MCP 规范选了 OAuth 2.1 作为授权协议,这个选择本身没什么好争议的——它是 OAuth 2.0 的安全加固版,强制 PKCE、禁止 Implicit Grant、要求 HTTPS,把过去十年踩过的坑都填上了。
整个流程大概是这样的:MCP Client 首次连接 MCP Server,Server 甩回一个 401,附带一个指向受保护资源元数据(Protected Resource Metadata, RFC 9728)的 URL。Client 顺着这个 URL 找到授权服务器,走一遍标准的 Authorization Code + PKCE 流程,拿到 access token,之后每次请求带上 Authorization: Bearer xxx 就行了。
Client → MCP Server: "我要调用 refund_order 工具"MCP Server → Client: "401, 去这里认证: /.well-known/oauth-protected-resource"Client → AuthZ Server: Authorization Code + PKCE 流程AuthZ Server → Client: { access_token, refresh_token, scope: "mcp:tools" }Client → MCP Server: "Authorization: Bearer eyJhbG..."MCP Server: 验证 token → 执行工具
这一层解决的是"你是谁"和"你大致能干什么"。scope 字段像一个粗筛——mcp:tools 说明你能调用工具,但不管你调的是查询余额还是转账一百万。
问题来了:Agent 拿着一个 scope: mcp:tools 的 token,理论上可以调用所有已注册的工具、操作任何实体。这就像给了一个实习生整栋大楼的万能门卡——他确实该进得了大楼,但不该打开保险柜。
RFC 9396 RAR:把"能碰哪个订单"写进 Token 里
Rich Authorization Requests(RFC 9396)是 OAuth 2.0 的一个扩展,解决的正是 scope 太粗的问题。它引入了 authorization_details 参数,让你用结构化 JSON 精确描述这次授权的边界。
传统 scope 是一个空格分隔的字符串,表达力非常有限:
scope=mcp:tools orders:write你没法在 scope 里说清楚"只能退 ORD-001234 这笔订单、金额不超过 500 块"。而 RAR 可以:
{"authorization_details": [{"type": "mcp:order_operation","actions": ["refund"],"order_id": "ORD-2024-001234","constraints": {"max_amount": 500.00,"currency": "CNY"}}]}
这段 JSON 跟着 token 请求一起提交给授权服务器,授权服务器审批后把它嵌入签发的 JWT 中。MCP Server 收到请求时,不光验签、检查 exp,还要把请求参数和 authorization_details 里的约束做匹配。
在 MCP Server 侧,验证逻辑大概长这样:
async function validateRAR(token: DecodedJWT, request: ToolCallRequest) {const details = token.authorization_details;if (!details || details.length === 0) {throw new Error('Token 缺少 authorization_details');}// 找到与当前工具调用匹配的授权条目const matched = details.find(d =>d.type === 'mcp:order_operation' &&d.actions.includes(request.action) &&d.order_id === request.params.orderId);if (!matched) {throw new Error(`Token 未授权对订单 ${request.params.orderId} 执行 ${request.action}`);}// 检查约束条件if (matched.constraints?.max_amount &&request.params.amount > matched.constraints.max_amount) {throw new Error(`退款金额 ${request.params.amount} 超过授权上限 ${matched.constraints.max_amount}`);}}
这段代码做的事情很直白:从 JWT 里拿出 authorization_details,逐条匹配当前请求的操作类型、目标实体、金额约束。任何一项不匹配,请求就被拒绝。
HashiCorp Vault 在其 AI Agent 支持中就是这么干的——用 vault:path_access 类型的 RAR,把 token 限制到具体的 Vault 路径和操作。Agent 拿到的不是一个"可以访问 Vault"的宽泛令牌,而是"只能读 database/creds/readonly-role 这一个路径"的精确凭证。
jti + 服务端消费记录:让 Token 只能用一次
RAR 解决了"能对什么做什么",但没有解决"做几次"。一个绑定了订单的 token,如果被截获或者由于网络重试被重复提交,可能导致退款被执行两次。
JWT 规范(RFC 7519)预留了 jti(JWT ID)字段,就是为了防重放。思路很简单:每个 token 有唯一 ID,服务端记录哪些 ID 已经被消费过,下次再来就拒绝。
这里有个哲学问题绕不开——JWT 的设计初衷是无状态验证,而 jti 消费记录引入了服务端状态,这不是自相矛盾吗?
答案是:「这是有意识的工程权衡,不是设计缺陷。」 JWT 规范本身定义 jti 字段就说明设计者预料到了这个需求。关键在于这个状态的"重量"可以做到极轻——你不需要存储整个 session,只需要一个 jti 到过期时间的映射,而且 TTL 可以对齐 token 的有效期,到期自动蒸发。
用 Redis 实现是最自然的选择:
import Redis from 'ioredis';const redis = new Redis();async function consumeToken(jwt: DecodedJWT): Promise<boolean> {const { jti, exp } = jwt;if (!jti) throw new Error('Token 缺少 jti 声明');// 计算剩余有效期作为 TTLconst ttlSeconds = exp - Math.floor(Date.now() / 1000);if (ttlSeconds <= 0) throw new Error('Token 已过期');// SET NX:仅在 key 不存在时设置成功,原子操作const result = await redis.set(`mcp:consumed:${jti}`,'1','EX', ttlSeconds,'NX');// result === null 说明 key 已存在 → token 已被消费过return result !== null;}
SET key value EX ttl NX 这条命令干了两件事:检查 key 是否存在 + 不存在则写入,整个操作是原子的。并发请求打过来,只有一个能拿到 OK,其余都会得到 null。TTL 对齐 token 过期时间,意味着 token 过了有效期后,这条记录自动消失,Redis 不会无限膨胀。
存储压力有多大?算一笔账:假设 token 有效期 5 分钟,系统每秒处理 100 个敏感操作,那 Redis 里最多同时存在 5×60×100 = 30000 条 key,每条 key 几十字节,总共不到 2MB。这对 Redis 来说约等于无。
业务层幂等:最后一道防线
jti 消费记录解决的是 token 层面的防重放,但现实中还有一种情况它挡不住:网络超时导致客户端不确定请求是否成功,于是用一个新 token 重新发起相同操作。两次请求用的是不同 jti,消费记录机制看到的是两个"全新"请求。
这时候就需要业务层面的幂等保障了。思路是给每个业务操作一个不可变的业务键(idempotency key),服务端用这个键去重:
server.registerTool("refund_order", {inputSchema: {orderId: z.string(),amount: z.number(),idempotencyKey: z.string(), // 客户端生成的唯一请求标识},}, async ({ orderId, amount, idempotencyKey }, context) => {// 先查有没有已完成的相同操作const existing = await db.query(`SELECT result FROM refund_operationsWHERE idempotency_key = ? AND status = 'completed'`,[idempotencyKey]);if (existing) {// 已经成功执行过,直接返回之前的结果return { content: [{ type: "text", text: existing.result }] };}// 用数据库唯一约束 + INSERT 保证并发安全try {await db.query(`INSERT INTO refund_operations (idempotency_key, order_id, amount, status)VALUES (?, ?, ?, 'processing')`,[idempotencyKey, orderId, amount]);} catch (e) {if (e.code === 'DUPLICATE_KEY') {// 另一个请求正在处理中return { content: [{ type: "text", text: "操作处理中,请稍后查询结果" }] };}throw e;}// 执行实际退款const result = await paymentService.refund(orderId, amount);await db.query(`UPDATE refund_operations SET status = 'completed', result = ?WHERE idempotency_key = ?`,[result.message, idempotencyKey]);return { content: [{ type: "text", text: result.message }] };});
这段代码有三个层次的保护:先查是否已完成(快速返回),再用唯一约束抢占执行权(并发安全),最后才执行真正的业务操作。即使 token 层的防重放被绕过,业务层也不会重复退款。
幂等和 jti 消费不是互相替代的关系,而是互补的:jti 拦截的是"同一个凭证被重复使用",幂等拦截的是"不同凭证发起相同业务操作"。
Transaction Token:跨服务调用链的信任传递
前面讨论的所有机制都发生在 MCP Client 和 MCP Server 之间的第一跳。但真实世界里,MCP Server 收到请求后往往不是自己独立完成操作,而是要调用下游的支付服务、库存服务、通知服务。这条调用链上,每一跳都需要知道"这次请求是谁发起的、授权了什么、针对哪个实体"。
IETF 正在推进的 Transaction Token 规范(draft-ietf-oauth-transaction-tokens)就是为这个场景设计的。
思路是这样的:可信域(Trust Domain)内部署一个 Transaction Token Service(TTS),入口工作负载(比如 MCP Server)收到外部请求后,拿着原始 access token 去 TTS 换取一个短命的 Txn-Token。TTS 验证原始 token 的合法性,把请求上下文打包进一个签名的 JWT,然后返回给请求方。之后整条调用链上,每一跳都通过专用的 Txn-Token HTTP Header(而非 Authorization Header)传递这个 token,下游服务只需验签 + 检查 claims 就能确认授权上下文。
用户 → MCP Client → MCP Server(入口工作负载)││ 携带 access_token 请求 TTS▼┌───────────┐│ TTS │ ← 验证 access_token,签发 Txn-Token└───────────┘│▼ 返回 Txn-TokenMCP Server││ Txn-Token Header▼支付服务 ← 验签,检查 sub/scope/tctx││ 同一个 Txn-Token Header▼通知服务 ← 验签,检查 sub/scope/tctx
按照规范(draft-09),一个 Txn-Token 的 JWT Body 大概长这样:
{"iat": 1753200000,"aud": "mcp-trust-domain.example","exp": 1753200300,"txn": "a7f7e2c1-3b4d-4e5f-9a8b-1c2d3e4f5a6b","sub": "user-alice-001","req_wl": "mcp-server.mcp-trust-domain.example","scope": "order.refund","rctx": {"req_ip": "203.0.113.42","authn": "pwd"},"tctx": {"action": "refund","order_id": "ORD-2024-001234","amount": "500.00","currency": "CNY"}}
几个关键点:sub 标识发起人,txn 是全局唯一的事务 ID 用于审计和防重放,scope 尽可能窄地描述这次事务的目的,tctx(Transaction Context)携带不可变的业务参数,rctx(Request Context)携带环境信息如来源 IP。整条链上的每个服务都能看到完整的上下文,也都能验证这个上下文没被篡改。
这解决了一个微服务架构下的经典难题:如果支付服务只收到一个"退款 ORD-001234"的请求,它怎么知道这个请求确实来自一个经过授权的 MCP Server,而不是被某个内部服务的漏洞伪造的?Txn-Token 由 TTS 统一签发、所有工作负载独立验签,任何一跳都可以独立验证这次操作的合法性。
完整的防御纵深
把这些层叠起来,一次敏感操作的完整授权流程是:

每一层解决一个特定维度的问题,缺一不可,但也不是所有场景都需要全栈上满。查询类操作可能只需要前两层,涉及资金的操作则最好五层齐备。
不是所有操作都需要全套
工程不是论文,不需要为了方案完整而把每一层都加上。一个合理的决策矩阵:
查询余额、获取订单详情这类只读操作——OAuth 2.1 + 基础 scope 就够了。Agent 看了一眼订单金额,不改变任何状态,重放也无害。
修改收货地址、更新用户偏好——加上 RAR 绑定实体,但不需要一次性消费。操作本身可重复执行,结果相同。
退款、转账、删除数据——全套上阵。token 绑定实体 + 一次性消费 + 业务幂等,三道锁确保这笔钱只退一次。
多服务协作的复杂事务——Txn-Token 进场。把授权上下文从入口透传到每一个参与者,端到端可追溯。
选择哪些层取决于一个简单的问题:「这个操作如果被重复执行或被未授权方执行,后果有多严重?」 后果越重,层数越多。
回到起点
MCP 规范选择 OAuth 2.1 作为授权基座,这个起点是稳固的。但在 Agent 代替人类执行敏感操作的场景下,仅靠"这个人能不能进来"是不够的。RFC 9396 让你说清楚"这次进来只能碰这个订单",jti 消费记录确保"这把钥匙用完就作废",业务幂等兜住"万一有人配了一把新钥匙做同样的事",Txn-Token 则让整条执行链上的每个参与者都能独立验证这次操作的合法性。
安全不是一面墙,是一组层层递进的筛网。每多一层筛网,攻击者需要同时突破的条件就多一个。对于 AI Agent 这种"一旦授权就自主行动"的系统来说,这种纵深防御不是过度设计,是底线。
参考规范
OAuth 2.1 — draft-ietf-oauth-v2-1-13 RFC 9396 — OAuth 2.0 Rich Authorization Requests RFC 7519 — JSON Web Token (JWT) RFC 9728 — OAuth 2.0 Protected Resource Metadata RFC 8707 — Resource Indicators for OAuth 2.0 draft-ietf-oauth-transaction-tokens-09 — Transaction Tokens RFC 8693 — OAuth 2.0 Token Exchange MCP 授权规范 MCP 安全最佳实践 HashiCorp Vault — Rich Authorization Requests for AI Agent
夜雨聆风