🔌 第3章 MCP的安全问题
📄 一句话概括

三道闸:按这次要用的给权限、写操作必须人确认、进来出去都过一遍校验。 顺序不能反:先白名单(不装野的),再最小权限(少给),最后才是校验和确认。 代价是打断对话。所以只对真正危险的动作设闸,别撒胡椒面。

📝 本章和前面几章的关系

上一节把三类攻击和一张威胁表摆完了,这一节给办法。 三道闸都不是 MCP 协议本身提供的——协议只规定怎么说话,不规定怎么防。 所以这三道闸要靠宿主和 Server 各自实现,谁少做一环,链条就断在那里。

一、闸一:按这次要用的给,不按它有什么给

最小权限这四个字谁都听过,难的是落到具体做法上。 演示里六个工具,权限分三类:查天气、搜地点、读文件是 read,写笔记、写文件是 write, 执行命令是 exec。 用户这一轮只想「查个天气」。按最小权限,这次会话只该给 read 里的天气那一项。

可常见的情况是反过来的:Server 声明了什么,宿主就全给。 于是一个查天气的 Server,手里握着 shell.run。 它在协议上没错,在权限上越界了。 协议只要求 Server 如实声明能力, 没要求宿主照单全收——该由宿主按「这次要用什么」给。

二、闸二:写操作必须二次确认

读错了最多重来,写出去就收不回来。所以确认要按权限分级,不是每次调用都弹。 演示里一轮五句话的对话,各调了什么权限:三次读、两次写。真要弹的只有两次。 如果全部都弹,第三次之后用户就开始闭眼点了。 【全都要确认等于都没确认】——这句话是安全设计里最实用的一条经验。

所以还得配一条:同一会话里同一种写操作,5 分钟内不重复问。 exec 那一类特殊,要逐条确认。 因为「执行一条命令」能做多少事,没有边界,上一条是 ls,下一条可以是删除。

三、闸三:进来和出去都过一遍

进来的是参数。 演示里六组参数只有第一组通过,后面五组全是模型填错:缺必填、超范围、类型不对、枚举外、多塞了一个。 这些错不是模型笨,是它不知道你的约束。 所以约束得写进 schema,而且服务端收到之后要再查一遍, 不能只靠描述里那句「天数别超过 7」。 出去的是返回值。数据库连接串、内部文件路径、堆栈信息,模型会原样说给用户听。

演示里那条 mysql 地址脱敏之后变成三个方括号;路径的前缀也被抹掉。 【返回值也是往外说的话,一样要过一遍。】 有一个常见的过分做法:把返回值整体截断。结果是模型拿不到关键信息,开始编。 脱敏要按字段做,不要按长度砍。

四、三道闸的代价,以及为什么顺序不能反

每一道闸都有成本。最小权限要多配一张表;二次确认会打断对话;参数校验要写 schema; 输出脱敏偶尔把有用信息也抹掉。 演示里那张五行表标了哪几道能自动化。五条里三条能全自动,两条必须人把关——就是二次确认和来源白名单。 顺序也有讲究:先白名单,再最小权限,最后才是校验和确认。

反过来做的典型样子是:认真讨论参数怎么校验,同时随手装了十个来路不明的 Server。 门没锁,却在挑门帘。 一句话收这一章:能力越大越要问「这次真需要吗」。 MCP 把能力变成可插拔的, 也就把「该不该插」这个问题交回给了你。

五、动手 Demo:三道闸,一道一道过

先按工具声明分权限,看宿主「全给」和「按需给」差在哪。 再走一轮五句话的对话,看哪几次真要弹窗。最后把六组参数和两条返回值各过一遍。 校验逻辑是真跑的,六组参数里有五组被真的拒掉。

# 闸一:这次会话只给查天气需要的权限
ALL = {'read': ['weather', 'poi', 'file'],
       'write': ['note', 'file'],
       'exec': ['shell']}
