跳至正文
来两杯美式
返回

MCP 工具权限码怎么设计?read、write、dangerous 三层模型(附完整源码)

By 来两杯美式
发布于更新于

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敏感度readwritedangerous
baselowget_current_timeget_current_user
knowledgemediumsearch_knowledgeask_knowledgeingest_knowledgedelete_knowledge_document
apikeyhighlist_api_keyscreate_api_keyrevoke_api_key

对应的权限码就是 mcp:knowledge:readmcp:knowledge:writemcp:knowledge:dangerousmcp:apikey:dangerous 这些。

注意它不是机械地给每个工具集凑齐三层:base 只有 readapikey 直接从 read 跳到 dangerous。一个工具集该有哪几层,取决于它实际暴露了哪些动作,而不是为了对称凑数。

权限继承:dangerous ⊇ write ⊇ read

前面把三层权限当成并列关系来讲,是为了说清楚每一层代表什么。但真正落地时,还要补一条规则:

高风险权限自动包含低风险权限。

也就是 dangerous ⊇ write ⊇ read。拿到 mcp:knowledge:dangerous 的 API Key,天然就能检索、导入、删除知识库,不需要再单独授予 readwrite;反过来,只有 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:readknowledge:dangerous 两个 scope,却照样能检索、问答、导入知识库——dangerousreadwrite 都包进去了。

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 至少包含两层含义:

  1. 这个用户有没有资格执行这类动作;
  2. 这次具体执行,是否还需要用户强确认。

通常答案是:有资格,也仍然要确认。

因为高风险动作真正需要控制的,不只是“谁能做”,还有“做什么、影响谁、能不能撤回、谁承担责任”。

一句话概括:

dangerous 代表通过了权限门槛,但不代表可以跳过确认。

Dangerous 怎么确认:一次完整的审批状态机

说了“必须确认”,那确认具体是怎么发生的?

最简单的做法是工具调用时弹个确认框,用户点一下就执行。但这有个问题:确认框一关,谁也说不清当时确认了什么、确认的是哪个参数、事后还能不能追溯。所以 dangerous 的确认不能只靠一次性的弹窗。

我们在 agent-runtime-lab 里把它做成了一个有状态的审批流程,而不是一次性的 yes/no:

pending ──批准──▶ approved ──执行──▶ consumed
   │                 │
   │拒绝/超时         │超时
   ▼                 ▼
rejected          expired

一次 dangerous 调用通常要走两轮:

  1. 第一轮,Agent 拿着参数调 delete_knowledge_document,系统不执行,而是创建一条 pending 审批,返回这次动作的影响摘要和一个 approvalId
  2. 审批人在控制台看到摘要后决定批准或拒绝;
  3. 批准后,Agent 用完全相同的参数加上 approvalId 再调一次,这次才真正执行。

这里有几个工程细节,是“要确认”这句话背后真正在防的东西:

而且整个过程都落在审计里:创建审批、批准、消费、执行成功或失败,每一步都是一条可查的审计事件。这样 dangerous 动作不只是“被确认过”,而是“确认了什么、由谁确认、最后执行成什么样”都可追溯。

一句话概括:

dangerous 的确认不是一次弹窗,而是一条绑定参数、一次性消费、可审计的状态流转。

鉴权时,按动作选择权限

有了三层权限码之后,鉴权就不能只看工具名,而要看具体动作。

比如订单工具里有这些动作:

工具动作所需权限码说明
listOrdersmcp:order:read查询订单列表
getOrderDetailmcp:order:read查看订单详情
updateOrderNotemcp:order:write修改内部备注
addOrderTagmcp:order:write添加订单标签
cancelOrdermcp:order:dangerous取消订单
refundOrdermcp:order:dangerous发起退款
deleteOrdermcp: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”;而 requireApprovalaction === "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 高风险,鉴权时按动作风险选择对应权限码,dangerous 动作需用户审批确认

