首页 › tutorials › Roots原语实战:让MCP服务器读懂项目边界

概述

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

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

前置条件

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

绝大多数 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"
  }
}

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

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 服务器必须同时处理三种情况:客户端支持且返回有效 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-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,也会在校验阶段被拦截。

第七步:用 MCP Inspector 完整测试

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

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 传输,指向编译产物):

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

延伸阅读

训练营适用性评估

维度 评估
适合的训练营类型 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) — Roots 原语的官方权威定义,含能力协商、roots/list、list_changed 通知、错误码与安全考量
  2. Filesystem MCP Server - 官方参考实现 — Roots 落地的标准答案,本文 getClientCapabilities/listRoots/setNotificationHandler/oninitialized 等 API 全部源自其 index.ts 源码
  3. Support MCP roots capability - Claude Code Issue #53861 — 记录 Claude Code 对 Roots 客户端能力的支持演进,2026-04-27 提出、2026-06-20 关闭,是判断支持时机的关键依据
  4. Connect Claude Code to tools via MCP — Claude Code 官方 MCP 接入文档,含 claude mcp add 命令、传输方式与 list_changed 支持说明
  5. Roots | modelcontextprotocol/python-sdk (DeepWiki) — 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学通信知识星球海报-扫码加入