SKILL 12 / MCP · DEEP DIVE

接 MCP 的安全边界:工具描述和返回值,都是别人写给模型看的

规范里的三条硬约束 · 装前验货与运行时围栏 · 工具名冲突与全限定名

2026-09-13 · BigCat

你以为你装的是一组函数。模型看到的是一段别人写的、会被原样读进上下文的文本——而且在你调用任何工具之前就已经读完了。

相关 → Skill 11 浏览器自动化 MCP(上期结尾那句「页面正文就是注入面」,本期是正题)

// WHY THIS MATTERS

前三期把 MCP 当能力讲:文档检索搬文本,浏览器自动化给出一双手。本期换个视角——每接一个 server,你就把一段陌生人写的文字接进了模型的系统提示,还顺手给了它一份你的凭据。

这一期没有「主角 skill」,主角是一条从装之前到跑起来的防线:先用 MCP Inspector 和 agent-scan 验货,再用 mcp-context-protector 或容器网关把它围起来,最后用客户端权限规则收口。十一个条目全部可装可读,判据在后半程。

// 01

它替你干掉什么:干掉「装完才发现它能读我 SSH 私钥」

论断:MCP 的安全问题不是抽象风险,是一个你今晚就能跑两条命令查出来的具体问题。绝大多数人从没打开看过自己装的 server 到底暴露了哪些工具、描述里写了什么。

第一条立刻能跑的用法

# ① 先看它到底给了模型什么(官方调试器,Web UI / CLI / TUI 三种界面)
npx @modelcontextprotocol/inspector

# ② 再扫一遍机器上已装的所有 server 与 skill
export SNYK_TOKEN=...
uvx snyk-agent-scan@latest

# ③ 当场把爆炸半径缩一圈:只读 + 只开要用的工具组
github-mcp-server --read-only --toolsets repos,issues

第 ① 条的重点不是「连上了」,而是逐个读一遍 tools/list 返回的描述文本。那段文本会原样进入模型上下文;它由 server 作者写,不由你写。第 ② 条有个坑值得单独说:网上大量教程还在教 uvx mcp-scan@latest——那个仓库 invariantlabs-ai/mcp-scan 现在已经是 snyk/agent-scan,包名也换成了 snyk-agent-scan,并且要求 SNYK_TOKEN。抄命令前先看仓库现状,这条纪律上一期刚交过一次学费。

失败模式:拿「star 多不多」当安全判据。star 衡量的是有用,不是可信;而且一个今天干净的 server 明天可以推一个新版本换掉所有描述——这正是下一节 rug pull 的定义。
一手源 · modelcontextprotocol/inspector(据 GitHub 仓库页 2026-09 约 1.09 万 star,MIT)· snyk/agent-scan(据 GitHub 仓库页 2026-09 约 3.0 千 star,Apache-2.0)
// 02

机制:为什么「工具描述」是攻击面,而不是文档

论断:普通软件里,函数签名是给编译器看的;MCP 里,工具描述是给模型看的自然语言指令。两者的区别在于:前者说错了会编译失败,后者说错了会被照做。
一次普通会话里,别人写的文字是怎么进来的 ────────────────────────────────────────────────────────── 你还没调用任何工具 server ──tools/list──► 工具名 + 描述 + 入参 schema ──► 系统提示 ↑ 这一步就已经在影响模型了(line jumping) 你调用了一次工具 server ──result────► 任意文本 ──► 上下文 ↑ 网页正文 / issue 正文 / 别人的 PR 描述 ────────────────────────────────────────────────────────── 三种已被复现的玩法(invariantlabs-ai/mcp-injection-experiments) direct-poisoning.py 描述里藏指令:调用 A 时顺手读 ~/.ssh/id_rsa shadowing.py 描述里改写别的 server 的工具行为 whatsapp-takeover.py 先装好人,之后的版本再变脸(sleeper rug pull) ──────────────────────────────────────────────────────────

规范自己怎么说

MCP 规范(2026-07-28 版)在 server/tools.mdx 里给了两句几乎是本期全部理论的话:「clients MUST consider tool annotations to be untrusted unless they come from trusted servers」,以及「there SHOULD always be a human in the loop with the ability to deny tool invocations」。注意第一句的措辞——不可信的不只是返回值,连 server 自己标注的「我这个工具是只读的」也不可信。

规范总则那一节把它归到三条原则:用户须明确同意并理解所有数据访问与操作;宿主在把用户数据暴露给 server 前须取得同意;「Tools represent arbitrary code execution and must be treated with appropriate caution」。工具等于任意代码执行——这是规范原话,不是本站的修辞。

失败模式:以为「我只装只读的 server 就安全了」。只读 server 照样能把恶意文本送进上下文,然后由你装的另一个可写 server 去执行。危险的是组合,不是单个工具——扫描器把这种叫 toxic flow。
// 03

真实用法与边界:十一个条目,三层防线

