MCP 服务器项目根目录安全获取方法(详解客户端 roots 能力与 list 请求)

要让 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,跨实现时会失败。

下面这套最小排查顺序适合工程团队在引入根目录能力后做自检:

  1. 在客户端配置中显式声明根目录能力(在握手阶段附带 listChanged);
  2. 服务器启动后立刻调用根目录拉取请求,确认能拿到至少一条 file 协议 URI;
  3. 把根目录列表写入运行时缓存,同时订阅变更通知,收到后重拉;
  4. 在每次文件操作前用统一的路径解析函数校验,凡是不在根目录下的路径直接拒绝;
  5. 在容器或沙箱层把进程限制到根目录路径,即使代码层有漏洞也越不出边界。

需要把根目录能力与沙箱结合的工程,可以参考 OWASP MCP 安全速查表对根目录与 OS 层隔离的协同建议,以及 MCP 维护者多次在 PR 评论里强调的「根目录是协调机制而非安全边界」。

常见问题(FAQ)

Q1:客户端不声明根目录能力,服务器还能读文件吗?

可以,服务器此时只能依赖环境变量或当前工作目录,但失去了协议级边界协商。

Q2:根目录 URI 必须是 file 协议吗?

当前规范要求是,扩展 scheme 的提案仍在讨论,生产环境暂用 file 协议。

Q3:收到变更通知后必须重拉吗?

应该立即重拉,继续用旧列表会导致用户切换工程后操作错位。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
蓝队管理员的头像蓝队管理员普通用户

相关推荐

返回顶部