要让 MCP(Model Context Protocol)服务器只读当前工程而不染指整块磁盘,必须使用协议内置的客户端「根目录」能力。根目录由客户端在握手时声明能力,服务器在运行期通过 roots/list 请求拉取,客户端在用户切换工程时通过 notifications/roots/list_changed 主动通知,这一整套机制比靠环境变量猜路径要可靠得多,也避免了把 $HOME 或 process.cwd() 误当作项目边界。
一、客户端根目录能力是什么
MCP 规范把「根目录」定义为客户端能力而不是服务器能力:客户端声明自己愿意暴露哪些 file:// URI 作为边界,服务器在需要时主动拉取。规范明确根目录的 URI 在当前版本必须以 file:// 开头,可附带可选的展示名,如 file:///home/user/projects/myproject 对应「My Project」。
声明根目录能力时,客户端会在 initialize 握手中带上 capabilities.roots.listChanged,表示自己会在列表变更时主动发通知。如果客户端没声明这个能力,服务器调用对应请求就会收到 JSON-RPC -32601 错误,这是协议约定的「方法未找到」码。
// 客户端 initialize 时的能力声明
{
"capabilities": {
"roots": { "listChanged": true }
}
}
需要强调的是,根目录是「声明式边界」,协议本身不强制服务器在根目录内操作;实际隔离要靠操作系统权限、容器 bind mount 或沙箱,根目录只是为良性的服务器指明「应该在这里工作」。
二、roots/list 请求的完整流程
服务器想知道当前工作空间,发一个 roots/list 请求;客户端用根对象数组回复。每条对象含 uri 与可选的 name,uri 当前规范要求是 file:// 形式。
| 阶段 | 方向 | 消息 | 含义 |
|---|---|---|---|
| 握手 | 客户端→服务器 | capabilities.roots.listChanged |
声明会同步变更 |
| 拉取 | 服务器→客户端 | roots/list |
询问当前根列表 |
| 应答 | 客户端→服务器 | roots 数组 |
暴露用户授权的 URI |
| 变更 | 客户端→服务器 | notifications/roots/list_changed |
通知列表已变 |
| 错误 | 客户端→服务器 | -32601 / -32603 |
不支持或内部失败 |
一次完整的请求—响应示例(基于 2025-11-25 协议):
// 服务器发起
{ "jsonrpc": "2.0", "id": 1, "method": "roots/list" }
// 客户端响应
{
"jsonrpc": "2.0", "id": 1,
"result": {
"roots": [
{ "uri": "file:///home/user/projects/myproject", "name": "My Project" }
]
}
}
服务器拿到列表后,应在每次工具调用前把目标路径与根目录做规范化比对,凡是不在某个根目录下的路径直接拒绝。客户端则需要监听用户切换工作区的动作,主动推 list_changed 让服务器刷新缓存。
三、为什么优先使用根目录而不是环境变量
很多早期实现让服务器读 PROJECT_ROOT、WORKSPACE_DIR 这类环境变量来猜路径。这种做法有三个硬伤:一是不同 shell、CI、容器注入的变量命名不统一;二是环境变量没有「变更通知」,用户切换项目后服务器还在用旧值;三是环境变量无法表达多个根目录(例如 monorepo 下的多个子工程),而 roots/list 一次就能返回多个对象。
把环境变量当辅助手段而非唯一来源是稳妥的做法:服务器在启动时若没收到任何根目录,可以回退到 process.cwd(),并在第一次成功调用 roots/list 后切换到根目录模式;运行期再收到 list_changed 就以客户端的列表为准。这样既兼容无根目录能力的旧客户端,也保证新型客户端得到精确边界。
在多仓库场景下,根目录机制允许客户端同时声明前端与后端两个仓库,服务器在响应中分别给出 Frontend Repository 与 Backend Repository,省去服务器自己解析 .git/config 或 package.json 来推断边界的麻烦。
四、服务器侧路径校验的最小实现
拿到根目录后,服务器要在每次文件操作前校验目标路径,防止「..」逃逸。下面的 TypeScript 片段展示如何把请求路径解析为绝对路径,然后逐条与根目录前缀比较:
import path from "node:path";
function isInsideRoots(target: string, roots: string[]): boolean {
const abs = path.resolve(target);
return roots.some(root => {
const r = path.resolve(root);
return abs === r || abs.startsWith(r + path.sep);
});
}
// 工具调用前
if (!isInsideRoots(req.path, currentRoots)) {
throw new Error("Path escapes declared roots");
}
path.resolve 会把 .. 与符号链接规范化,配合 startsWith(root + sep) 可避免「/home/user/project2」误判为「/home/user/project」的子目录。生产环境还应在操作系统层加一道:用容器只 bind mount 工程目录,即便服务器代码有漏洞也碰不到根目录外文件。
roots/list 请求的真正价值在于「协议级协商」:服务器问一次,客户端答一次,变更再推一次,双方对边界的认知始终一致。环境变量做不到这种动态协商,也做不到多根目录,这是规范推荐 roots/list 而非环境变量的根本原因。
五、常见落地坑
把 cwd 当根目录是反模式:容器内的工作目录经常是 / 或 /app,把这种值当成边界等于没边界。第二个坑是缓存陈旧:服务器在会话开始时拉了一次根目录列表就再也不更新,用户在 IDE 里切换工程后,服务器仍在旧工程下读写文件,排查起来非常痛苦,正确做法是订阅变更通知并在收到后重拉。第三个坑是忽略 file 协议限制:有人在根 URI 里塞 https 或自定义 scheme,目前规范不接受这种 URI,跨实现时会失败。
下面这套最小排查顺序适合工程团队在引入根目录能力后做自检:
- 在客户端配置中显式声明根目录能力(在握手阶段附带 listChanged);
- 服务器启动后立刻调用根目录拉取请求,确认能拿到至少一条 file 协议 URI;
- 把根目录列表写入运行时缓存,同时订阅变更通知,收到后重拉;
- 在每次文件操作前用统一的路径解析函数校验,凡是不在根目录下的路径直接拒绝;
- 在容器或沙箱层把进程限制到根目录路径,即使代码层有漏洞也越不出边界。
需要把根目录能力与沙箱结合的工程,可以参考 OWASP MCP 安全速查表对根目录与 OS 层隔离的协同建议,以及 MCP 维护者多次在 PR 评论里强调的「根目录是协调机制而非安全边界」。
常见问题(FAQ)
Q1:客户端不声明根目录能力,服务器还能读文件吗?
可以,服务器此时只能依赖环境变量或当前工作目录,但失去了协议级边界协商。
Q2:根目录 URI 必须是 file 协议吗?
当前规范要求是,扩展 scheme 的提案仍在讨论,生产环境暂用 file 协议。
Q3:收到变更通知后必须重拉吗?
应该立即重拉,继续用旧列表会导致用户切换工程后操作错位。