MCP 权限码不是为了把工具名字编码进去,而是为了表达动作风险。

read 代表可以看,write 代表可以改,dangerous 代表可能产生不可逆影响、资产损失、外部传播、隐私泄露或生产事故。

鉴权时选择哪个权限,不应该只看工具属于哪个集合,而应该看这次动作会造成什么后果。

所以,一个可用的 MCP 权限体系,至少应该回答三个问题:

  1. 这个动作属于哪个工具域?
  2. 这个动作是什么风险等级?
  3. 当前用户是否有执行这个风险等级动作的资格?

如果答案是高风险,还要继续问第四个问题:

用户是否已经理解并确认了这次具体执行的后果?

这就是从 mcp:xxx:readmcp:xxx:dangerous 的意义。

它不是复杂化权限,而是把 Agent 调工具时最关键的风险边界显式表达出来。

动手验证

上面这些不是纸上设计,agent-runtime-lab 提供了一套可以直接跑的实验。运行 pnpm seed 会生成四档 demo API Key,正好对应三个权限层级:

Keyscopes能做什么
readonly-demobase:read, knowledge:read只能检索、问答
writer-demobase:read, knowledge:write额外能导入知识库
dangerous-demobase:read, knowledge:dangerous能申请删除文档(需审批)
approver-demobase:read, apikey:dangerous在审批中心批准或拒绝请求

dangerous-demodelete_knowledge_document,就能亲眼看到上面说的两轮审批流程;跑 pnpm verify:approval 则会用断言把整条状态机走一遍:pending → 未批准被拦 → 批准 → 消费执行 → 重放被拦。相关代码在 src/runtime/mcp/registry.tssrc/runtime/approvals/service.ts

完整源码

本文涉及的权限码结构、权限继承、Dangerous 审批状态机和审计链路都是可运行的,完整源码开源于 agent-runtime-lab

AI 工程化相关阅读


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
查看系列全部文章
  1. 01.MCP Server 选型决策树:从语言到审计,六个维度一次说清
  2. 02.当 MCP Server 只是"翻译"现成能力:FastMCP + Mem0 + Redis 的选型逻辑
  3. 03.当 MCP Server 要"嵌进"对外平台:Node.js + 官方 SDK + API Key 的选型逻辑
  4. 04.SSE 已被 MCP 判为 legacy:存量服务怎么迁、新服务怎么选
  5. 05.MCP 权限怎么分级?只读、可操作和高风险动作(附完整源码)
  6. 06.MCP 工具权限码怎么设计?read、write、dangerous 三层模型(附完整源码)
  7. 07.自建 SearXNG + MCP:给 AI 工具接入可控的网页搜索
  8. 08.Agent Skill 是快照,快照会腐烂:我把业务流程全部搬到了服务端
  9. 09.能力目录与协议下发:把接口设计和控制流一起交给 Agent
  10. 10.四套版本号:把 Agent Skill 的版本管理挪回服务端之后
  11. 11.让用户自定义 Agent 能力,而不打开注入的后门
  12. 12.MCP 2026-07-28 改了什么:从有状态会话到无状态核心
  13. 13.Anthropic 发布 Agent Skills:AI Agent 的能力该如何被打包
  14. 14.Agent Skills 设计模式:用文件夹给 Agent 装上专业能力
  15. 15.Agent Skills 最佳实践:从评测、结构拆分到安全审查
  16. 16.MCP Server 升级 2026-07-28:不是换依赖,而是重画协议边界
  17. 17.MCP Client 升级 2026-07-28:服务端能留兼容窗口,我们偏不兼容旧协议
  18. 18.MCP 爆炸之后,Agent 怎么从一堆工具里选对那一个

上一篇
别再把聊天记录当记忆了:AI Agent 的四种记忆到底怎么分?
下一篇
MCP 权限怎么分级?只读、可操作和高风险动作(附完整源码)