MCP 工具权限码不应该只设计成 mcp:order 这种粗粒度开关。因为同一个订单工具里,查询订单是只读,修改备注是可操作,取消订单或退款就是高风险动作。把它们放进同一个权限码,会让授权边界变得很模糊。
更实用的起点是按风险拆成三层:read 表示只能看,write 表示可以做有限且可追踪的修改,dangerous 表示可能造成不可逆影响或外部后果。鉴权时不只看工具名,而要按具体动作选择对应权限码。
上一篇聊 MCP 权限分级时,我把工具调用分成了三类:只读、可操作和高风险动作。
那篇更偏方法论,解决的是“为什么要分”。但真正落地时,很快会遇到另一个问题:权限码怎么设计?鉴权时怎么判断?
如果一个 MCP 工具只有一个权限码,比如:
mcp:order
那它到底代表什么?
是允许查询订单,还是允许修改订单?是允许添加备注,还是允许取消订单、发起退款、删除数据?
如果这些动作都被包在同一个权限码里,权限粒度就太粗了。授权查询,可能等于授权修改;授权内部操作,可能等于授权外部动作;授权一个工具,可能等于授权了这个工具下所有风险等级的能力。
所以,MCP 工具权限码的设计,不应该只表达“能不能使用某个工具”,还应该表达“能执行这个工具里的哪一类动作”。
一个工具,不应该只有一个权限码
早期按工具集授权很方便。
比如订单工具、文档工具、消息工具、日程工具。用户或 Agent 拥有某个工具集权限,就可以调用里面的工具。
但 Agent 高频自主调用之后,问题会变得明显:同一个工具域里,动作风险差异非常大。
以订单为例:
- 查询订单列表,是只读;
- 修改订单备注,是可操作;
- 取消订单或退款,是高风险动作。
如果只设计一个:
mcp:order
那它就同时覆盖了查询、修改、取消、退款等动作。
这不是权限设计,而是把不同风险揉成了一个开关。
更合适的做法,是至少按风险拆成三层:
mcp:order:read
mcp:order:write
mcp:order:dangerous
这样同一个工具域下,不同动作可以走不同权限。
基础权限码:read、write、dangerous
我更倾向于把 MCP 工具权限码设计成这样的结构:
mcp:<tool>:<level>
其中 <level> 至少有三类:
read
write
dangerous
如果系统里存在多个 MCP Server,也可以扩展成:
mcp:<server>:<tool>:<level>
比如:
mcp:crm:customer:read
mcp:crm:customer:write
mcp:crm:customer:dangerous
但对多数场景来说,先用下面这种结构就够了:
mcp:xxx:read
mcp:xxx:write
mcp:xxx:dangerous
它的好处是足够简单,也能表达最关键的风险边界。
我们在 agent-runtime-lab 里怎么落地
mcp:<tool>:<level> 不只是个设想,我们在 agent-runtime-lab 里就是这么实现的。项目里目前有三个工具集,每个都标了敏感度:
| toolset | 敏感度 | read | write | dangerous |
|---|---|---|---|---|
base | low | get_current_time、get_current_user | — | — |
knowledge | medium | search_knowledge、ask_knowledge | ingest_knowledge | delete_knowledge_document |
apikey | high | list_api_keys | — | create_api_key、revoke_api_key |
对应的权限码就是 mcp:knowledge:read、mcp:knowledge:write、mcp:knowledge:dangerous、mcp:apikey:dangerous 这些。
注意它不是机械地给每个工具集凑齐三层:base 只有 read,apikey 直接从 read 跳到 dangerous。一个工具集该有哪几层,取决于它实际暴露了哪些动作,而不是为了对称凑数。
权限继承:dangerous ⊇ write ⊇ read
前面把三层权限当成并列关系来讲,是为了说清楚每一层代表什么。但真正落地时,还要补一条规则:
高风险权限自动包含低风险权限。
也就是 dangerous ⊇ write ⊇ read。拿到 mcp:knowledge:dangerous 的 API Key,天然就能检索、导入、删除知识库,不需要再单独授予 read 和 write;反过来,只有 mcp:knowledge:read 的 Key 去调 ingest_knowledge 会被拒绝。
原因很直接:能承担 dangerous 后果的角色,再卡他的只读权限没有意义,只会逼他额外申请一堆权限;把三层当成有包含关系的层级,而不是三个互不相干的开关,授权配置会干净很多——一个角色通常只需要配它最高那一档。
在 agent-runtime-lab 里,这个继承是用一个简单的等级比较实现的:
const ACTION_RANK: Record<McpAction, number> = {
read: 0,
write: 1,
dangerous: 2,
};
// granted.action:当前 API Key 已授予的等级
// action:这次工具调用需要的等级
// "已授予等级 >= 需要等级" 就放行
if (ACTION_RANK[granted.action] >= ACTION_RANK[action]) return true;
这也是为什么 demo 用的 dangerous-demo Key 只配了 base:read 和 knowledge:dangerous 两个 scope,却照样能检索、问答、导入知识库——dangerous 把 read 和 write 都包进去了。
read:只能看,不能改
read 表示只读权限。
它允许 Agent 查询、搜索、读取信息,但不允许改变业务状态,也不允许触发外部动作。
比如:
mcp:order:read
可以允许:
- 查询订单列表;
- 查看订单详情;
- 搜索订单;
- 读取订单状态;
- 查看订单关联的基础信息。
但不应该允许:
- 修改订单;
- 取消订单;
- 发起退款;
- 删除订单;
- 给客户发送通知。
read 解决的是:Agent 能不能看见上下文。
在实际产品里,只读权限通常可以相对宽松,因为如果 Agent 连上下文都无法读取,就很难真正完成任务。但只读也不是无限读,它仍然要受数据范围、用户身份、敏感字段和业务边界限制。
一句话概括:
read代表可以看,但不能改变任何东西。
write:可以改,但要可追踪
write 表示可操作权限。
它允许 Agent 执行一些会改变业务状态的动作,但这些动作通常影响范围有限,可以追踪、可以撤销,或者至少可以被人工修正。
比如:
mcp:order:write
可以允许:
- 修改内部备注;
- 添加订单标签;
- 更新非关键字段;
- 创建内部跟进任务;
- 生成或提交草稿类记录。
但不应该允许:
- 删除订单;
- 取消订单;
- 发起退款;
- 批量修改核心状态;
- 对外发送客户通知。
write 解决的是:Agent 能不能替用户推进业务动作。
这一级和 read 的区别很大。read 不改变状态,而 write 已经开始落数据了。因此它不能完全静默执行,至少应该保证动作可解释、结果可追踪,关键字段不能靠模型随意猜。
比如用户说“帮我整理一下这个客户的跟进事项”,Agent 可以创建任务。但在创建前,它最好能明确任务标题、关联客户、截止时间、备注内容这些字段。
一句话概括:
write代表可以改,但动作必须清楚、可追踪、可修正。
dangerous:高风险动作,不等于自动执行
dangerous 表示高风险动作权限。
它覆盖的是那些一旦执行,可能造成不可逆影响、资产损失、外部传播、权限变化、隐私泄露或生产事故的动作。
比如:
mcp:order:dangerous
可能包括:
- 删除订单;
- 取消订单;
- 发起退款;
- 批量变更订单状态;
- 对外发送客户通知;
- 修改核心业务字段。
这里有一个很重要的点:
拥有
dangerous权限,不代表 Agent 可以静默执行高风险动作。
权限通过,只说明用户或 Agent 满足了调用门槛;但真正执行前,仍然应该进入高风险确认流程。
也就是说,dangerous 至少包含两层含义:
- 这个用户有没有资格执行这类动作;
- 这次具体执行,是否还需要用户强确认。
通常答案是:有资格,也仍然要确认。
因为高风险动作真正需要控制的,不只是“谁能做”,还有“做什么、影响谁、能不能撤回、谁承担责任”。
一句话概括:
dangerous代表通过了权限门槛,但不代表可以跳过确认。
Dangerous 怎么确认:一次完整的审批状态机
说了“必须确认”,那确认具体是怎么发生的?
最简单的做法是工具调用时弹个确认框,用户点一下就执行。但这有个问题:确认框一关,谁也说不清当时确认了什么、确认的是哪个参数、事后还能不能追溯。所以 dangerous 的确认不能只靠一次性的弹窗。
我们在 agent-runtime-lab 里把它做成了一个有状态的审批流程,而不是一次性的 yes/no:
pending ──批准──▶ approved ──执行──▶ consumed
│ │
│拒绝/超时 │超时
▼ ▼
rejected expired
一次 dangerous 调用通常要走两轮:
- 第一轮,Agent 拿着参数调
delete_knowledge_document,系统不执行,而是创建一条 pending 审批,返回这次动作的影响摘要和一个approvalId; - 审批人在控制台看到摘要后决定批准或拒绝;
- 批准后,Agent 用完全相同的参数加上
approvalId再调一次,这次才真正执行。
这里有几个工程细节,是“要确认”这句话背后真正在防的东西:
- 参数绑定:审批记录会对入参算一个 hash。第二轮如果换了参数(比如把删除的文档 id 改了),hash 对不上,执行被拒。审批的是“这次具体动作”,而不是“这个工具的某种长期许可”。
- 身份绑定:审批记录绑定了 API Key 和工具名。别人的 approvalId、别的工具的 approvalId,都拿来用不了。
- 一次性消费:执行是在数据库事务里把状态从 approved 改成 consumed,只有抢到这一次的才能执行,防止同一个批准被重放多次。
- 会过期:审批有 TTL(项目里是 5 分钟),过期自动失效,避免一条批准长期挂着随时可用。
而且整个过程都落在审计里:创建审批、批准、消费、执行成功或失败,每一步都是一条可查的审计事件。这样 dangerous 动作不只是“被确认过”,而是“确认了什么、由谁确认、最后执行成什么样”都可追溯。
一句话概括:
dangerous 的确认不是一次弹窗,而是一条绑定参数、一次性消费、可审计的状态流转。
鉴权时,按动作选择权限
有了三层权限码之后,鉴权就不能只看工具名,而要看具体动作。
比如订单工具里有这些动作:
| 工具动作 | 所需权限码 | 说明 |
|---|---|---|
listOrders | mcp:order:read | 查询订单列表 |
getOrderDetail | mcp:order:read | 查看订单详情 |
updateOrderNote | mcp:order:write | 修改内部备注 |
addOrderTag | mcp:order:write | 添加订单标签 |
cancelOrder | mcp:order:dangerous | 取消订单 |
refundOrder | mcp:order:dangerous | 发起退款 |
deleteOrder | mcp:order:dangerous | 删除订单 |
这样,当 Agent 准备调用 refundOrder 时,系统不应该只检查它有没有 mcp:order,而应该检查它有没有:
mcp:order:dangerous
一个简单的执行流程可以是:
Agent 准备调用 refundOrder
→ 读取工具动作声明:mcp:order:dangerous
→ 检查当前用户是否拥有该权限
→ 如果没有,拒绝调用
→ 如果有,进入高风险确认
→ 用户确认后执行
这就是“鉴权时可以选择”的核心:不是所有订单工具动作都选择同一个权限,而是每个动作选择自己对应的权限等级。
不建议只靠函数名猜权限
有些系统会尝试根据函数名推断权限等级。
比如:
function resolvePermissionLevel(action: string) {
if (
action.startsWith("get") ||
action.startsWith("list") ||
action.startsWith("search")
) {
return "read";
}
if (action.startsWith("create") || action.startsWith("update")) {
return "write";
}
if (
action.startsWith("delete") ||
action.includes("refund") ||
action.includes("publish")
) {
return "dangerous";
}
return "dangerous";
}
这个思路可以作为兜底,但不应该作为主要方案。
因为函数名不一定可靠。
sendPreview 可能只是生成预览,也可能真的发送。syncData 可能只是同步缓存,也可能覆盖生产数据。updateStatus 可能只是更新内部状态,也可能触发外部通知。
更稳妥的方式,是在工具注册时显式声明权限码。
更推荐:工具注册时声明权限
工具注册时,可以把权限作为工具元数据的一部分。
例如:
const orderTools = {
listOrders: {
permission: "mcp:order:read",
description: "查询订单列表",
},
getOrderDetail: {
permission: "mcp:order:read",
description: "查看订单详情",
},
updateOrderNote: {
permission: "mcp:order:write",
description: "修改订单内部备注",
},
refundOrder: {
permission: "mcp:order:dangerous",
requireConfirmation: true,
description: "发起订单退款",
},
};
调用时,鉴权逻辑只需要读取工具声明:
const tool = orderTools[toolName];
if (!currentUser.permissions.includes(tool.permission)) {
throw new Error("Permission denied");
}
if (tool.requireConfirmation) {
await requestUserConfirmation(tool, input);
}
return executeTool(tool, input);
这样做有几个好处:
- 权限等级清楚;
- 不依赖命名约定;
- 新工具接入时必须声明风险等级;
- 鉴权逻辑可以统一;
- 高风险动作可以自然接入确认流程。
这也能避免一种常见问题:工具已经接入了,但没人说清楚它到底是只读、可操作,还是高风险动作。
这正是 agent-runtime-lab 采用的方式。它的工具声明集中在一个 registry 里,每个工具都显式标了 action,而 dangerous 会自动带上 requireApproval:
// 每个工具只需声明 action:
// permission 由工具集和 action 派生:mcp:{toolset}:{action}
// requireApproval 由 action === "dangerous" 自动推断
knowledge: {
search_knowledge: { action: "read", description: "检索知识库切片" },
ask_knowledge: { action: "read", description: "基于知识库回答问题" },
ingest_knowledge: { action: "write", description: "导入 Markdown 文档" },
delete_knowledge_document: { action: "dangerous", description: "删除知识文档及其切片" },
}
这样有两个直接好处:权限等级集中在声明里看得见,新工具接入时必须标 action,不会出现“工具上线了但没人说它是 read 还是 dangerous”;而 requireApproval 由 action === "dangerous" 自动推断,不用每个工具单独记要不要确认。
权限码不是越细越好
当然,权限码也不是越细越好。
如果一开始就设计成这样:
mcp:order:list
mcp:order:get
mcp:order:updateNote
mcp:order:addTag
mcp:order:cancel
mcp:order:refund
mcp:order:delete
看起来很精确,但运营成本会明显上升。
每接一个工具,都要设计一堆权限;每给一个角色授权,都要理解一堆动作;每次权限变更,都可能变成细碎配置。
对多数 MCP 场景来说,更实用的起点是:
mcp:order:read
mcp:order:write
mcp:order:dangerous
也就是按工具域加风险等级来拆。
只有当某个动作确实需要单独管控时,再把它进一步拆出来。
比如退款特别敏感,可以额外设计:
mcp:order:refund
但这应该是例外,不应该一开始就把所有动作都拆成独立权限码。
一个比较稳的原则是:
先按风险等级分层,再对特别敏感的动作单独授权。
权限码表达的是风险边界

