AI 技术
#MCP#Roots#路径规范化#符号链接#访问控制

MCP Roots 文件系统边界:客户端声明的根目录如何限制工具服务端访问范围

本地 MCP Server 读写项目文件时,客户端通过 roots/list 声明的根目录决定了服务端能碰哪些路径。文章拆解根目录替换、变更通知、路径规范化与符号链接绕过,并给出可观测指标与部署边界。

一个越界读取是怎么发生的

假设你在本地跑一个 MCP 文件系统服务端,用来让 AI 助手读写当前项目。客户端在初始化时把工作区声明为 /home/user/projects/myproject。某次工具调用传入的路径是 ../../.ssh/id_rsa,服务端如果只做字符串拼接,就会读到工作区之外的私钥文件。

这类越界不是攻击者才关心的问题。工具调用参数由模型生成,模型可能因为上下文里出现过某个绝对路径,就把那个路径原样传回来。服务端如果默认信任所有传入路径,工作区声明就形同虚设。

MCP 的 Roots 机制就是为这个边界设计的。客户端把允许服务端操作的目录以 file:// URI 列表的形式暴露出来,服务端据此限制自己的文件访问范围。本文以本地文件系统服务端为场景,说明这套边界如何生效、在哪些情况下会失效,以及生产环境该观察什么。

Roots 协议在做什么

能力声明与请求流程

Roots 属于客户端能力。支持 Roots 的客户端必须在初始化阶段声明:

{
  "capabilities": {
    "roots": { "listChanged": true }
  }
}

listChanged 表示客户端在根目录列表变化时会不会发通知。服务端在初始化完成后,通过 roots/list 请求向客户端索取根目录列表:

{ "jsonrpc": "2.0", "id": 1, "method": "roots/list" }

客户端返回的每个根目录包含 uri 和可选的 name。规范要求 uri 必须是 file:// URI。name 只用于界面展示,不参与路径判断。

根目录列表完全替换服务端已有目录

这是最容易被忽略的一条。文件系统服务端可以接受两种目录来源:命令行参数和 Roots。当客户端通过 Roots 提供目录时,这些目录会完全替换服务端启动时的允许目录,而不是追加。

替换语义带来一个直接后果:如果客户端声明了三个仓库,服务端启动时用命令行参数指定的第四个目录就消失了。工具调用再访问那个目录,会被拒绝。

如果服务端启动时没有命令行参数,且客户端不支持 Roots 或返回空列表,服务端会在初始化阶段报错。它需要至少一个允许目录才能工作。

变更通知与重新拉取

当用户在客户端里增删工作区时,支持 listChanged 的客户端发送:

{ "jsonrpc": "2.0", "method": "notifications/roots/list_changed" }

服务端收到通知后应重新调用 roots/list,用新列表替换本地缓存。规范没有要求服务端在收到通知后立即完成替换,也没有规定替换期间正在执行的操作如何处理。这部分留给实现决定,也留下了一类竞态:通知到达时,一个写文件操作可能正在用旧根目录做校验。

路径校验的完整链路

服务端收到工具调用里的路径参数后,不能直接打开文件。它需要把路径解析成一个规范形式,再判断这个形式是否落在某个根目录之内。

flowchart TD
    A[工具调用传入路径] --> B[解析为绝对路径]
    B --> C[解析符号链接与 .. 段]
    C --> D[得到规范化真实路径]
    D --> E{是否位于某个根目录内}
    E -- 是 --> F[执行文件操作]
    E -- 否 --> G[返回越界错误]
    H[roots/list_changed 通知] --> I[重新拉取根目录列表]
    I --> J[替换本地允许目录缓存]
    J --> E

关键转折点在 C 和 E。C 之前,路径只是用户或模型给出的字符串,.. 和符号链接都可能指向根目录之外。E 是唯一做边界判断的地方,它依赖 D 产出的规范化路径,而不是原始输入。

如果服务端在 B 之后、C 之前就做前缀匹配,/home/user/projects/myproject/../../.ssh/id_rsa 会通过检查,因为它的字符串前缀确实匹配根目录。只有先解析再比较,才能拦住这类输入。

多根目录下的归属判断

当客户端声明多个根目录时,服务端通常按顺序检查路径是否落在任意一个根目录内。这里有两个实现细节值得注意。

第一,根目录之间可能存在嵌套。比如同时声明 /home/user/projects 和 /home/user/projects/myproject。一个路径落在内层目录时,两个根目录都匹配。如果服务端按“最长前缀优先”处理,行为更符合直觉;如果只判断“是否匹配任意一个”,结果相同。真正出问题的是权限模型按根目录区分时,嵌套会让归属变得不明确。

第二,根目录列表的顺序不应被当作优先级。规范没有定义顺序语义。服务端如果按第一个匹配的根目录决定后续行为,换一个客户端实现就可能改变结果。

符号链接与路径规范化失败模式

符号链接绕过

符号链接是边界校验最常见的失败点。假设根目录是 /home/user/projects/myproject,目录内有一个链接:

