<!-- title: Roots原语实战：让MCP服务器读懂项目边界 -->

## 概述

在 Model Context Protocol（MCP）的五大原语中，Tools、Resources、Prompts、Sampling、Elicitation 都已被反复讨论，唯独 **Roots** 长期被忽视。Roots 是一个"反向"原语：它让**客户端主动告诉服务器**自己正在操作哪些目录边界，从而让服务器"读懂项目"。本教程将从协议层到 SDK 实现，带你构建一个**项目感知的代码统计服务器**，掌握能力协商、动态拉取、变更监听与优雅降级四大核心机制。

**适用人群**：已写过至少一个 MCP 服务器、希望让工具不再"瞎猜当前项目"的中级开发者。

## 前置条件

- Node.js ≥ 20，熟悉 TypeScript 基本语法
- 已安装 Claude Code（用于最终接入测试）
- 了解 MCP 三大服务端原语（Tools/Resources/Prompts）的基础概念
- 建议先在浏览器跑通一次 [MCP Inspector](https://github.com/modelcontextprotocol/inspector)，它是最可靠的 Roots 测试客户端

## 第一步：理解 Roots 原语的"反向"本质

绝大多数 MCP 原语是 **服务器 → 客户端** 方向（服务器声明有哪些工具、资源）。Roots 恰好相反——它是**客户端能力**：客户端在连接时声明"我支持 Roots"，服务器随后**主动向客户端发请求**索取当前的工作目录列表。

一个 Root 的数据结构极简，规范明确要求 `uri` 当前必须是 `file://`：

```json
{
  "uri": "file:///home/user/projects/myproject",
  "name": "我的项目"
}
```

整个协议只有两条消息加一条通知：

| 方向 | 方法 | 作用 |
|------|------|------|
| 服务器 → 客户端 | `roots/list` 请求 | 拉取当前 roots 列表 |
| 客户端 → 服务器 | `notifications/roots/list_changed` 通知 | 告知 roots 已变化，请重新拉取 |
| 客户端 → 服务器 | 初始化时 `capabilities.roots` | 声明"我支持 Roots，且会发变更通知" |

**能力协商**发生在 `initialize` 握手阶段，客户端声明：

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

其中 `listChanged` 表示客户端是否会在 roots 变化时主动发通知。错误处理遵循标准 JSON-RPC：客户端不支持 Roots 时返回 `-32601`（Method not found），内部错误返回 `-32603`。

**为什么这件事重要？** 在 Roots 出现之前，服务器只能靠三种"猜"的方式确定项目边界：命令行参数硬编码、读取进程 `cwd`、让用户每次调用工具时手动传 `path`。这三种方式在多仓库会话、中途 `/add-dir` 切换工作区时全部会失效——服务器"自以为知道项目在哪"，实则早已陈旧。

## 第二步：脚手架与依赖

新建项目目录并初始化：

```bash
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`）：

```json
{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts"
  }
}
```

## 第三步：实现 Root 解析与安全校验

Roots 最容易被忽视却最致命的点是**路径穿越（path traversal）**。客户端给你的 `file://` URI 只是"建议"，服务器在用它之前必须校验每一次文件操作都落在某个 root 之内。先写两个工具函数 `src/roots.ts`：

```typescript
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 服务器必须同时处理三种情况：客户端支持且返回有效 roots、客户端支持但返回空、客户端根本不支持。新建 `src/index.ts`：

```typescript
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` 中追加：

```typescript
// 注册 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`（查看服务器当前感知到的边界，便于调试）：

```typescript
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，也会在校验阶段被拦截。

## 第七步：用 MCP Inspector 完整测试

由于 Roots 是客户端能力，测试时**必须用一个真正支持 Roots 的客户端**。MCP Inspector 是首选，它提供图形化的"添加 root"和"触发 list_changed"按钮。编译并启动：

```bash
npm run build
npx @modelcontextprotocol/inspector node dist/index.js
```

浏览器打开 Inspector 后，测试闭环分四步：

1. **添加 root**：在 Inspector 左侧 `Roots` 面板点击 Add Root，填入 `file:///home/user/myproject`，命名"我的项目"
2. **观察初始化拉取**：服务器 stderr 应打印 `[roots] 已获取 1 个 root: /home/user/myproject`
3. **触发动态变更**：再 Add 一个 root，Inspector 会自动推送 `notifications/roots/list_changed`，服务器 stderr 应打印 `[roots] 收到 list_changed，重新拉取...` 与新的 root 数量
4. **验证边界约束**：调用 `count_lines`，传 `subdir: "../../../etc"`，应得到"路径越界"错误而非系统文件内容

测试降级路径：用 Inspector 的配置关掉 `roots` 能力声明后重连，服务器 stderr 应打印 `[roots] 客户端不支持 roots，降级到 cwd`，工具仍可正常工作。

## 第八步：接入 Claude Code

将服务器注册到 Claude Code（stdio 传输，指向编译产物）：

```bash
claude mcp add project-stats -- node /绝对路径/dist/index.js
```

用 `/mcp` 确认连接状态为 `connected`，然后直接对话："统计当前项目的代码行数"。Claude 会调用 `count_lines`，服务器感知到的 root 即为 Claude Code 当前工作目录。

**关于 Roots 支持时机的诚实说明**：Claude Code 对 Roots 的客户端支持经历了一段演进。GitHub issue [#53861](https://github.com/anthropics/claude-code/issues/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 会话：

```bash
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 + 动态通知的组合，是唯一能在"不重启、不传参"前提下保持边界实时同步的方案。

## 工作流程全景

```mermaid
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: 统计结果 / 越界错误
```

降级决策逻辑则是一棵清晰的判断树：

```mermaid
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"工具设计的关键一步。

## 延伸阅读

- **Roots 官方规范（2025-06-18）**：`https://spec.modelcontextprotocol.io` 的 Client Concepts → Roots 章节，包含完整的 JSON-RPC 消息定义与安全考量
- **server-filesystem 参考实现**：官方仓库 `src/filesystem` 目录，是 Roots 落地的"标准答案"，本教程的 API 调用全部源自此处
- **MCP Inspector**：调试 Roots 的必备客户端，图形化添加 root、触发 list_changed，比命令行测试直观得多
- **Python SDK 低层服务器**：若用 Python，`server/lowlevel/server.py` 提供 `on_roots_list_changed` 回调与 `notifications/roots/list_changed` 通知注册，思路与 TypeScript 版完全对称
- **Completion 原语**：Roots 之外另一个被低估的原语，让服务器为 Prompt 参数和 Resource 模板提供自动补全，适合作为下一篇进阶主题

## 训练营适用性评估

| 维度 | 评估 |
|------|------|
| 适合的训练营类型 | MCP 进阶营、AI 工具链工程化营、开发者工具安全营 |
| 学员基础要求 | 已独立完成过至少一个 MCP 服务器（Tools 层） |
| 建议教学时长 | 3 小时（1 小时协议讲解 + 1.5 小时动手实现 + 0.5 小时 Inspector 调试） |
| 核心收获 | 能力协商机制、反向原语设计、路径穿越防御、四级降级工程思维 |
| 延展练习 | 给 `count_lines` 增加 `rootName` 多 root 选择；改用 Python SDK 重写一遍；把降级策略从"宽容 cwd"改为"严格抛错"并对比适用场景 |

本教程的最大价值不在 Roots 协议本身（它只有三条消息），而在于它迫使开发者面对一个真实的工程命题：**当你的工具依赖一个"可能存在、可能不存在、可能随时变化"的外部状态时，如何写出在所有客户端上都体面的代码**。这套"检查能力 → 尝试获取 → 失败降级 → 监听变更"的四段式范式，同样适用于 Sampling、Elicitation 等所有可选客户端能力，是 MCP 高质量服务器的通用骨架。

## 参考来源

1. [Roots - Model Context Protocol 规范 (2025-06-18)](https://modelcontextprotocol.io/specification/2025-06-18/client/roots) — Roots 原语的官方权威定义，含能力协商、roots/list、list_changed 通知、错误码与安全考量
2. [Filesystem MCP Server - 官方参考实现](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem) — Roots 落地的标准答案，本文 `getClientCapabilities`/`listRoots`/`setNotificationHandler`/`oninitialized` 等 API 全部源自其 `index.ts` 源码
3. [Support MCP `roots` capability - Claude Code Issue #53861](https://github.com/anthropics/claude-code/issues/53861) — 记录 Claude Code 对 Roots 客户端能力的支持演进，2026-04-27 提出、2026-06-20 关闭，是判断支持时机的关键依据
4. [Connect Claude Code to tools via MCP](https://code.claude.com/docs/en/mcp) — Claude Code 官方 MCP 接入文档，含 `claude mcp add` 命令、传输方式与 list_changed 支持说明
5. [Roots | modelcontextprotocol/python-sdk (DeepWiki)](https://deepwiki.com/modelcontextprotocol/python-sdk/5.1-roots) — Python SDK 中 Roots 客户端能力的实现解析，对照 TypeScript 版理解跨语言对称性

> [!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 等场景全覆盖。

![51学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