need = {'read': ['weather']}        # 用户只想查天气
print(need, '而非', ALL)

# 闸二:按权限分级决定要不要弹窗
ASK = {'read': False, 'write': True, 'exec': True}
for turn, tool, scope in [(1, 'weather.forecast', 'read'),
                           (2, 'poi.search', 'read'),
                           (3, 'note.add', 'write'),
                           (4, 'weather.forecast', 'read'),
                           (5, 'note.delete', 'write')]:
    print(turn, tool, scope, '要确认' if ASK[scope] else '直接过')

# 闸三:进来按 schema 查,出去按规则脱敏
for args in [{'city': '杭州', 'days': 3},
             {'city': '杭州'},
             {'city': '杭州', 'days': 30},
             {'city': '杭州', 'days': '三天'},
             {'city': '杭州', 'days': 3, 'unit': '开尔文'},
             {'city': '杭州', 'days': 3, 'debug': True}]:
    print(args, check_args(SCHEMA, args))
print(mask('mysql://root:[email protected]:3306/prod'))

实跑输出:

--------------------------------------------------------------------
三道闸,一道一道过
--------------------------------------------------------------------


  ① 闸一:最小权限
  --------------------------------------------------------------------

| 工具 | 需要什么权限 | 它能干什么 |
|---|---|---|
| weather.forecast | read | 查天气 |
| poi.search | read | 搜地点 |
| note.add | write | 写笔记 |
| file.read | read | 读文件 |
| file.write | write | 写文件 |
| shell.run | exec | 执行命令 |


用户只想「查个天气」。按最小权限,这次会话只该给第一项:
  会话申请的权限:{read: [weather]}

可常见的情况是这样:
  整个 Server 声明的权限:{read: [weather, poi, file], write: [note, file], exec: [shell]}
  宿主直接全给:6 个工具全可用
  一个查天气的 Server,为什么要 shell.run?
  【它在协议上没错,在权限上越界了。】宿主必须按「这次要用什么」给,不按「它有什么」给。

  ② 闸二:写操作必须二次确认
  --------------------------------------------------------------------

| 权限 | 要不要弹窗 | 为什么 |
|---|---|---|
| read | 不用 | 读错了最多重来 |
| write | 要 | 写出去就收不回来 |
| exec | 要,且逐条 | 它能做的事没有边界 |


一轮五句话的对话,各调了什么:

| 轮 | 用户说 | 调用 | 权限 | 要确认吗 |
|---:|---|---|---|---|
| 1 | 明天杭州下雨吗 | weather.forecast | read | 不用 |
| 2 | 那推荐个室内的 | poi.search | read | 不用 |
| 3 | 帮我记一下 | note.add | write | 要 |
| 4 | 再查下后天 | weather.forecast | read | 不用 |
| 5 | 把这条也删了 | note.delete | write | 要 |

  五轮里要弹 2 次窗(第 3、5 轮)。
  【全都要确认等于都没确认】——第三次之后用户就开始闭眼点了。
  所以还得配一条:同一会话里同一种写操作,5 分钟内不重复问。

  ③ 闸三:进来和出去都过一遍
  --------------------------------------------------------------------
进来的(参数):按 schema 校验,缺的、类型不对的、超出范围的,一律拒。

| 模型填的参数 | 结果 | 原因 |
|---|---|---|
| {"city": "杭州", "days": 3} | 通过 | — |
| {"city": "杭州"} | 拒 | 缺必填参数 days |
| {"city": "杭州", "days": 30} | 拒 | days 不能大于 7 |
| {"city": "杭州", "days": "三天"} | 拒 | days 类型应为 integer |
| {"city": "杭州", "days": 3, "unit": "开尔文"} | 拒 | unit 只能取 摄氏度/华氏度 |
| {"city": "杭州", "days": 3, "debug": true} | 拒 | 多出来的参数 debug |

  六组里只有第一组过。后面五组都是【模型填错】——它不知道你的约束。
  所以校验不能只写在描述里,得写进 schema,还得在服务端再查一遍。