/home/user/projects/myproject/link -> /home/user/.ssh

工具调用传入 link/id_rsa。如果服务端只做字符串层面的前缀判断,这个路径的字符串形式以根目录开头,会被放行。但操作系统打开文件时跟随链接,实际读到的是根目录之外的私钥。

防御方式是在判断前解析符号链接,拿到真实路径再比较。伪代码大致如下:

function isAllowed(inputPath, roots):
    real = realpath(inputPath)        // 解析 .. 与符号链接
    for root in roots:
        rootReal = realpath(root.uri)
        if real == rootReal or real startsWith rootReal + separator:
            return true
    return false

realpath 在多数平台上会解析所有符号链接并返回绝对路径。注意它要求路径的每一段都存在,否则会失败。对于“创建新文件”这类操作,目标文件还不存在,服务端需要先对父目录做规范化,再拼上文件名。

规范化失败的几种表现

路径规范化不是一次调用就能覆盖所有情况。

大小写不敏感的文件系统上,/Home/User/Projects 和 /home/user/projects 指向同一位置。如果根目录和输入路径的大小写不一致,字符串比较会误判为越界。服务端需要在比较前统一大小写,或者依赖文件系统返回的真实路径。

Windows 上的短文件名(8.3 格式)和 UNC 路径会引入额外形式。C:\\PROGRA~1 和 C:\\Program Files 指向同一目录。只处理 file:// URI 的解析逻辑在 Windows 上需要额外转换。

挂载点和绑定挂载会让同一个目录出现在多个路径下。如果根目录通过一个路径声明,而输入通过另一个路径到达同一位置,规范化后的字符串可能不匹配。这类情况在容器和网络文件系统中更常见。

还有一种失败是规范化本身抛错。路径过长、权限不足、中间目录被删除,都会让 realpath 失败。服务端如果把失败当作“不匹配”处理,会拒绝合法请求;如果当作“放行”处理,会打开越界缺口。正确做法是失败时拒绝,并返回可区分的错误信息。

与无边界直接访问方案的对比

很多本地工具在早期实现里直接接受绝对路径,不做任何根目录限制。这种方案在单用户、可信输入的场景下能工作,但一旦工具参数由模型生成,风险就变了。

维度无边界直接访问Roots 约束访问
路径校验无,直接打开规范化后与根目录比较
越界行为允许,取决于进程权限拒绝并返回错误
运行时调整范围需重启进程通过 roots/list_changed 动态替换
符号链接处理通常不处理需显式解析真实路径
多目录管理命令行参数,启动时固定客户端声明,可运行时变更
实现复杂度低中,需要规范化与缓存
防御强度依赖操作系统权限依赖服务端校验正确性

这张表里最需要留意的是最后一行。Roots 不是操作系统沙箱。它约束的是服务端自己的文件操作,前提是服务端正确实现了校验。如果服务端代码里有一条路径绕过了校验函数,Roots 声明不会阻止它。

相比之下,用容器或独立用户运行服务端,由操作系统强制权限边界,防御强度更高,但运行时调整范围需要重启或重新配置。两种方案可以叠加:Roots 处理应用层边界,操作系统权限处理进程层边界。

可观测指标与部署边界

需要观察的信号

服务端至少应记录以下几类事件:

  • 根目录列表的每次替换,包括旧列表、新列表和触发来源。
  • 被拒绝的路径请求,记录原始输入、规范化结果和匹配失败的根目录。
  • 规范化失败的请求,区分“路径不存在”和“权限不足”。
  • 符号链接解析结果与原始路径不一致的情况。

这些记录能回答两个问题:边界是否在按预期生效,以及拒绝是否误伤了合法请求。如果拒绝日志里大量出现规范化失败,说明客户端声明的根目录和实际访问路径之间存在形式差异,需要检查 URI 编码或大小写。

部署时的前提条件

Roots 机制生效依赖几个前提。客户端必须声明 roots 能力,否则服务端只能退回命令行参数。服务端必须在每次文件操作前执行校验,而不是只在初始化时校验一次。根目录 URI 必须是 file:// 形式,其他 scheme 在当前规范下不被接受。

当这些前提不成立时,边界会退化。客户端不支持 Roots 时,服务端要么用启动参数,要么在无参数时直接报错。服务端缓存根目录但不处理 list_changed 时,用户在工作区里移除的目录仍然可访问,直到进程重启。

还有一个未解决的问题:规范没有规定根目录被移除时,正在进行的操作该如何处理。服务端可以在下一次校验时拒绝,也可以让当前操作完成。不同实现的选择会影响用户体验,也会影响“移除工作区后立即调用工具”这类操作的结果。

对于把文件系统服务端接入自动化流程的场景,建议在部署时明确三件事:客户端是否支持 Roots、服务端是否在每次操作前校验、以及越界拒绝的错误码是否被上层正确识别。这三项决定了边界是真实生效,还是只写在配置里。

资料来源

  1. Model Context Protocol Specification - Roots
  2. Model Context Protocol - Filesystem Server
  3. 根目录 – Model Context Protocol (MCP)