在 Model Context Protocol(MCP)的五大原语中,Tools、Resources、Prompts、Sampling、Elicitation 都已被反复讨论,唯独 Roots 长期被忽视。Roots 是一个"反向"原语:它让客户端主动告诉服务器自己正在操作哪些目录边界,从而让服务器"读懂项目"。本教程将从协议层到 SDK 实现,带你构建一个项目感知的代码统计服务器,掌握能力协商、动态拉取、变更监听与优雅降级四大核心机制。
适用人群:已写过至少一个 MCP 服务器、希望让工具不再"瞎猜当前项目"的中级开发者。
绝大多数 MCP 原语是 服务器 → 客户端 方向(服务器声明有哪些工具、资源)。Roots 恰好相反——它是客户端能力:客户端在连接时声明"我支持 Roots",服务器随后主动向客户端发请求索取当前的工作目录列表。
一个 Root 的数据结构极简,规范明确要求 uri 当前必须是 file://:
{
"uri": "file:///home/user/projects/myproject",
"name": "我的项目"
}
整个协议只有两条消息加一条通知:
| 方向 | 方法 | 作用 |
|---|---|---|
| 服务器 → 客户端 | roots/list 请求 |
拉取当前 roots 列表 |
| 客户端 → 服务器 | notifications/roots/list_changed 通知 |
告知 roots 已变化,请重新拉取 |
| 客户端 → 服务器 | 初始化时 capabilities.roots |
声明"我支持 Roots,且会发变更通知" |
能力协商发生在 initialize 握手阶段,客户端声明:
{
"capabilities": {
"roots": { "listChanged": true }
}
}
其中 listChanged 表示客户端是否会在 roots 变化时主动发通知。错误处理遵循标准 JSON-RPC:客户端不支持 Roots 时返回 -32601(Method not found),内部错误返回 -32603。
为什么这件事重要? 在 Roots 出现之前,服务器只能靠三种"猜"的方式确定项目边界:命令行参数硬编码、读取进程 cwd、让用户每次调用工具时手动传 path。这三种方式在多仓库会话、中途 /add-dir 切换工作区时全部会失效——服务器"自以为知道项目在哪",实则早已陈旧。
新建项目目录并初始化:
mkdir project-stats-mcp && cd project-stats-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init
package.json 中加入启动脚本(注意 stdio 服务器不能有任何 stdout 污染,日志必须走 stderr):
{
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
}
}
Roots 最容易被忽视却最致命的点是路径穿越(path traversal)。客户端给你的 file:// URI 只是"建议",服务器在用它之前必须校验每一次文件操作都落在某个 root 之内。先写两个工具函数 src/roots.ts:
import path from "path";
// 全局缓存:从客户端拉取并解析后的绝对路径
export let workspaceRoots: string[] = [];
export function setWorkspaceRoots(roots: string[]) {
workspaceRoots = roots.map((p) => path.resolve(p));
}
// file:// URI → 本地绝对路径,拒绝非 file 协议
export function uriToPath(uri: string): string {
const url = new URL(uri);
if (url.protocol !== "file:") {
throw new Error(`不支持的 root 协议 ${url.protocol}:当前规范仅允许 file://`);
}
return decodeURIComponent(url.pathname);
}
// 安全校验:目标路径必须严格落在某个 root 之内
export function assertWithinRoots(target: string): string {
const resolved = path.resolve(target);
const inside = workspaceRoots.some((root) => {
const rel = path.relative(root, resolved);
// rel 为空串表示恰好等于 root;不以 ".." 开头且非绝对路径表示在内部
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
});
if (!inside) {
throw new Error(`路径越界,不在任何 root 之内:${resolved}`);
}
return resolved;
}
这段逻辑直接对应官方规范"安全考量"一节的要求:客户端必须校验 root URI 防止路径穿越;服务器应当尊重 root 边界并校验所有路径。path.relative + startsWith("..") 是判断"是否在子树内"最稳健的写法,能正确处理符号链接场景。
这是整篇教程的核心。一个健壮的 Roots 服务器必须同时处理三种情况:客户端支持且返回有效 roots、客户端支持但返回空、客户端根本不支持。新建 src/index.ts:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
RootsListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js";
import fs from "fs/promises";
import path from "path";
import { uriToPath, assertWithinRoots, setWorkspaceRoots } from "./roots.js";
const server = new Server(
{ name: "project-stats", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
// 从客户端拉取 roots,内置三级降级
async function refreshRoots() {
const caps = server.getClientCapabilities();
// 情况一:客户端未声明 roots 能力 → 降级到进程 cwd
if (!caps?.roots) {
setWorkspaceRoots([process.cwd()]);
console.error("[roots] 客户端不支持 roots,降级到 cwd:", process.cwd());
return;
}
try {
const { roots } = await server.listRoots();
const dirs = (roots ?? []).map((r) => uriToPath(r.uri));
// 情况二:协商了 roots 但返回空 → 降级到 cwd
if (dirs.length === 0) {
setWorkspaceRoots([process.cwd()]);
console.error("[roots] 客户端返回空 roots,降级到 cwd");
return;
}
// 情况三:正常路径
setWorkspaceRoots(dirs);
console.error(`[roots] 已获取 ${dirs.length} 个 root:`, dirs.join(", "));
} catch (err) {
// 情况四:拉取抛错(如 -32601)→ 兜底降级
setWorkspaceRoots([process.cwd()]);
console.error("[roots] 拉取失败,降级到 cwd:", err);
}
}
这段代码的每一个 API 调用都与官方参考实现 server-filesystem 完全一致:
server.getClientCapabilities() 读取握手阶段客户端声明的能力server.listRoots() 向客户端发送 roots/list 请求并等待响应caps?.roots 判断是否走 Roots 协议server-filesystem 的处理方式是"若既无命令行参数又无 roots 则直接抛错拒绝启动"。本教程选择了更宽容的 cwd 兜底,因为对于"代码统计"这类只读工具,宁可降级运行也不要让用户面对一个启动失败的连接。是否选择严格抛错,取决于你的工具是否必须有边界才能安全运行(如写文件、删除操作就该严格抛错)。
会话期间用户可能用 /add-dir 新增目录,客户端会推送 notifications/roots/list_changed。服务器必须注册通知处理器并重新拉取。继续在 src/index.ts 中追加:
// 注册 roots 变更通知处理器(对应 listChanged: true)
server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
console.error("[roots] 收到 list_changed,重新拉取...");
await refreshRoots();
});
// 初始化完成后拉取第一次 roots
server.oninitialized = async () => {
await refreshRoots();
};
这两行是 Roots 的"动态"灵魂。oninitialized 钩子在 initialized 通知交换完毕后触发,此时客户端能力已确认、可以安全发起 roots/list;setNotificationHandler 则保证会话中途任何一次工作区变化都能被服务器实时感知。这正是 Roots 相对"命令行参数硬编码"的根本优势——无需重启服务器即可热更新边界。
有了受 root 约束的文件访问,暴露两个工具:count_lines(统计某路径下代码行数)和 list_roots(查看服务器当前感知到的边界,便于调试):
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "count_lines",
description: "统计指定路径下的代码总行数(仅限 root 边界内)",
inputSchema: {
type: "object",
properties: {
subdir: { type: "string", description: "相对某个 root 的子目录,默认为 root 本身" },
},
},
},
{
name: "list_roots",
description: "返回服务器当前感知到的项目 root 列表(用于调试)",
inputSchema: { type: "object", properties: {} },
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "list_roots") {
const { workspaceRoots } = await import("./roots.js");
return {
content: [{ type: "text", text: JSON.stringify(workspaceRoots, null, 2) }],
};
}
if (name === "count_lines") {
// 注意:用户传的 subdir 必须拼接到某个真实 root 上再做校验
const { workspaceRoots } = await import("./roots.js");
if (workspaceRoots.length === 0) {
return { content: [{ type: "text", text: "尚无可用 root" }] };
}
const base = workspaceRoots[0];
const target = args?.subdir ? path.join(base, String(args.subdir)) : base;
const safe = assertWithinRoots(target); // 越界直接抛错
let total = 0;
const walk = async (dir: string) => {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === ".git") continue;
await walk(full);
} else {
const content = await fs.readFile(full, "utf-8");
total += content.split("\n").length;
}
}
};
await walk(safe);
return { content: [{ type: "text", text: `${safe} 共 ${total} 行` }] };
}
throw new Error(`未知工具: ${name}`);
});
// 启动
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("project-stats MCP 服务器已启动(stdio)");
assertWithinRoots(target) 这一行是安全底线:即使用户在 subdir 里塞 ../../etc/passwd 这种穿越 payload,也会在校验阶段被拦截。
由于 Roots 是客户端能力,测试时必须用一个真正支持 Roots 的客户端。MCP Inspector 是首选,它提供图形化的"添加 root"和"触发 list_changed"按钮。编译并启动:
npm run build
npx @modelcontextprotocol/inspector node dist/index.js
浏览器打开 Inspector 后,测试闭环分四步:
Roots 面板点击 Add Root,填入 file:///home/user/myproject,命名"我的项目"[roots] 已获取 1 个 root: /home/user/myprojectnotifications/roots/list_changed,服务器 stderr 应打印 [roots] 收到 list_changed,重新拉取... 与新的 root 数量count_lines,传 subdir: "../../../etc",应得到"路径越界"错误而非系统文件内容测试降级路径:用 Inspector 的配置关掉 roots 能力声明后重连,服务器 stderr 应打印 [roots] 客户端不支持 roots,降级到 cwd,工具仍可正常工作。
将服务器注册到 Claude Code(stdio 传输,指向编译产物):
claude mcp add project-stats -- node /绝对路径/dist/index.js
用 /mcp 确认连接状态为 connected,然后直接对话:"统计当前项目的代码行数"。Claude 会调用 count_lines,服务器感知到的 root 即为 Claude Code 当前工作目录。
关于 Roots 支持时机的诚实说明:Claude Code 对 Roots 的客户端支持经历了一段演进。GitHub issue #53861("Support MCP roots capability with listChanged notifications")于 2026-04-27 提出,描述了"Claude Code 目前未实现 MCP roots 客户端能力",并指出 claude --add-dir 与 /add-dir 正是 notifications/roots/list_changed 的天然触发源;该 issue 已于 2026-06-20 关闭。这意味着:新版 Claude Code 正在或已经补齐 Roots 客户端能力,但不同版本、不同客户端的支持程度参差不齐——这正是本教程坚持"四级降级到 cwd"的根本原因。你的服务器在任何客户端上都能跑,只是"项目感知精度"不同。
想象一个真实场景:你同时打开了 frontend 和 backend 两个仓库。用 Claude Code 启动多 root 会话:
claude --add-dir ~/repos/frontend ~/repos/backend
接入支持 Roots 的客户端后,服务器在 oninitialized 阶段一次性拿到两个 root 并缓存。当你说"分别统计前后端的代码行数"时,Claude 调用 count_lines 两次,分别以两个 root 为基点统计——无需你手动传任何路径参数,也无需重启服务器。会话中途若 /add-dir ~/repos/shared,客户端推送 list_changed,服务器自动把第三个 root 纳入缓存,下一次统计即覆盖新目录。
对比传统方案:若用命令行参数硬编码 root,添加 shared 后必须重启服务器、重新 claude mcp add;若用 cwd 启发式,多仓库场景下只能感知到启动目录一个。Roots + 动态通知的组合,是唯一能在"不重启、不传参"前提下保持边界实时同步的方案。
sequenceDiagram
participant S as 服务器
participant C as 客户端 (Claude Code / Inspector)
participant U as 用户
Note over S,C: 1. 握手与能力协商
C->>S: initialize (capabilities.roots.listChanged=true)
S-->>C: 返回服务器能力
C->>S: initialized 通知
Note over S,C: 2. 首次拉取
S->>S: oninitialized 触发
S->>C: roots/list 请求
C-->>S: { roots: [{uri, name}] }
S->>S: 解析+校验+缓存 workspaceRoots
Note over U,C: 3. 运行时动态变更
U->>C: /add-dir 新目录
C->>S: notifications/roots/list_changed
S->>C: roots/list (重新拉取)
C-->>S: 更新后的 roots
S->>S: 热更新缓存,无需重启
Note over S,C: 4. 工具调用受边界约束
C->>S: count_lines(subdir)
S->>S: assertWithinRoots 越界校验
S-->>C: 统计结果 / 越界错误
降级决策逻辑则是一棵清晰的判断树:
flowchart TD
A[工具需要访问文件] --> B{客户端声明了 roots 能力?}
B -- 否 --> C[降级: 使用进程 cwd]
B -- 是 --> D[发送 roots/list]
D --> E{返回有效 roots?}
E -- 空或异常 --> F[降级: 使用进程 cwd]
E -- 有效 --> G[解析 file:// URI 并缓存]
C --> H[assertWithinRoots 越界校验]
F --> H
G --> H
H --> I[执行文件操作]
Q1:listRoots() 抛出 "Method not found"(-32601)怎么办?
A:这是客户端不支持 Roots 的标准信号。说明握手时客户端未声明 capabilities.roots。这正是降级逻辑要捕获的情况——你的 try/catch 应将其视为"走 cwd 兜底"而非致命错误。
Q2:macOS 下用户填的 /tmp 总是校验失败?
A:macOS 的 /tmp 是 /private/tmp 的符号链接。用户传 /tmp/xxx,path.resolve 解析后变成 /private/tmp/xxx,与 root /tmp 不匹配。server-filesystem 的做法是同时存储原始路径和解析后路径。若你的用户群体在 macOS,建议在 setWorkspaceRoots 中对 root 也做一次 fs.realpath。
Q3:收到 list_changed 后重新拉取,但 root 列表没变?
A:部分客户端在用户"切换"而非"增删"工作区时也会发通知。这是正常的——幂等地重新拉取并覆盖缓存即可,不要做差量对比,规范要求"完全替换"而非合并。
Q4:stdio 服务器启动后没有任何 root,工具直接报错?
A:检查是否在 oninitialized 之外过早调用了需要 root 的工具。Roots 必须在握手完成后才能拉取,任何工具调用都应假设 workspaceRoots 可能为空并优雅处理。
Q5:Claude Code 调用 count_lines 时 subdir 越界,但用户其实想访问的是另一个 root?
A:当前实现只以 workspaceRoots[0] 为基点。多 root 场景下更友好的做法是:让工具接受一个可选的 rootIndex 或 rootName 参数,先选定基点 root 再拼接 subdir。这是从"单 root"升级到"多 root"工具设计的关键一步。
https://spec.modelcontextprotocol.io 的 Client Concepts → Roots 章节,包含完整的 JSON-RPC 消息定义与安全考量src/filesystem 目录,是 Roots 落地的"标准答案",本教程的 API 调用全部源自此处server/lowlevel/server.py 提供 on_roots_list_changed 回调与 notifications/roots/list_changed 通知注册,思路与 TypeScript 版完全对称| 维度 | 评估 |
|---|---|
| 适合的训练营类型 | MCP 进阶营、AI 工具链工程化营、开发者工具安全营 |
| 学员基础要求 | 已独立完成过至少一个 MCP 服务器(Tools 层) |
| 建议教学时长 | 3 小时(1 小时协议讲解 + 1.5 小时动手实现 + 0.5 小时 Inspector 调试) |
| 核心收获 | 能力协商机制、反向原语设计、路径穿越防御、四级降级工程思维 |
| 延展练习 | 给 count_lines 增加 rootName 多 root 选择;改用 Python SDK 重写一遍;把降级策略从"宽容 cwd"改为"严格抛错"并对比适用场景 |
本教程的最大价值不在 Roots 协议本身(它只有三条消息),而在于它迫使开发者面对一个真实的工程命题:当你的工具依赖一个"可能存在、可能不存在、可能随时变化"的外部状态时,如何写出在所有客户端上都体面的代码。这套"检查能力 → 尝试获取 → 失败降级 → 监听变更"的四段式范式,同样适用于 Sampling、Elicitation 等所有可选客户端能力,是 MCP 高质量服务器的通用骨架。
getClientCapabilities/listRoots/setNotificationHandler/oninitialized 等 API 全部源自其 index.ts 源码roots capability - Claude Code Issue #53861 — 记录 Claude Code 对 Roots 客户端能力的支持演进,2026-04-27 提出、2026-06-20 关闭,是判断支持时机的关键依据claude mcp add 命令、传输方式与 list_changed 支持说明[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