MCP 权限码不是为了把工具名字编码进去,而是为了表达动作风险。
read 代表可以看,write 代表可以改,dangerous 代表可能产生不可逆影响、资产损失、外部传播、隐私泄露或生产事故。
鉴权时选择哪个权限,不应该只看工具属于哪个集合,而应该看这次动作会造成什么后果。
所以,一个可用的 MCP 权限体系,至少应该回答三个问题:
- 这个动作属于哪个工具域?
- 这个动作是什么风险等级?
- 当前用户是否有执行这个风险等级动作的资格?
如果答案是高风险,还要继续问第四个问题:
用户是否已经理解并确认了这次具体执行的后果?
这就是从 mcp:xxx:read 到 mcp:xxx:dangerous 的意义。
它不是复杂化权限,而是把 Agent 调工具时最关键的风险边界显式表达出来。
动手验证
上面这些不是纸上设计,agent-runtime-lab 提供了一套可以直接跑的实验。运行 pnpm seed 会生成四档 demo API Key,正好对应三个权限层级:
| Key | scopes | 能做什么 |
|---|---|---|
readonly-demo | base:read, knowledge:read | 只能检索、问答 |
writer-demo | base:read, knowledge:write | 额外能导入知识库 |
dangerous-demo | base:read, knowledge:dangerous | 能申请删除文档(需审批) |
approver-demo | base:read, apikey:dangerous | 在审批中心批准或拒绝请求 |
用 dangerous-demo 调 delete_knowledge_document,就能亲眼看到上面说的两轮审批流程;跑 pnpm verify:approval 则会用断言把整条状态机走一遍:pending → 未批准被拦 → 批准 → 消费执行 → 重放被拦。相关代码在 src/runtime/mcp/registry.ts 和 src/runtime/approvals/service.ts。
完整源码
本文涉及的权限码结构、权限继承、Dangerous 审批状态机和审计链路都是可运行的,完整源码开源于 agent-runtime-lab。