三道闸:按这次要用的给权限、写操作必须人确认、进来出去都过一遍校验。 顺序不能反:先白名单(不装野的),再最小权限(少给),最后才是校验和确认。 代价是打断对话。所以只对真正危险的动作设闸,别撒胡椒面。
上一节把三类攻击和一张威胁表摆完了,这一节给办法。 三道闸都不是 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 秒实验
- 把演示里的 5 分钟改成「本次会话内不再问」,想想会带来什么新问题。
- 给自己的一个工具写 schema,要求 days 是 1 到 7 的整数。写完之后用六组错参数各试一次。
- 列出你手上所有工具里,哪些属于 read、哪些属于 write、哪些属于 exec。哪有不好分的,为什么。
常见误区
- 「全部操作都弹窗最安全。」弹多了人就闭眼点。按权限分级,只弹不可逆的。
- 「参数校验写在描述里就行。」模型不会严格执行描述。约束要写进 schema,服务端收到还要再查一遍。
- 「返回值是给人看的,不用脱敏。」模型会把返回值原样说给用户听。 连接串、内网地址、文件路径都算往外说。
自测三问(点开对答案)
1. 最小权限落到做法上是哪一句话? —— 按「这次会话要用什么」给,不按「这个 Server 声明了什么」给。 2. 为什么不能所有调用都弹窗确认? —— 弹多了用户就闭眼点,确认等于失效。只对写和 exec 这类不可逆动作弹。 3. 三道闸的顺序为什么不能反? —— 先白名单解决「装不装」,再最小权限解决「给多少」,最后才是校验和确认。 先谈校验却乱装 Server,等于门没锁却在挑门帘。安全这一章到此为止。 下一章换个轻松的:把现在支持 MCP 的客户端和平台过一遍,看哪些能直接用、哪些只是挂了个名。
- ☐ (本篇完,下一篇 4.1 AI 编程平台)