MCP 根目录
根目录(Roots)是 MCP 提供的一种机制,允许服务器询问客户端可以操作的文件系统边界。通过根目录,服务器可以了解自己被授权访问哪些目录,从而在安全边界内执行文件操作。
什么是根目录?
根目录定义了服务器可以访问的文件系统范围。当服务器需要读取或操作文件时,它应该先查询客户端提供的根目录列表,确保所有操作都在授权范围内。
为什么需要根目录?
在传统的文件访问场景中,服务器可能需要:
- 知道项目的工作目录
- 了解可以读取哪些配置文件
- 确定日志文件的存放位置
根目录机制让客户端可以明确告诉服务器这些边界信息,而不需要在每次请求中重复传递。
与资源的关系
根目录和资源都涉及文件访问,但职责不同:
| 概念 | 职责 | 使用场景 |
|---|---|---|
| 根目录 | 定义访问边界 | 服务器了解可以操作的范围 |
| 资源 | 提供具体数据 | 客户端读取特定文件内容 |
根目录是"范围定义",资源是"数据提供"。
协议消息
列出根目录
服务器通过 roots/list 方法查询可用的根目录:
{
"jsonrpc": "2.0",
"id": "roots-1",
"method": "roots/list"
}
客户端响应:
{
"jsonrpc": "2.0",
"id": "roots-1",
"result": {
"roots": [
{
"uri": "file:///home/user/projects/myapp",
"name": "项目目录"
},
{
"uri": "file:///home/user/.config",
"name": "配置目录"
}
]
}
}
根目录变更通知
当根目录发生变化时,客户端发送通知:
{
"jsonrpc": "2.0",
"method": "notifications/roots/list_changed"
}
服务器收到通知后应重新查询根目录列表。
能力声明
支持根目录功能的客户端需要声明 roots 能力:
# 客户端能力声明
client_capabilities = {
"roots": {
"listChanged": True # 支持根目录变更通知
}
}
服务器端实现
服务器通常在初始化后查询根目录,并在后续操作中使用这些信息:
from mcp.server import Server
from mcp.types import Root, TextContent
from pathlib import Path
app = Server("file-server")
# 存储根目录列表
allowed_roots: list[str] = []
@app.on_initialize()
async def on_initialize():
"""初始化时查询根目录"""
global allowed_roots
# 查询客户端提供的根目录
result = await session.send_request("roots/list", {})
allowed_roots = [root["uri"] for root in result.get("roots", [])]
def is_path_allowed(file_path: str) -> bool:
"""检查路径是否在允许的根目录内"""
path = Path(file_path).resolve()
for root_uri in allowed_roots:
root_path = Path(root_uri.replace("file://", "")).resolve()
try:
path.relative_to(root_path)
return True
except ValueError:
continue
return False
@app.read_resource()
async def read_resource(uri: str) -> list[TextContent]:
"""读取文件资源"""
if not uri.startswith("file://"):
raise ValueError("Only file:// URIs are supported")
file_path = uri[7:] # 移除 "file://" 前缀
# 验证路径是否在允许的根目录内
if not is_path_allowed(file_path):
raise ValueError("Access denied: path outside allowed roots")
# 读取文件
content = Path(file_path).read_text(encoding="utf-8")
return [TextContent(type="text", text=content)]
客户端端实现
客户端负责提供和维护根目录列表:
from mcp import ClientSession
from mcp.types import Root
class MyClient:
def __init__(self):
self.roots = [
Root(uri="file:///home/user/projects", name="项目目录"),
Root(uri="file:///home/user/data", name="数据目录")
]
@server.list_roots()
async def list_roots(self) -> list[Root]:
"""返回可用的根目录"""
return self.roots
def add_root(self, uri: str, name: str):
"""添加新的根目录并发送通知"""
self.roots.append(Root(uri=uri, name=name))
# 发送变更通知
await self.session.send_notification(
method="notifications/roots/list_changed"
)
典型应用场景
1. 项目工作区
IDE 或编辑器类的客户端可以提供当前打开的项目目录作为根目录:
# VS Code 扩展示例
roots = [
Root(
uri=f"file://{workspace_folder.uri.path}",
name=workspace_folder.name
)
for workspace_folder in vscode.workspace.workspaceFolders or []
]
服务器可以使用这些根目录来:
- 查找项目配置文件(如
pyproject.toml、package.json) - 遍历项目源代码
- 生成项目结构概览
2. 安全沙箱
在受限环境中,根目录可以定义安全边界:
# 限制服务器只能访问特定目录
SAFE_DIRS = ["/tmp/sandbox", "/var/data/public"]
@app.list_roots()
async def list_roots():
return [
Root(uri=f"file://{dir}", name=f"安全目录: {dir}")
for dir in SAFE_DIRS
]
3. 多项目管理
当用户同时打开多个项目时,可以提供多个根目录:
roots = [
Root(uri="file:///projects/frontend", name="前端项目"),
Root(uri="file:///projects/backend", name="后端项目"),
Root(uri="file:///projects/shared", name="共享库")
]
根目录 URI 约定
文件系统路径
最常见的是使用 file:// 方案:
file:///home/user/projects/myapp
file:///C:/Users/user/projects/myapp # Windows
注意:file:// 后面的路径应该是绝对路径。
其他 URI 方案
虽然文件系统是最常见的场景,但根目录也可以使用其他 URI 方案:
git:///repository/path
s3://bucket/prefix
custom://application/workspace
服务器应该根据自己支持的 URI 方案来处理这些根目录。
最佳实践
服务器端
- 始终验证路径:在执行任何文件操作前,验证路径是否在允许的根目录内
- 处理根目录变更:正确处理
notifications/roots/list_changed通知 - 使用绝对路径:比较路径时先转换为绝对路径,避免相对路径问题
- 缓存根目录:初始化后缓存根目录列表,避免频繁查询
from pathlib import Path
class RootValidator:
def __init__(self):
self.resolved_roots: list[Path] = []
def update_roots(self, roots: list[str]):
"""更新并解析根目录"""
self.resolved_roots = [
Path(r.replace("file://", "")).resolve()
for r in roots
]
def is_allowed(self, path: str | Path) -> bool:
"""检查路径是否在允许范围内"""
resolved = Path(path).resolve()
return any(
resolved.is_relative_to(root)
for root in self.resolved_roots
)
客户端端
- 提供有意义的名称:为每个根目录设置描述性名称
- 及时更新:当工作区变化时发送变更通知
- 合理限制范围:只提供必要的目录访问权限
- 考虑跨平台路径:确保 URI 格式在不同操作系统上正确
def create_root_from_path(path: str, name: str) -> Root:
"""创建跨平台兼容的根目录"""
import platform
abs_path = Path(path).resolve()
if platform.system() == "Windows":
# Windows 路径需要特殊处理
uri = f"file:///{abs_path}".replace("\\", "/")
else:
uri = f"file://{abs_path}"
return Root(uri=uri, name=name)
安全考虑
路径遍历攻击
始终验证路径,防止路径遍历攻击:
def safe_resolve_path(base: Path, relative: str) -> Path | None:
"""安全地解析相对路径"""
try:
resolved = (base / relative).resolve()
# 确保解析后的路径仍在基础目录内
resolved.relative_to(base)
return resolved
except ValueError:
return None
# 使用示例
base_dir = Path("/home/user/projects")
unsafe_path = "../../etc/passwd" # 尝试访问系统文件
safe_path = safe_resolve_path(base_dir, unsafe_path)
if safe_path is None:
raise ValueError("Path traversal detected!")
符号链接处理
正确处理符号链接,避免绕过安全边界:
def is_path_allowed_strict(file_path: Path, roots: list[Path]) -> bool:
"""严格检查路径,包括符号链接"""
# 解析符号链接后的实际路径
real_path = file_path.resolve()
for root in roots:
real_root = root.resolve()
try:
real_path.relative_to(real_root)
return True
except ValueError:
continue
return False
与其他功能的配合
与资源配合
根目录定义边界,资源提供数据访问:
# 根目录定义可访问范围
roots = [Root(uri="file:///projects/myapp", name="项目")]
# 资源提供具体的文件访问
@app.list_resources()
async def list_resources():
resources = []
for root in allowed_roots:
root_path = Path(root.replace("file://", ""))
for file in root_path.rglob("*.py"):
resources.append(Resource(
uri=f"file://{file}",
name=file.name,
mimeType="text/x-python"
))
return resources
与工具配合
文件操作工具应该验证根目录:
@app.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "search_in_files":
search_path = arguments.get("path", ".")
# 验证搜索路径
if not is_path_allowed(search_path):
return [TextContent(
type="text",
text="错误:搜索路径不在允许的根目录内"
)]
# 执行搜索
results = search_files(search_path, arguments["pattern"])
return [TextContent(type="text", text=results)]
常见问题
Q: 根目录列表为空怎么办?
服务器应该优雅处理空列表:
@app.read_resource()
async def read_resource(uri: str):
if not allowed_roots:
# 没有根目录,拒绝所有文件访问
raise ValueError("No roots configured, file access denied")
# 正常处理...
Q: 根目录 URI 格式不一致?
规范化 URI 格式:
def normalize_uri(uri: str) -> str:
"""规范化文件 URI"""
if uri.startswith("file:///"):
return uri
elif uri.startswith("file://"):
# file://host/path -> file:///path (忽略主机)
return "file:///" + uri[7:].split("/", 1)[-1]
elif uri.startswith("/"):
return f"file://{uri}"
else:
return f"file:///{uri}"