1. 为什么你的 MCP 服务器总在乱翻文件
如果你最近在用 Cline、Claude Code 或者自己写的 MCP 客户端接文件系统类服务,大概率遇到过这种场景:明明只想让 AI 读当前项目,结果它把整个用户目录都扫了一遍;或者你换了工作区,服务器还在拿旧路径去读文件,报一堆ENOENT。这类问题的根子,基本都落在 MCP 协议里的 Roots(根目录)机制上。
Roots 是 Model Context Protocol 中用来给服务器划「文件系统边界」的一层约定。简单说,客户端通过 Roots 告诉服务器:你只能在这几个目录里活动,别的地方不要碰。它不是一个强制的沙箱,而是一份双方都遵守的契约——服务器在发起roots/list请求后拿到目录列表,后续所有文件操作都应该限制在这个范围内。对使用 Cline、CC Switch 这类工具接入 MCP 服务的开发者来说,理解 Roots 的配置位置和验证方式,直接决定了你的 AI 助手是「听话干活」还是「到处乱翻」。
这篇是理论篇的第 8 篇,但我不打算只讲概念。我会把 Roots 从协议消息落到settings.json和config.toml的实际骨架配置上,再给你一套可复制的验证步骤和排查动作。适合已经跑通过至少一个 MCP 服务、想搞清楚权限边界怎么配的人。如果你还没接过 MCP,建议先把基础连接跑通再回来看这篇。
2. Roots 在协议里到底怎么跑起来
2.1 能力声明是第一步
Roots 不是默认开启的。客户端必须在初始化握手时声明自己支持 Roots,否则服务器不会去问。声明长这样:
{ "capabilities": { "roots": { "listChanged": true } } }listChanged这个字段很关键。它表示当根目录列表发生变化时,客户端会不会主动发通知。设为true,服务器就知道自己可以依赖notifications/roots/list_changed来感知变化;设为false或者不声明,服务器就得自己想办法,通常就是每次操作前重新拉一次列表。
2.2 服务器主动拉取列表
握手完成后,服务器想知道自己能碰哪些目录,就发一个标准 JSON-RPC 请求:
{ "jsonrpc": "2.0", "id": 1, "method": "roots/list" }客户端返回的响应里,roots是一个数组,每个元素包含uri和可选的name:
{ "jsonrpc": "2.0", "id": 1, "result": { "roots": [ { "uri": "file:///home/user/projects/myproject", "name": "我的项目" } ] } }注意uri在当前规范里必须是file://开头。多仓库场景就返回多个元素,比如前端和后端分开:
{ "roots": [ { "uri": "file:///home/user/repos/frontend", "name": "前端仓库" }, { "uri": "file:///home/user/repos/backend", "name": "后端仓库" } ] }2.3 列表变了要通知
当用户在客户端里切换工作区、增删项目目录时,如果之前声明了listChanged: true,客户端必须发一条通知:
{ "jsonrpc": "2.0", "method": "notifications/roots/list_changed" }服务器收到这条通知后,应该重新发roots/list拉取最新列表。这就是「动态权限管理」的实现方式——不需要重启服务,边界就能跟着工作区走。
注意:Roots 是协议层的约定,不是操作系统级的权限控制。服务器如果故意不遵守,仍然可以访问范围外的路径。它的价值在于让合规的服务器有据可依,也让客户端能把「我允许你访问什么」表达清楚。
3. 在 settings.json 和 config.toml 里配 Roots
理论讲完,落到配置。不同工具的配置文件名不一样,Cline 系通常走settings.json,一些基于 Rust 或 TOML 生态的客户端走config.toml。下面给的是骨架,字段名以你实际客户端为准,但结构逻辑是通用的。
3.1 settings.json 骨架
{ "mcpServers": { "filesystem": { "command": "npx", "args": [ "-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects/myproject" ], "roots": [ { "uri": "file:///home/user/projects/myproject", "name": "我的项目" } ], "capabilities": { "roots": { "listChanged": true } } } } }这里有两个地方容易混。args里传给 filesystem server 的路径,是服务器启动时的默认工作目录;而roots数组是协议层暴露给服务器的边界声明。两者最好保持一致,否则会出现「服务器以为能读 A,客户端只声明了 B」的错位。capabilities里的listChanged决定切换工作区时服务器能不能收到通知。
3.2 config.toml 骨架
[[mcp.servers]] name = "filesystem" command = "npx" args = ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects/myproject"] [[mcp.servers.roots]] uri = "file:///home/user/projects/myproject" name = "我的项目" [mcp.servers.capabilities.roots] listChanged = trueTOML 的嵌套用[[...]]表示数组元素,roots可以配多条。多仓库就再加一组[[mcp.servers.roots]]。
3.3 多仓库配置示例
{ "roots": [ { "uri": "file:///home/user/repos/frontend", "name": "前端仓库" }, { "uri": "file:///home/user/repos/backend", "name": "后端仓库" } ], "capabilities": { "roots": { "listChanged": true } } }配多仓库时,服务器拿到的列表就是两个目录,它应该在这两个目录范围内操作。如果你的客户端支持工作区选择器,切换工作区时更新这个数组并触发list_changed通知即可。
4. 验证 Roots 是否真的生效
配完不验证,等于没配。下面这套步骤可以确认 Roots 有没有被正确传递和遵守。
4.1 用 roots/list 手动探一次
最直接的办法是让服务器发一次roots/list,看返回的列表对不对。如果你用的是支持日志的客户端,打开 MCP 通信日志,搜索roots/list。正常应该能看到请求和响应成对出现,响应里的uri和你配置的一致。
如果日志里根本没有roots/list,说明客户端没声明 Roots 能力,或者服务器没主动拉取。先检查capabilities.roots有没有写对。
4.2 用一个越界读取来测试边界
配好 Roots 后,故意让 AI 去读一个范围外的文件,比如/etc/hosts或者项目外的某个目录。合规的 filesystem server 应该拒绝,返回类似「路径不在允许的根目录内」的错误。如果它读成功了,说明 Roots 没生效,或者服务器根本没检查。
这一步很关键,因为 Roots 的「边界」只有在服务器实际校验时才有意义。协议本身不阻止越界,是服务器的实现去遵守。
4.3 切换工作区看通知
如果你声明了listChanged: true,在客户端里切换工作区,然后观察日志里有没有notifications/roots/list_changed。有这条通知,并且服务器随后重新拉了roots/list,说明动态更新链路是通的。
4.4 验证结果对照表
| 检查项 | 期望结果 | 异常含义 |
|---|---|---|
| 日志出现 roots/list | 请求响应成对 | 能力未声明或服务器未拉取 |
| 越界读取被拒 | 返回路径错误 | Roots 未生效或服务器未校验 |
| 切换工作区有通知 | list_changed 出现 | listChanged 未开启 |
| 多仓库列表完整 | 两个 uri 都在 | 配置数组被覆盖 |
5. 常见报错与排查动作
5.1 roots/list 返回空数组
服务器拿到空列表,通常意味着客户端配置里roots字段没写,或者写成了空数组。检查settings.json或config.toml里对应 server 的roots节点。有些客户端把 Roots 放在全局配置而不是单个 server 下,确认层级别放错。
5.2 uri 格式报错
uri必须是file://开头。写成/home/user/project或者file:/home/user/project(少一个斜杠)都可能被拒。Windows 下路径要转成file:///C:/Users/...这种形式,盘符前是三个斜杠。
5.3 切换工作区后服务器还用旧路径
这是listChanged没开或者客户端没发通知的典型症状。先确认capabilities.roots.listChanged是true,再看客户端日志有没有notifications/roots/list_changed。如果通知发了但服务器没反应,可能是服务器实现没处理这条通知,需要看服务器版本是否支持。
5.4 服务器启动路径和 Roots 不一致
前面提过,args里的路径和roots里的uri要对应。如果args指向 A,roots声明 B,服务器可能按 A 初始化,但协议层告诉它边界是 B,行为就会很怪。统一成同一个目录最省事。
5.5 越界读取没被拦截
如果服务器对范围外路径照读不误,先确认你用的 filesystem server 版本是否实现了 Roots 校验。有些早期版本只把 Roots 当提示,不做强制检查。这种情况要么升级,要么在客户端侧用更严格的目录参数限制。
排查顺序建议:先看能力声明,再看 roots/list 日志,然后测越界,最后查通知链路。从协议层往实现层查,比一上来就翻服务器源码快得多。
6. 把 Roots 用顺手的几个实际建议
Roots 配好之后,有几个习惯能让它更稳。第一,把roots和服务器启动参数绑成同一个变量来源,别手写两遍,改一处漏一处是常见坑。第二,多仓库场景下给每个 root 起清晰的name,日志里一眼能认出是哪个项目。第三,如果你在做长期编码或 Agent 类任务,Roots 的稳定性直接影响上下文质量,可以考虑用 Coding Plan 这类面向持续编码的接入方式,把配置和额度一起管起来,减少中途因为权限边界错乱导致的重复调试。
验证 Roots 最有效的手段还是那三步:看roots/list日志、测越界读取、切工作区看通知。这三步过了,基本就能确认你的 MCP 文件边界是可信的。配置骨架可以直接从上面的settings.json和config.toml抄,把路径换成你自己的项目目录就能跑。