出去的(返回值):脱敏、限长、别把内部路径吐给模型。
  原样返回 db   :mysql://root:[email protected]:3306/prod
  原样返回 path :/srv/app/data/2026/10/tmp.csv
  脱敏之后 db   :mysql://root:[已隐藏]@[已隐藏]:3306/prod
  脱敏之后 path :[已隐藏]/data/2026/10/tmp.csv
  模型会把这些原样说给用户听。【返回值也是往外说的话,一样要过一遍。】

  ④ 三道闸的代价
  --------------------------------------------------------------------

| 闸 | 挡住什么 | 代价 | 能不能自动化 |
|---|---|---|---|
| 最小权限 | 用不到的能力 | 多配一张表 | 能,按工具声明分 |
| 二次确认 | 误写、误删 | 打断对话 | 半自动,按权限分级 |
| 参数校验 | 注入、越界 | 写 schema 费事 | 能 |
| 输出脱敏 | 泄露内部信息 | 偶尔把有用信息也抹了 | 能,靠规则 |
| 来源白名单 | 野 Server | 新 Server 要等审核 | 半自动 |

  五条里三条能全自动,两条(二次确认、来源白名单)必须人把关。
  【所以设计上要把它留给真正危险的少数动作】,别撒胡椒面。
  ! 顺序也有讲究:先白名单(不装野的),再最小权限(少给),最后才是校验和确认。
  反过来做——先谈参数校验,却随手装了十个来路不明的 Server,等于门没锁却在挑门帘。

①第一段对比的是「按需给」和「全给」。 这两行的差别不在代码量,在出事之后能不能说清「它凭什么有这个权限」。 ②第二段五轮只弹两次。注意第 4 轮又是查天气,仍然直接过——因为读操作没有不可逆的后果。 ③第三段六组参数只有第一组过。后面五组各自代表一类错:缺必填、超范围、类型不对、枚举外、多塞字段。 最后那个 debug 最容易被忽略——多传字段本身就该拒。 ④第四段两条返回值,一条连接串一条文件路径。 脱敏之后仍然可用(还看得出是 mysql、还看得出是哪天的文件),但已经不能直接拿来用了。 ⑤最后那张五行表是本节的收尾:三条能全自动,两条必须人把关。 所以设计上要把它留给真正危险的少数动作。

30 秒实验

  1. 把演示里的 5 分钟改成「本次会话内不再问」,想想会带来什么新问题。
  2. 给自己的一个工具写 schema,要求 days 是 1 到 7 的整数。写完之后用六组错参数各试一次。
  3. 列出你手上所有工具里,哪些属于 read、哪些属于 write、哪些属于 exec。哪有不好分的,为什么。

常见误区

  1. 「全部操作都弹窗最安全。」弹多了人就闭眼点。按权限分级,只弹不可逆的。
  2. 「参数校验写在描述里就行。」模型不会严格执行描述。约束要写进 schema,服务端收到还要再查一遍。
  3. 「返回值是给人看的,不用脱敏。」模型会把返回值原样说给用户听。 连接串、内网地址、文件路径都算往外说。
自测三问(点开对答案) 1. 最小权限落到做法上是哪一句话? —— 按「这次会话要用什么」给,不按「这个 Server 声明了什么」给。 2. 为什么不能所有调用都弹窗确认? —— 弹多了用户就闭眼点,确认等于失效。只对写和 exec 这类不可逆动作弹。 3. 三道闸的顺序为什么不能反? —— 先白名单解决「装不装」,再最小权限解决「给多少」,最后才是校验和确认。 先谈校验却乱装 Server,等于门没锁却在挑门帘。

安全这一章到此为止。 下一章换个轻松的:把现在支持 MCP 的客户端和平台过一遍,看哪些能直接用、哪些只是挂了个名。

相关笔记