一个文件系统工具最容易被问倒的问题不是「怎么读文件」,而是「你怎么知道该读哪个目录」。MCP 服务器是独立进程,它没有「当前项目」的概念;如果它默认以进程工作目录为根,在 IDE 里可能指向编辑器安装目录,在容器里可能是 /。让 LLM 传绝对路径更糟——模型会自信地拼出一个不存在的路径,或者更坏,拼出一个存在的敏感路径。
MCP Roots 就是回答这个问题的机制:由客户端(编辑器、IDE、Agent 宿主)声明一组「工作区根」,服务器据此理解自己在哪个范围内工作。本文要回答的是:Roots 的请求/通知时序怎么走、服务器如何把任意路径收敛到根内、多根与工作区切换怎么处理、以及为什么 Roots 是「协作约定」而不是「安全边界」。
1. Roots 是什么
1.1 定义
Roots 是客户端向服务器暴露的一组工作区根目录,用 file:// URI 表示:
{
"roots": [
{ "uri": "file:///Users/dev/project-api", "name": "project-api" },
{ "uri": "file:///Users/dev/shared-schemas", "name": "shared-schemas" }
]
}
注意方向:Roots 由客户端提供,服务器消费。这与直觉相反——不是服务器告诉客户端「我能访问哪些目录」,而是客户端告诉服务器「你只该在这些目录里活动」。
1.2 为什么需要它
| 没有 Roots | 有 Roots |
|---|---|
| 服务器以进程 cwd 为根,行为随启动方式漂移 | 根由客户端显式声明,行为可预期 |
| LLM 需自己猜绝对路径 | 服务器可把相对路径解析到根,减少猜测 |
| 多项目同时打开时无法区分 | 一项目一根,天然隔离 |
| 无法向用户展示「这个服务器在看哪些目录」 | 客户端 UI 可直接展示授权范围 |
1.3 与相关机制的分工
| 机制 | 作用 | 是否安全边界 |
|---|---|---|
| Roots | 声明工作区范围,供服务器解析相对路径 | 否,协作约定 |
| 工具入参校验 | 校验单次调用的路径合法性 | 是(第一道) |
| OS 权限 / 沙箱 | 文件系统 ACL、容器挂载、WASI preopen | 是(最终防线) |
| 用户审批 | 高危操作人在回路 | 是(策略层) |
一句话:Roots 决定「服务器认为自己在哪儿工作」,不决定「进程能碰什么」。混淆这两者是 Roots 最常见的误用。
2. 协议时序
2.1 服务器拉取根列表
Roots 由服务器主动查询,属于「服务器 → 客户端」方向的请求:
// 服务器 → 客户端
{ "jsonrpc": "2.0", "id": 7, "method": "roots/list" }
// 客户端 → 服务器
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"roots": [{ "uri": "file:///Users/dev/project-api", "name": "project-api" }]
}
}
2.2 能力协商
客户端必须在 initialize 时声明支持:
{
"capabilities": {
"roots": { "listChanged": true }
}
}
listChanged: true 表示客户端会在根集合变化时推送通知。服务器如果收到 roots/list 的 MethodNotFound,说明客户端不支持 Roots,此时必须退回显式配置(如服务器启动参数 --allowed-dirs),而不是默默使用 cwd。
2.3 变化通知
用户切换工作区、打开新文件夹时,客户端推送:
{ "jsonrpc": "2.0", "method": "notifications/roots/list_changed" }
服务器收到后应重新拉取 roots/list(通知不带载荷,这是刻意的:根列表可能很大,且客户端不需要在每个通知里复制一遍)。
2.4 服务器端缓存与刷新
class RootRegistry {
private roots: string[] = [];
private version = 0;
async refresh(client: Client): Promise<void> {
const res = await client.listRoots();
// 规范化:去掉末尾斜杠、解析为绝对路径、过滤非 file:// 协议
this.roots = res.roots
.map((r) => fileURLToPath(r.uri))
.map((p) => path.resolve(p))
.filter((p) => path.isAbsolute(p));
this.version++;
}
snapshot(): { roots: string[]; version: number } {
return { roots: [...this.roots], version: this.version };
}
}
关键点:缓存快照并带版本号。工具执行期间根可能变化,长任务应绑定执行开始时的快照,避免「读到一半根没了」导致路径解析结果前后不一致。
3. 路径收敛算法
拿到根列表后,服务器要做的是把任意输入路径收敛到根内,越界就拒绝。这一步不能用字符串前缀比较。
3.1 为什么字符串比较会出错
根:/Users/dev/project-api
输入:/Users/dev/project-api-evil/secret.txt
字符串前缀匹配 → 通过(错!这是另一个目录)
输入:/Users/dev/project-api/../../etc/passwd
包含 ".." → 规范化后越界(必须在规范化后再比较)
3.2 正确的收敛流程
import fs from "node:fs/promises";
import path from "node:path";
async function resolveWithinRoots(input: string, roots: string[]): Promise<string> {
// 1. 相对路径先拼到第一个根(或要求调用方显式给出根)
const abs = path.isAbsolute(input) ? input : path.join(roots[0], input);
// 2. 词法规范化:消掉 . 与 ..
const lexical = path.resolve(abs);
// 3. 真实路径解析:解开符号链接(这一步才是关键)
const real = await fs.realpath(lexical).catch(() => lexical);
// 4. 用分隔符边界比较,而不是 startsWith
for (const root of roots) {
const realRoot = await fs.realpath(root).catch(() => path.resolve(root));
if (real === realRoot || real.startsWith(realRoot + path.sep)) {
return real;
}
}
throw new Error(`路径越界:${input}`);
}
四步缺一不可:
| 步骤 | 拦截的攻击 |
|---|---|
| 相对路径拼接 | 空路径 / 纯文件名 |
path.resolve | ../ 穿越、重复分隔符 |
fs.realpath | 符号链接指向根外 |
| 分隔符边界比较 | /root-evil 冒充 /root |
3.3 符号链接的两个时机
符号链接的危险在于它是可变的:
t0: /root/link -> /root/safe.txt (检查时安全)
t1: 攻击者把 link 改成 -> /etc/shadow (使用时不安全)
这就是 TOCTOU(Time-of-Check to Time-of-Use)竞态。两种缓解方式:
方式一:解析后只用真实路径(realpath 结果),不用原路径再 open
方式二:打开时带 O_NOFOLLOW 语义(Node 可用 fs.open 后 fstat 校验),
或在容器/WASI 层禁止符号链接逃逸
仅做「检查」而不做「使用同一路径」是无效的——检查完再 fs.readFile(原路径),等于把竞态窗口重新打开。
3.4 归一化后的比较表
| 输入 | 词法规范化 | 真实路径 | 判定 |
|---|---|---|---|
src/index.ts | /root/src/index.ts | 同左 | 允许 |
../etc/passwd | /etc/passwd | 同左 | 拒绝 |
link(指向 /root/a.txt) | /root/link | /root/a.txt | 允许 |
link(指向 /etc/shadow) | /root/link | /etc/shadow | 拒绝 |
/root-evil/x | /root-evil/x | 同左 | 拒绝(边界比较) |
4. 多根与工作区切换
4.1 多根下的相对路径歧义
{ "roots": ["file:///work/api", "file:///work/web"] }
输入 package.json 该解析到哪个根?三种策略:
| 策略 | 行为 | 适用 |
|---|---|---|
| 显式根参数 | 工具入参加 root: "api" | 明确、可审计,推荐 |
| 首根优先 | 总是拼到第一个根 | 单根场景的兼容做法 |
| 全根搜索 | 逐个根探测存在性 | 只读搜索类工具 |
写操作必须用显式根参数。让模型自己决定往哪个根写,等价于把「写错项目」变成常态。
4.2 工作区切换的处理
用户在 IDE 里关闭 project-api、打开 project-b
→ 客户端推送 notifications/roots/list_changed
→ 服务器重新 roots/list,得到新的根集合
→ 旧根下的资源订阅全部失效
→ 已缓存的资源内容必须作废(否则会读到上一个项目的文件)
最后一条最容易漏。资源缓存、目录索引、文件监听器都绑定了旧根,必须在版本号变化时统一清理:
onRootsChanged(async () => {
await registry.refresh(client);
resourceCache.clear();
watchers.closeAll();
server.sendNotification(ResourceListChangedNotificationSchema, {});
});
4.3 与资源订阅的配合
根变化会让资源 URI 集合整体变化,因此除了清缓存,还应主动推送 notifications/resources/list_changed,让客户端重新拉取资源列表。URI 模板的设计要让根可辨识(如 file://{root}/{path}),否则客户端无法判断某个资源是否已失效。模板与订阅的完整机制见 https://plumephp.com/mcp-resources-templates-subscriptions/。
5. Roots 不是安全边界
这是本文最重要的一节。
5.1 为什么它不构成安全边界
1. Roots 由客户端提供,服务器实现可能有 bug,也可能故意不遵守
2. 恶意服务器可以完全忽略 Roots,直接读 /etc/passwd
3. 服务器进程的文件系统权限由操作系统决定,与协议声明无关
4. 提示注入可以诱导 Agent 调用绕过校验的工具路径
结论:Roots 是给「守规矩的服务器」的协作约定。它降低误操作概率(模型不会读到无关项目),但拦不住恶意实现。
5.2 真正的防线在哪
| 层次 | 手段 | 拦截对象 |
|---|---|---|
| 协议层 | Roots + 路径收敛 | 误操作、模型猜错路径 |
| 进程层 | 独立用户、目录 ACL、只读挂载 | 越权访问 |
| 沙箱层 | 容器挂载白名单、WASI preopen、chroot | 逃逸尝试 |
| 审计层 | 记录每次文件访问的根与真实路径 | 事后追溯 |
容器与 WASI 的目录预开放(preopen)机制值得单独一提:它把「允许访问的目录」下沉到运行时,服务器代码即使被注入也无法访问未挂载的路径,可参考 WASI 文件系统沙箱 ;需要更强的进程隔离时,可结合 cgroups 与 namespaces 做挂载命名空间收敛。
5.3 用户可见性
客户端应当把根列表展示给用户,并提供「本次会话不再允许该根」的开关。用户看不到授权范围,就无法做风险判断——这与权限提示必须标注发起方是同一个道理,参见 https://plumephp.com/mcp-security-practices/ 中的用户可见性原则。
6. 落地实践
6.1 服务器启动时的三步
1. initialize 后检查客户端能力:支持 roots → 调 roots/list 缓存
2. 不支持 → 读启动参数 --allowed-dirs,或读环境变量 MCP_ALLOWED_ROOTS
3. 两者都为空 → 拒绝启动(而不是退化到 cwd)
第 3 条是硬性建议:退化到 cwd 会让同一个服务器在不同启动方式下暴露不同目录,这是最难排查的一类安全问题。
6.2 工具描述里写明根语义
{
"name": "read_file",
"description": "读取工作区内的文件。path 可为相对路径(相对第一个工作区根)或绝对路径,绝对路径必须位于已授权工作区根内。",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "相对于工作区根的路径,如 src/index.ts" },
"root": { "type": "string", "description": "工作区根名称,多根场景下必填" }
},
"required": ["path"]
}
}
把「相对路径优先」写进描述能显著降低模型拼绝对路径的倾向。工具描述的整体设计原则见 https://plumephp.com/mcp-tools-design-patterns/。
6.3 越界时的错误信息
越界不要静默截断或改写路径,也不要把真实根路径暴露给模型:
差:返回 /etc/passwd 的内容
差:返回空文件(模型会以为是空文件,继续尝试)
好:isError=true, "路径不在已授权工作区范围内:../etc/passwd"
错误信息要能指导模型修正(提示它用相对路径),但不能泄露服务器侧的真实目录结构。
6.4 与只读/读写分级结合
只读根(shared-schemas):允许 list/read/search
读写根(project-api):允许上述 + write/edit/move
同一服务器可以服务多个根,但每个根的能力等级应独立配置。若某些操作需要更强隔离(如执行模型生成的代码),应转到独立沙箱进程处理。
7. 常见陷阱
| 陷阱 | 症状 | 解决 |
|---|---|---|
用 startsWith 判边界 | /root-evil 被误放行 | 比较 root + path.sep |
| 只做词法规范化 | 符号链接逃逸 | 加 fs.realpath |
| 检查与使用路径不一致 | TOCTOU 竞态 | 使用检查后的真实路径 |
| 根变化不清缓存 | 读到上一个项目的文件 | 版本号驱动统一失效 |
| 无 Roots 就退到 cwd | 行为随启动方式漂移 | 显式配置或拒绝启动 |
| 把 Roots 当安全边界 | 恶意服务器畅通无阻 | 叠加 OS/容器/WASI 层 |
| 多根下默认首根写 | 写错项目 | 写操作强制显式根参数 |
| 错误信息泄露真实路径 | 信息泄露 | 回显用户输入而非内部路径 |
8. 小结
Roots 的定位可以概括为「声明范围、收敛路径、承认边界」:
| 层面 | 要点 |
|---|---|
| 协议 | 客户端提供 roots/list,变化时 list_changed 通知 |
| 解析 | 相对路径优先,词法规范化 + realpath + 分隔符边界比较 |
| 多根 | 写操作强制显式根参数,避免歧义 |
| 切换 | 根变化 → 清缓存、关监听、推送资源列表变更 |
| 边界 | Roots 是协作约定,安全靠 OS 权限与沙箱 |
| 可见性 | 客户端展示授权范围,用户可逐根关闭 |
把 Roots 用对的关键,是既不要高估它(它不是沙箱),也不要低估它(它决定了模型眼里的「世界」有多大)。前者防的是安全事故,后者防的是模型在错误目录里瞎转——两件事都值得认真做。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。