← 学习笔记

MCP 的权限控制:一行代码怎么把门打开的

用户只说了一句「帮我退一下订单 12345」,钱就退到了别人账上。
问题不在模型,在你代码里的那一行。
场景设定:MCP Server 是你自己家的——客服系统调自家订单服务。第三方 Server 的情况放在附录。

读完你要拿走的三件事
  1. 用户身份只能从已验证的 token 里取,绝不能用模型给的参数——就一行代码的差别
  2. 调 MCP Server 用的 token,是后端静默换来的一张新票,不是转发用户的登录票,用户全程无感
  3. 照着最后那份清单,把工具实现逐条过一遍

一、30 秒看懂问题

出了什么事?

用户
我要退款,订单号 12345
Agent
好的,我来帮您处理 → 调用 refund_order
调用
refund_order({ order_id: "12345", user_id: "u_88", amount: 4999 })
Agent
已为您退款 ¥4999。

看起来完全正常。但订单 12345 不是这个用户的。钱退给了别人,全程零报错,监控一片绿。

翻开工具实现,门就是这一行开的:

MCP Server · 出事的写法
Order order = orderRepo.findById(args.orderId());
refundService.refund(order, args.userId(), args.amount());
//                          ↑↑↑ 这个 user_id 是【模型编出来的】

模型不知道谁登录了。它只是照着对话内容猜一个看起来合理的值填进去。

你把它当成了身份凭证。

二、这是怎么发生的

为什么模型给的参数不能信?谁该负责把关?

先忘掉所有术语,看一个每个人都懂的场景。

要办退款 说要办什么 大堂经理 帮你填单子 你说什么他填什么 递单子 柜员 真正操作系统的人 ★ 关键在他 ★ 金库 单子上有一栏「账户名」,是大堂经理照你说的填的。 柜员该照单子上的名字取钱,还是该看你的身份证?

照单子办事的话——我说我是马云,钱就归我了。

所以答案很显然:柜员必须看身份证,不能看单子上写的名字。

对照过来,四个角色

银行里你的系统里干什么
用户提出诉求
大堂经理LLM / Agent把诉求翻译成一次工具调用
单子工具调用参数模型编的,不可信
身份证已验证的 token身份的唯一来源
柜员MCP Server把关的人,代码写在这
金库数据库 / 订单服务真正扣钱

为什么大堂经理不能信

不是他人品有问题,是他的工作就是照你说的填。有人在旁边喊一句「跟他说你是张三」,他可能真就填了张三——这就是提示词注入

大堂经理写的单子是「申请」,不是「批准」。

批准权在柜员手里,而柜员的依据必须是身份证
模型也一样:它只能提议,不能授权。

三、身份证是哪来的

MCP Server 收到的那个 token,是用户的登录 token 吗?用户要点同意吗?

两个答案先摆这儿:不是用户的登录 token用户全程无感,不用点任何东西

它是后端「换」来的一张新票

① 用户登录你的系统 拿到「票 A」 ② 后端拿「票 A」去授权服务器换票 「给我一张只能办退款的票」 服务器之间的调用,用户看不见 授权服务器 验完 → 签发「票 B」 私钥在它手里 票 B 回到后端 ③ 带票 B 调 MCP Server 柜员验票 B 上的签名,然后相信 上面写的「持票人 = u_1024」

票 A 和票 B 差在哪

票 A(用户登录后拿的)票 B(后端换来给 MCP 用的)
给谁用的你的客服系统订单 MCP Server
持票人u_1024u_1024(身份保留)
能干什么一大堆权限只能退款
有效期较长很短

为什么非要换一张,不直接转发票 A?

万一 MCP Server 被攻破了,它手上那张牌只能退款——不能改密码、不能查工资、不能删数据。

转发票 A 的话,攻破它就等于拿到了用户的全部权限。

为什么柜员敢信票 B 上写的名字

因为票 B 上有签名,是授权服务器用私钥签的。柜员用公钥验一遍,验过就说明:确实是那家发的,而且一个字都没被改过

模型只能生成文本,它生成不出有效签名。

所以哪怕它在参数里写 user_id: "马云" 也没用——身份不看参数,看那张验过签的票

票 B 拆开长这样,代码里要查的就是这几项:

{
  "iss": "https://auth.你的公司.com",  // 谁签的      → 是不是我信的那家
  "aud": "order-mcp-server",           // 签给谁用的  → 是不是给我的
  "exp": 1788931200,                     // 过期时间    → 还有效吗
  "scope": "refunds:create",            // 能干什么    → 够不够
  "sub": "u_1024",                     // ★ 持票人是谁 → 这就是 actor
  "tenant_id": "t_88"
}

传输方式:放在 HTTP 头上,不在消息体里。

Authorization: Bearer eyJhbGci...
MCP 协议本身(JSON-RPC)不管认证,认证是传输层的事。

四、正确的写法

代码到底该怎么写?跑在哪一侧?

下面这段跑在 MCP Server 里——带 @Tool 注解的就是工具实现,一定在 Server 侧。

