用户只说了一句「帮我退一下订单 12345」,钱就退到了别人账上。
问题不在模型,在你代码里的那一行。
场景设定:MCP Server 是你自己家的——客服系统调自家订单服务。第三方 Server 的情况放在附录。
出了什么事?
refund_order看起来完全正常。但订单 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(用户登录后拿的) | 票 B(后端换来给 MCP 用的) | |
|---|---|---|
| 给谁用的 | 你的客服系统 | 订单 MCP Server |
| 持票人 | u_1024 | u_1024(身份保留) |
| 能干什么 | 一大堆权限 | 只能退款 |
| 有效期 | 较长 | 很短 |
为什么非要换一张,不直接转发票 A?
万一 MCP Server 被攻破了,它手上那张牌只能退款——不能改密码、不能查工资、不能删数据。
转发票 A 的话,攻破它就等于拿到了用户的全部权限。
因为票 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 侧。
@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 这个字段。不定义它,就没人会误用它。
我怎么知道自己写对了?
Schema、类型、必填、金额精度。结构化输出保证的是「形状」,不是「真假」。
用票上的 sub,绝不用参数里的 user_id。这关错了,后面全是摆设。
这个人能不能碰这一条?租户对吗、是本人的订单吗、状态允许退吗。
金额 ≤ 实付?没超退款期?没重复退过?
幂等键由你的代码生成,不能收模型给的。配数据库唯一约束兜底。
超过 X 元必须人工审批,审批凭证要和参数哈希绑定——否则审的是「退 50」、执行的是「退 5000」。
谁、何时、模型建议了什么、系统实际执行了什么。四项都要记。
args.userId,出现一次就是一个洞user_id 这个字段iss / aud / exp / scope 四项,一项不少一句话收口:模型的工具调用参数 = 不可信的用户输入。
别指望模型不被骗——要保证它被骗了也干不成坏事。
安全边界必须在模型之外那一层,因为模型这一层,你永远守不住。
主线到此结束。下面是边界情况和延伸,用到再看。
就算你没用模型给的 user_id,还有一个更隐蔽的错法——把收到的票 B 直接转给下游:
原样转发还有个更糟的后果:票 B 里的全部权限,MCP Server 都能用。它本来只该退款,转发之后下游看到的是用户的票,能干什么全看那张票的 scope。
这张表建议贴进 code review 清单:
| 信息 | 来源 | 可信吗 | 怎么用 |
|---|---|---|---|
| 用户身份 | 票上的 sub | ✅ 唯一可信 | 所有授权判断都用它 |
| 用户身份 | 参数 user_id | ❌ 绝不可信 | 字段都别定义 |
| 要操作哪个资源 | 参数 order_id | ⚠️ 半信 | 可用来定位,必须校验归属 |
| 金额、数量 | 参数 | ⚠️ 半信 | 和数据库真实值比对 |
readOnlyHint | Server 自述 | ❌ 不可信 | 只能驱动 UI,不能驱动鉴权 |
| 工具 description | Server | ❌ 不可信 | 是数据不是指令,可能藏注入 |
| 工具返回内容 | 下游 | ⚠️ 半信 | 会进下一轮上下文,可能带指令 |
关于 readOnlyHint:那段 annotations 是 Server 自己写的,跟网页里的一段文字没本质区别。第三方 Server 完全可以声称只读、实际删库。风险等级要由你自己维护的注册表决定。
| ❌ 图省事 | ✅ 该长什么样 |
|---|---|
| admin | orders:read:self |
| full-access | refunds:create:under-500 |
推演一遍「省事」的代价: 给 MCP Server 发了 admin,理由是省得每加一个工具就重新授权 ↓ 三个月后,有人给它加了个 export_all_orders 工具 ↓ 这个新工具当场就有权限了 —— 没人重新授权,没人审批 ↓ 某天这个 Server 被注入攻破 → 攻击者能干 admin 能干的全部
scope 的作用不是「这次能不能过」,而是「最坏情况下损失多大」。
增量授权的摩擦感不是缺陷,是特性——它逼你每加一个能力就重想一遍风险。
比如 Agent 要访问用户的 GitHub、Notion。这时必须弹一次 OAuth 授权页——那些数据不归你,得用户本人点头,跟「用 GitHub 账号登录某网站」是一回事。
但只在第一次挂载时弹一次,之后靠 refresh token 续,不打扰。和主线(自家 Server 静默换票)是两个场景,别混。
stdio 是标准输入输出,没有 HTTP 头,也就没有票这回事。凭证靠环境变量塞进去:
{ "mcpServers": { "github": {
"command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"],
"env": { "GITHUB_TOKEN": "ghp_xxxx" } // 就这么给
}}}
这种其实最吓人。
Server 是你机器上的进程,以你的身份运行——能读你所有文件、能用你所有凭证、能连你所有内网。没有任何一道技术关卡能拦它。
防线只剩三条:审代码、锁版本、别挂来路不明的。
Client 查「这个用户能不能用这个工具」,Server 查「能不能碰这条数据」,下游再查一遍。
因为有人可能不走大堂,直接翻窗户找柜员——Server 不能因为「Client 已经问过了」就不验票。