小白 / Xiaobai
开发者 · 产品构建者
持续构建 AI 工程系统、开发者工具与长期数字资产。
关于作者与 XBSTACK →
MCP Resources、Tools、Prompts、Roots 区别:Roots 不是安全沙箱
MCP Resources、Tools、Prompts、Roots 分别做什么、该怎么选?本文用 Python MCP 文件服务器实测符号链接逃逸,解释 Resource/Tool 选择、realpath、Root 归属校验、只读边界与审计方案。
如果你正在判断 MCP Resources、Tools、Prompts、Roots 到底有什么区别,最重要的不是背四个名词,而是先分清“上下文、动作、任务模板、文件边界”四种职责。本文同时解决另一个容易踩坑的问题:配置了 Roots,并不等于已经获得安全沙箱。
直接答案:Resources 用来提供可标识、可读取的上下文;Tools 用来执行查询、计算、写入或外部 API 调用;Prompts 是用户主动选择的任务模板;Roots 是 Client 提供给 Server 的候选文件范围。稳定读取优先用 Resource,产生动作或副作用时用 Tool,任务模板用 Prompt。Roots 只声明边界,Server 仍必须在每次文件操作前执行 realpath、符号链接解析、Root 归属、只读策略、大小限制与审计校验。
如果你遇到的是 -32700 Parse Error、Tool list failed 或 stdout 污染,请直接查看独立排错页:MCP -32700 Parse Error 怎么修?。
2026-08-05 状态说明:MCP 2026-07-28 已是稳定规范。本文继续解释 Resources、Tools、Prompts、Roots 与文件安全边界,同时区分稳定规范中的无状态核心、
server/discover、每请求元数据与仍需按 SDK/Client 验证的采用差异;旧版 2025-11-25 的 Session 流程仅作为迁移对照。
我重新检查这个实现时,先遇到的不是 ../
客户端已经让用户选择了工作区,Server 也拿到了 Roots。表面上看,模型只能操作这个目录,边界似乎已经足够清楚。
真正做路径测试时,问题马上出现:
workspace/
├── docs/
├── drafts/
└── latest-log -> /Users/me/.ssh/
请求读取的参数只有:
latest-log/config
输入里没有 ../,也不是绝对路径,但符号链接解析后,真实目标已经离开工作区。如果 Server 只检查原始字符串,或者只判断客户端是否提供了 Root,这次读取仍可能被放行。
这也是本文最重要的结论:Roots 是边界声明,不是已经完成的安全沙箱。
先给结论:安全边界必须落到每一次文件操作
一个可上线的 MCP 文件网关,至少要让每个请求依次经过下面这条链路:
用户选择可访问目录
↓
Client 通过 Roots 声明候选边界
↓
拒绝绝对路径
↓
resolve / realpath 解析 .. 与符号链接
↓
校验真实路径仍属于授权 Root
↓
检查敏感目录、文件类型、大小和读写策略
↓
Resource / Tool 权限控制
↓
低权限用户或容器形成最后物理边界
其中最容易犯的错误,是把 Roots、环境变量里的 ALLOWED_ROOT 或客户端工作区选择,当成最终执行边界。真正阻止目录穿越、绝对路径、符号链接逃逸和越权写入的,是 Server 在每一次操作中执行的路径解析和访问控制。
MCP 当前使用 JSON-RPC 2.0,连接建立时会完成协议版本与能力协商。Server 可以暴露 Resources、Prompts 和 Tools;Client 则可以提供 Roots、Sampling、Elicitation 等能力。文件网关只实现自己真正需要的能力即可,不应为了“功能完整”一次性开放全部读写权限。
Resources、Tools、Prompts、Roots 到底有什么区别
| 能力 | 谁主要控制 | 适合解决什么问题 | 文件网关中的典型用法 |
|---|---|---|---|
| Resources | 应用驱动 | 提供可标识、可读取的上下文 | 项目 README、配置快照、日志片段、数据库 Schema |
| Tools | 模型可发现和调用,应用应保留人工控制 | 查询、计算或产生副作用的动作 | 搜索文件、生成摘要、写入草稿、移动文件 |
| Prompts | 用户主动选择 | 提供可复用的任务模板 | “审查当前配置”“总结指定日志” |
| Roots | Client 提供给 Server | 声明文件系统操作边界 | 当前工作区、用户选择的仓库目录 |
Resources:应用决定怎样把上下文交给模型
Resources 通过 URI 唯一标识,客户端可以使用 resources/list 发现资源,再用 resources/read 获取内容。它适合“这是一个可被读取的对象”,而不是“执行一个动作”。
{
"jsonrpc": "2.0",
"id": 2,
"method": "resources/read",
"params": {
"uri": "file:///workspace/README.md"
}
}
Resource 并不天然等于物理磁盘文件。file:// URI 可以表示具有文件系统语义的资源,Server 仍可在内部从对象存储、数据库或版本库读取数据。无论底层来源是什么,都要校验 URI、检查权限并限制返回大小。
Tools:把动作拆小,而不是注册一个万能 shell
Tools 通过 tools/list 暴露名称、描述和输入 Schema,通过 tools/call 执行。它可以查询数据库、调用 API、计算结果,也可以写文件或触发部署,因此风险显著高于只读 Resource。
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "write_draft",
"arguments": {
"relative_path": "drafts/plan.md",
"content": "..."
}
}
}
不要提供下面这种接口:
run_command(command: string)
read_any_file(path: string)
write_any_file(path: string, content: string)
更安全的设计是把能力收窄成明确动作:
search_markdown(query, limit):只搜索允许目录中的 Markdown。read_text(relative_path, max_chars):只读取白名单文本格式并限制长度。write_draft(relative_path, content):只写入drafts/,禁止覆盖正式内容。propose_patch(relative_path, patch):返回 Diff,由用户确认后再落盘。
MCP 规范建议工具调用始终保留人类可拒绝的入口。对于写入、删除、外发消息、生产部署和凭据操作,这不应只是界面提示,还应在 Server 权限层再次限制。
Prompts:是任务入口,不是隐藏的系统后门
Prompts 是 Server 暴露给 Client 的结构化消息模板,通常由用户在界面中主动选择,例如斜杠命令。它适合把“如何使用 Resources 和 Tools”组织成稳定工作流,但 Prompt 本身不能绕过 Tool 权限,也不应偷偷注入用户不可见的高风险操作。
Roots:边界声明必须与 Server 校验同时存在
支持 Roots 的 Client 可以通过 roots/list 告知 Server 当前允许操作的目录。规范同时要求 Client 验证 Root URI、实施访问控制,也要求 Server 尊重 Root 边界并对路径再次验证。
因此正确关系是:
Roots = 用户和客户端认可的候选边界
Server path validation = 每次操作的强制执行边界
OS/container permissions = 最后的物理权限边界
只有三层同时成立,才能降低误读用户主目录、.ssh、浏览器配置和生产凭据的风险。
安全文件网关的 10 项检查
- 只接受相对路径,直接拒绝
/etc/passwd、C:\Users\...等绝对路径。 - 使用
resolve/realpath消解..和现有符号链接。 - 校验解析后的路径仍属于授权 Root,而不是只做字符串前缀判断。
- 默认只读;写、删除、移动分别注册独立 Tool。
- 写入只允许进入指定子目录,例如
drafts/或临时目录。 - 限制文件扩展名、单文件大小、读取字符数和目录列表数量。
- 禁止读取密钥、凭据、
.env、私钥和浏览器数据目录。 - 审计
tool_name、相对路径、操作者、结果、耗时和拒绝原因,但不记录敏感正文。 - 错误返回稳定错误码,不把服务器绝对路径和完整堆栈直接暴露给模型。
- 使用低权限系统用户或容器运行 Server,不依赖应用代码单层防护。
Python 实战:实现可复用的路径沙箱
下面的示例只演示安全边界,不假设客户端一定支持某个特定 UI。它使用 FastMCP 注册两个收窄后的 Tool:只读文本和写入草稿。
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("SafeFileGateway")
logger = logging.getLogger("safe-file-gateway")
logger.setLevel(logging.INFO)
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
logger.handlers.clear()
logger.addHandler(handler)
logger.propagate = False
ROOT = Path(os.environ.get("MCP_ALLOWED_ROOT", "./sandbox")).expanduser().resolve()
DRAFT_ROOT = (ROOT / "drafts").resolve()
ROOT.mkdir(parents=True, exist_ok=True)
DRAFT_ROOT.mkdir(parents=True, exist_ok=True)
ALLOWED_READ_SUFFIXES = {".md", ".txt", ".json", ".yaml", ".yml", ".log"}
MAX_FILE_BYTES = 512 * 1024
MAX_RETURN_CHARS = 50_000
class PathRejected(ValueError):
pass
def resolve_inside(root: Path, relative_path: str, *, must_exist: bool) -> Path:
candidate = Path(relative_path)
if candidate.is_absolute():
raise PathRejected("absolute paths are not allowed")
target = (root / candidate).resolve(strict=must_exist)
try:
target.relative_to(root)
except ValueError as exc:
raise PathRejected("path escapes the allowed root") from exc
return target
def reject_sensitive_name(target: Path) -> None:
lowered = {part.lower() for part in target.parts}
blocked = {".ssh", ".gnupg", ".aws", ".env", "credentials", "secrets"}
if lowered & blocked or target.name.lower().endswith((".pem", ".key", ".p12")):
raise PathRejected("sensitive path is blocked")
@mcp.tool()
def read_text(relative_path: str, max_chars: int = 20_000) -> dict:
"""Read a bounded UTF-8 text file from the authorized root."""
try:
target = resolve_inside(ROOT, relative_path, must_exist=True)
reject_sensitive_name(target)
if not target.is_file():
raise PathRejected("target is not a regular file")
if target.suffix.lower() not in ALLOWED_READ_SUFFIXES:
raise PathRejected("file type is not allowed")
if target.stat().st_size > MAX_FILE_BYTES:
raise PathRejected("file is larger than the configured limit")
bounded = max(1, min(max_chars, MAX_RETURN_CHARS))
text = target.read_text(encoding="utf-8")
logger.info("read_text path=%s bytes=%s", relative_path, target.stat().st_size)
return {
"ok": True,
"relative_path": relative_path,
"text": text[:bounded],
"truncated": len(text) > bounded,
}
except (OSError, UnicodeError, PathRejected) as exc:
logger.warning("read_text rejected path=%s reason=%s", relative_path, exc)
return {"ok": False, "error": "READ_REJECTED", "message": str(exc)}
@mcp.tool()
def write_draft(relative_path: str, content: str) -> dict:
"""Write a UTF-8 Markdown draft under drafts/. Never writes production files."""
try:
target = resolve_inside(DRAFT_ROOT, relative_path, must_exist=False)
reject_sensitive_name(target)
if target.suffix.lower() != ".md":
raise PathRejected("drafts must use the .md suffix")
encoded = content.encode("utf-8")
if len(encoded) > MAX_FILE_BYTES:
raise PathRejected("content is larger than the configured limit")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(encoded)
logger.info("write_draft path=%s bytes=%s", relative_path, len(encoded))
return {"ok": True, "relative_path": str(target.relative_to(DRAFT_ROOT))}
except (OSError, UnicodeError, PathRejected) as exc:
logger.warning("write_draft rejected path=%s reason=%s", relative_path, exc)
return {"ok": False, "error": "WRITE_REJECTED", "message": str(exc)}
if __name__ == "__main__":
mcp.run(transport="stdio")
用四条路径验证修复是否真的生效
不要只看代码“像是安全的”,至少要把正常路径、目录穿越、绝对路径和符号链接逃逸都跑一遍。
先在测试目录中准备一个指向 Root 外部的符号链接:
mkdir -p sandbox/docs
printf "ok" > sandbox/docs/readme.md
ln -s ~/.ssh sandbox/latest-log
然后复用上面的 resolve_inside:
cases = [
"docs/readme.md",
"../.ssh/config",
"/etc/passwd",
"latest-log/config",
]
for relative_path in cases:
try:
target = resolve_inside(ROOT, relative_path, must_exist=False)
print(f"ALLOWED {relative_path} -> {target}")
except PathRejected as exc:
print(f"REJECTED {relative_path} -> {exc}")
预期结果应当是:
| 输入 | 结果 | 原因 |
|---|---|---|
docs/readme.md | ALLOWED | 解析后的真实路径仍在 Root 内 |
../.ssh/config | REJECTED | 规范化后越过 Root |
/etc/passwd | REJECTED | 绝对路径 |
latest-log/config | REJECTED | 符号链接解析后指向 Root 外部 |
如果第四条仍然被放行,说明实现只检查了字符串,没有检查真实路径。
还要注意一个更隐蔽的问题:resolve() 与真正打开文件之间存在时间窗口。对高风险、多用户或存在恶意本地进程的场景,仅做“先解析、后打开”仍可能遇到 TOCTOU 竞态。生产环境应进一步使用目录文件描述符、openat/dir_fd、O_NOFOLLOW 或容器只读挂载,把校验与打开尽量绑定在同一权限边界内。
这段代码有几个刻意限制:
- 不接受绝对路径。
- 使用
Path.resolve()后再执行relative_to(),避免../和符号链接逃逸。 read_text只接受文本白名单,并限制文件大小与返回字符数。write_draft只能写入drafts/下的 Markdown,不能覆盖正式内容。- 日志写入
stderr,不会污染 stdio 的协议输出。
实际生产还应增加用户身份、租户隔离、速率限制、并发控制、变更审批和审计存储。文件删除和命令执行不应在这个基础示例里顺手补上。
stdio 与 Streamable HTTP 怎么选
MCP 2025-11-25 规范定义的标准传输是:
| 传输 | 适合场景 | 关键风险 |
|---|---|---|
| stdio | 本地桌面客户端、IDE、单用户开发环境 | stdout 污染、PATH、工作目录、子进程权限 |
| Streamable HTTP | 跨机器、团队共享、云端服务 | 认证、Origin 校验、会话劫持、代理与超时 |
stdio:本地优先,但 stdout 必须绝对纯净
stdio 中,Client 启动 Server 子进程,通过 stdin 发送消息、stdout 接收消息。每条消息以换行分隔,消息内部不能包含未编码的换行;普通日志可以写 stderr,但 stdout 不能出现任何非 MCP 消息。
因此这些写法都可能触发 -32700 Parse error:
print("server started")
console.log("database connected")
不要通过 sys.stdout = sys.stderr 或覆盖 process.stdout.write 进行粗暴兜底,因为这也可能破坏 SDK 的合法响应。正确做法是关闭依赖的 banner,让应用日志显式使用 stderr;无法控制的依赖放到独立子进程中。
更完整的排错流程见:MCP -32700 Parse error 排查。
Streamable HTTP:不是旧式双端点 SSE
Streamable HTTP 替代了 2024-11-05 版本中的 HTTP+SSE Transport。新实现使用单一 MCP 端点处理 POST 和 GET,例如:
https://example.com/mcp
Client 通过 POST 发送 JSON-RPC 消息,Server 可以返回普通 JSON,也可以选择 text/event-stream 进行流式响应。远程部署至少要做到:
- 校验
Origin,拒绝不可信来源,防止 DNS Rebinding。 - 本地服务只绑定
127.0.0.1,不要默认监听0.0.0.0。 - 为所有远程连接实施认证与授权。
- 按协议代际处理生命周期:MCP
2026-07-28不再使用协议级Mcp-Session-Id,每个请求携带协议版本、Client 信息与能力元数据;只有 legacy2025-11-25及更早 Streamable HTTP 流程仍涉及 Session。 MCP-Protocol-Version与协议要求的请求元数据必须由当前 SDK/Client 正确发送;不要把旧版“初始化后缓存协商结果”的做法套到 2026-07-28。
部署细节见:MCP Streamable HTTP 实战;授权边界见:MCP OAuth 认证实战。
常见故障:先判断属于哪一层
| 症状 | 所属层 | 优先检查 |
|---|---|---|
-32700 Parse error | stdio / JSON-RPC | stdout 普通文本、消息截断、非法 JSON、编码 |
Tool list failed | 生命周期 / 能力协商 | 先确认协议版本:legacy 2025 检查 initialize/tools capability;2026-07-28 检查每请求元数据、能力发现/方法调用和 tools/list 结构 |
spawn ENOENT | 本地进程 | command 绝对路径、虚拟环境、PATH、工作目录 |
READ_REJECTED | 应用安全 | 绝对路径、目录逃逸、敏感目录、文件类型与大小 |
| HTTP 403 | Streamable HTTP | Origin、认证、授权范围 |
| HTTP 404 且带 Session ID | legacy 会话 | 仅对 2025-era Session 流程排查 Session 是否终止/失效;2026-07-28 不应依赖协议级 Session ID |
| 返回内容过大 | 资源治理 | 分页、摘要、搜索、字符上限、二进制处理 |
排错时从第一条异常开始,不要只盯着最后出现的 EPIPE 或“连接已关闭”。后者通常是前面解析失败或进程崩溃后的连锁结果。
上线前验收清单
- Client 只暴露用户明确选择的 Roots。
- Server 对每次文件请求重新校验 Root 边界。
- 默认能力只读,写入和删除需要独立授权。
- 不存在万能 shell、任意路径读取或任意 URL 请求工具。
- stdout 只包含合法 MCP 消息,日志全部走 stderr 或文件。
- 文件大小、返回长度、并发、超时和速率都有上限。
- 审计日志能回答“谁在何时对哪个相对路径执行了什么动作”。
- Server 使用低权限用户或容器,不以管理员身份运行。
- Streamable HTTP 已配置 Origin 校验、认证,并按协议代际处理状态:2026-07-28 不依赖协议级 Session ID;legacy Session 仅作为兼容路径维护。
- 使用当前 MCP Inspector 和自动测试验证目标协议:legacy 2025 覆盖 initialize/list/read/call;2026-07-28 覆盖自描述请求、discover(如使用)、list/read/call 与错误分支。
什么时候不该使用 MCP
MCP 解决的是 Client 与 Server 之间的能力发现和上下文交换标准化,不会自动解决所有权限、业务或部署问题。
下面几种情况,传统 API 或单一 Function Calling 反而更简单:
- 只有一个固定接口,调用方也只有一个应用。
- 服务本身已经有成熟 REST API,不需要多客户端自动发现。
- 操作必须经过复杂事务和强一致审批,现有业务系统已经承担这些职责。
- 团队还没有身份、审计、限流和密钥管理,却准备先把远程 MCP 暴露到公网。
更完整的选型对比见:MCP vs Function Calling。
官方规范与继续阅读
本文于 2026-08-15 重新核对 MCP 2026-07-28 当前规范,同时保留 2025-11-25 作为 legacy 兼容背景:
- MCP 2026-07-28 Specification
- MCP 2026-07-28 Streamable HTTP
- MCP 2026-07-28 Release Notes
- MCP Specification 2025-11-25(legacy compatibility)
- MCP Security Best Practices
站内延伸:
- MCP Server 连接 SQLite:只读查询与权限控制
- MCP 安全最佳实践与边界防御
- MCP JSON-RPC Parse error 排查
- MCP Streamable HTTP 部署
- MCP OAuth 认证
继续按 MCP 生产部署路径读,而不是堆 guide / tutorial
MCP 内容统一按协议理解、本地 Server、远程部署、OAuth、安全治理、stdio/JSON-RPC 排障和工具对比来承接,避免站内关键词互相抢。
继续阅读
返回专题 →AI 工程周报
只发真正改变工程判断的变化、故障、实验和新资产。
参与讨论
问题、验证与勘误
登录后可发表评论。所有新评论先进入审核;审核期间仅评论者本人和管理员可见,通过后才公开。