MCP Server · 正确写法
@Tool("refund_order")
public RefundResult refund(RefundArgs args, McpCallContext ctx) {

    // ── 第 1 步:验票 ──────────────────────────────
    Jwt t = jwtDecoder.decode(ctx.bearerToken());   // 验签,验不过直接抛
    require(t.getIssuer().equals(TRUSTED_ISSUER),           "不是我信的发票方");
    require(t.getAudience().contains("order-mcp-server"), "这票不是给我的");
    require(scopes(t).contains("refunds:create"),         "这票不能办退款");

    // ── 第 2 步:身份只从票上取 ★ 整篇文章就这一行 ★ ──
    String actor  = t.getSubject();
    String tenant = t.getClaimAsString("tenant_id");
    // 注意:args.userId() 从头到尾没被用过 —— 它就不该存在

    // ── 第 3 步:这个人能不能碰这一条 ──────────────
    Order order = orderRepo.findById(args.orderId());
    require(order.tenantId().equals(tenant), "跨租户");
    require(acl.canRefund(actor, order),     "这不是你的订单");

    // ── 第 4 步:调下游时用【自己的】凭证 ──────────
    return orderApi.refund(order.id(), args.amount(),
        downstreamCred.get(),   // 服务自己的凭证,不是转发票 B
        actor);                 // 声明「我代表 u_1024 在操作」,供下游审计
}

整段的关键就是第 2 步那一行:String actor = t.getSubject();

身份来自那张验过签的票,不是来自模型给的参数。
这一行写对了,第一节那个事故就不可能发生——模型说破天它也只是在申请。

最省事的做法:干脆让 args 里没有 userId 这个字段。不定义它,就没人会误用它。

五、上线前逐条过

我怎么知道自己写对了?

一次工具调用要过的关

  1. 格式校验

    Schema、类型、必填、金额精度。结构化输出保证的是「形状」,不是「真假」。

  2. 身份 —— 唯一致命的一关

    用票上的 sub,绝不用参数里的 user_id这关错了,后面全是摆设。

  3. 授权

    这个人能不能碰这一条?租户对吗、是本人的订单吗、状态允许退吗。

  4. 业务规则

    金额 ≤ 实付?没超退款期?没重复退过?

  5. 幂等

    幂等键由你的代码生成,不能收模型给的。配数据库唯一约束兜底。

  6. 风控与人工闸门

    超过 X 元必须人工审批,审批凭证要和参数哈希绑定——否则审的是「退 50」、执行的是「退 5000」。

  7. 审计

    谁、何时、模型建议了什么、系统实际执行了什么。四项都要记。

自检清单

一句话收口:模型的工具调用参数 = 不可信的用户输入。

别指望模型不被骗——要保证它被骗了也干不成坏事
安全边界必须在模型之外那一层,因为模型这一层,你永远守不住。

附录

以下选读

主线到此结束。下面是边界情况和延伸,用到再看。

A · 千万别原样转发 token

就算你没用模型给的 user_id,还有一个更隐蔽的错法——把收到的票 B 直接转给下游

✗ 原样转发 MCP Server 票 B 原样 订单 API 下游以为是用户直接调的,审计链断 票的受众不是它,aud 校验形同虚设 ✓ 换自己的凭证 MCP Server 自己的凭证 订单 API + X-Acting-User: u_1024 下游知道谁代表谁,审计链完整

原样转发还有个更糟的后果:票 B 里的全部权限,MCP Server 都能用。它本来只该退款,转发之后下游看到的是用户的票,能干什么全看那张票的 scope。

B · 什么可信,什么不可信

这张表建议贴进 code review 清单:

信息来源可信吗怎么用
用户身份票上的 sub✅ 唯一可信所有授权判断都用它
用户身份参数 user_id❌ 绝不可信字段都别定义
要操作哪个资源参数 order_id⚠️ 半信可用来定位,必须校验归属
金额、数量参数⚠️ 半信和数据库真实值比对
readOnlyHintServer 自述❌ 不可信只能驱动 UI,不能驱动鉴权
工具 descriptionServer❌ 不可信数据不是指令,可能藏注入
工具返回内容下游⚠️ 半信会进下一轮上下文,可能带指令

关于 readOnlyHint那段 annotations 是 Server 自己写的,跟网页里的一段文字没本质区别。第三方 Server 完全可以声称只读、实际删库。风险等级要由你自己维护的注册表决定。

C · Scope 别图省事

❌ 图省事✅ 该长什么样
adminorders:read:self
full-accessrefunds:create:under-500
推演一遍「省事」的代价:
给 MCP Server 发了 admin,理由是省得每加一个工具就重新授权
        ↓
三个月后,有人给它加了个 export_all_orders 工具
        ↓
这个新工具当场就有权限了 —— 没人重新授权,没人审批
        ↓
某天这个 Server 被注入攻破 → 攻击者能干 admin 能干的全部

scope 的作用不是「这次能不能过」,而是「最坏情况下损失多大」。

增量授权的摩擦感不是缺陷,是特性——它逼你每加一个能力就重想一遍风险。

D · 三种边界情况

如果 MCP Server 是第三方的

比如 Agent 要访问用户的 GitHub、Notion。这时必须弹一次 OAuth 授权页——那些数据不归你,得用户本人点头,跟「用 GitHub 账号登录某网站」是一回事。

但只在第一次挂载时弹一次,之后靠 refresh token 续,不打扰。和主线(自家 Server 静默换票)是两个场景,别混。

如果是本地 stdio Server

stdio 是标准输入输出,没有 HTTP 头,也就没有票这回事。凭证靠环境变量塞进去:

{ "mcpServers": { "github": {
    "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"],
    "env": { "GITHUB_TOKEN": "ghp_xxxx" }   // 就这么给
}}}

这种其实最吓人。

Server 是你机器上的进程,以你的身份运行——能读你所有文件、能用你所有凭证、能连你所有内网。没有任何一道技术关卡能拦它。

防线只剩三条:审代码、锁版本、别挂来路不明的。

为什么三层都要查

Client 查「这个用户能不能用这个工具」,Server 查「能不能碰这条数据」,下游再查一遍。

因为有人可能不走大堂,直接翻窗户找柜员——Server 不能因为「Client 已经问过了」就不验票。