论断:这三层的成本差一个数量级。第三层几乎免费、今天就能做完,绝大多数人却从第二层开始纠结。顺序反了,安全就永远停在「等我有空搭个网关」。

第一层 · 装之前验货

第二层 · 运行时围栏

第三层 · 客户端权限(最便宜,先做这层)

还有一层不属于工具,属于协议

如果你写 server 而不只是装,规范 basic/authorization/security-considerations.mdx 里那几条 MUST 是硬门槛:server MUST NOT 把从客户端收到的 token 直接透传给下游 API;server MUST 校验 token 的受众、只接受发给自己的 token;客户端 MUST 带上 resource 参数、MUST 实现 PKCE 并在无法确认对方支持时拒绝继续;用静态 client ID 的代理型 server,MUST 对每个动态注册的客户端单独取得用户同意(这条治的就是 confused deputy)。

// 04

代价与替代品:最该知道的一条是「MCP server 不在沙箱里」

论断:你为 Bash 配的沙箱,管不到 MCP。这不是疏漏,是写在文档里的边界——而大多数人以为开了 /sandbox 就一起罩住了。

三条诚实的代价

三种「什么都不接」的替代品

诚实的代价:本期这些围栏,没有一个能挡住「模型读到一段话就照做」这个根问题。它们做的全是缩小照做之后的后果。判据也就只有一条——假设这个 server 返回的每一个字都是攻击者写的,它最多能让我损失什么?答案里出现「私有仓库」「生产数据库」「支付」,这个 server 就不该和别的东西同时在线。

// 一页选购卡

先做这三件(今晚,零成本):① npx @modelcontextprotocol/inspector 逐个读一遍已装 server 的工具描述;② 在设置里写死 "deny": ["mcp__*"] 再按 mcp__<server>__<tool> 逐个放行,allow 一律锚定到具体 server;③ 给 github-mcp-server 加 --read-only --toolsets repos,issues,要写那天再摘。

接外部 server 那天再加:uvx snyk-agent-scan@latest(装前扫)· mcp-context-protector.sh --command ...(描述固定 + 变更告警)· docker mcp gateway run(容器隔离与密钥)· MCP Registry 查命名空间归属。

团队与 CI:--strict-mcp-config 和 disabledMcpjsonServers 无条件开(无人值守时批准框不会弹)· ToolHive 做集中管控与审计 · 写 server 就照 security-considerations.mdx 那几条 MUST 来。

明确别做:拿 star 数当可信度 · 照抄博客里的 uvx mcp-scan@latest(仓库已迁到 snyk/agent-scan)· 相信工具自己标注的 annotation · 以为 /sandbox 罩得住 MCP。

一句话判据:假设这个 server 每一个字都是攻击者写的——它能让我损失什么?答案够大就别让它和你的写权限同时在线。

// 术语表

tool poisoning(工具投毒)
把给模型的指令藏进工具描述里。用户在客户端界面上看到的往往只是工具名和一句摘要,完整描述文本只有模型读得到。
line jumping(插队)
恶意 server 在你调用任何工具之前就已经影响了模型——因为 tools/list 的返回内容在会话一开始就进了上下文。mcp-context-protector 的 README 把它列为首要防护目标。
tool shadowing(工具影射)
一个 server 在自己的描述里改写另一个 server 的工具行为。协议不保证工具名跨 server 唯一,全限定名 mcp__<server>__<tool> 是客户端侧的应对。
rug pull(抽地毯)
首版干净、后续版本变脸。静态扫描对它无效,靠的是配置固定与变更告警。
toxic flow(毒性流)
单个工具都合规,组合起来却构成一条「读私密 → 写外部」的通路。危险在流,不在点。
confused deputy(混淆代理)
一个有环境权限的中间人被诱导替攻击者办事。规范的解法是代理型 server 对每个动态注册的客户端单独取得用户同意。
token passthrough(令牌透传)
server 把客户端给的 token 原样转给下游 API。规范写明 MUST NOT——它绕过受众校验与限流,还让审计失真。
全限定名(fully-qualified tool name)
客户端给工具加上 server 前缀后的唯一名字。它同时是权限规则、hook matcher 与 subagent 工具清单的书写单位。

// 延伸阅读

// 想一想

你现在装着的 server 里,有几个是你亲眼读过工具描述全文的?
如果答案是零,那本期第一条命令就是今晚的作业。客户端界面显示的摘要和模型读到的描述,往往不是同一段文本。
把「只读 server」和「可写 server」同时挂在一个会话里,风险是相加还是相乘?
相乘。单点合规、组合致命,这正是 toxic flow 这个词存在的理由:读私密的那一个和能往外写的那一个,同时在线就构成一条通路。
如果一个 server 的安全性靠「它承诺自己是只读的」来保证,你还缺什么?
缺一个不依赖它自我声明的强制点。规范把 annotation 明确列为不可信正是此意——只读要么来自它没有写工具,要么来自 --read-only 这种外部开关,不能来自一句标注。