XBSTACK XBSTACK
小白 / Xiaobai

小白 / Xiaobai

开发者 · 产品构建者

持续构建 AI 工程系统、开发者工具与长期数字资产。

关于作者与 XBSTACK →
MCP Resources、Tools、Prompts、Roots 有什么区别?文件安全实战:MCP 协议文章封面

MCP Resources、Tools、Prompts、Roots 区别:Roots 不是安全沙箱

MCP Resources、Tools、Prompts、Roots 分别做什么、该怎么选?本文用 Python MCP 文件服务器实测符号链接逃逸,解释 Resource/Tool 选择、realpath、Root 归属校验、只读边界与审计方案。

发布 · 2026-05-2413 分钟阅读XBSTACK 原创
#MCP#MCP Server#Resources#Tools#Roots#Python#文件服务器#安全沙箱

如果你正在判断 MCP Resources、Tools、Prompts、Roots 到底有什么区别,最重要的不是背四个名词,而是先分清“上下文、动作、任务模板、文件边界”四种职责。本文同时解决另一个容易踩坑的问题:配置了 Roots,并不等于已经获得安全沙箱。

直接答案:Resources 用来提供可标识、可读取的上下文;Tools 用来执行查询、计算、写入或外部 API 调用;Prompts 是用户主动选择的任务模板;Roots 是 Client 提供给 Server 的候选文件范围。稳定读取优先用 Resource,产生动作或副作用时用 Tool,任务模板用 Prompt。Roots 只声明边界,Server 仍必须在每次文件操作前执行 realpath、符号链接解析、Root 归属、只读策略、大小限制与审计校验。

如果你遇到的是 -32700 Parse ErrorTool 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用户主动选择提供可复用的任务模板“审查当前配置”“总结指定日志”
RootsClient 提供给 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 项检查

  1. 只接受相对路径,直接拒绝 /etc/passwdC:\Users\... 等绝对路径。
  2. 使用 resolve/realpath 消解 .. 和现有符号链接。
  3. 校验解析后的路径仍属于授权 Root,而不是只做字符串前缀判断。
  4. 默认只读;写、删除、移动分别注册独立 Tool。
  5. 写入只允许进入指定子目录,例如 drafts/ 或临时目录。
  6. 限制文件扩展名、单文件大小、读取字符数和目录列表数量。
  7. 禁止读取密钥、凭据、.env、私钥和浏览器数据目录。
  8. 审计 tool_name、相对路径、操作者、结果、耗时和拒绝原因,但不记录敏感正文。
  9. 错误返回稳定错误码,不把服务器绝对路径和完整堆栈直接暴露给模型。
  10. 使用低权限系统用户或容器运行 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.mdALLOWED解析后的真实路径仍在 Root 内
../.ssh/configREJECTED规范化后越过 Root
/etc/passwdREJECTED绝对路径
latest-log/configREJECTED符号链接解析后指向 Root 外部

如果第四条仍然被放行,说明实现只检查了字符串,没有检查真实路径。

还要注意一个更隐蔽的问题:resolve() 与真正打开文件之间存在时间窗口。对高风险、多用户或存在恶意本地进程的场景,仅做“先解析、后打开”仍可能遇到 TOCTOU 竞态。生产环境应进一步使用目录文件描述符、openat/dir_fdO_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 信息与能力元数据;只有 legacy 2025-11-25 及更早 Streamable HTTP 流程仍涉及 Session。
  • MCP-Protocol-Version 与协议要求的请求元数据必须由当前 SDK/Client 正确发送;不要把旧版“初始化后缓存协商结果”的做法套到 2026-07-28。

部署细节见:MCP Streamable HTTP 实战;授权边界见:MCP OAuth 认证实战

常见故障:先判断属于哪一层

症状所属层优先检查
-32700 Parse errorstdio / JSON-RPCstdout 普通文本、消息截断、非法 JSON、编码
Tool list failed生命周期 / 能力协商先确认协议版本:legacy 2025 检查 initialize/tools capability;2026-07-28 检查每请求元数据、能力发现/方法调用和 tools/list 结构
spawn ENOENT本地进程command 绝对路径、虚拟环境、PATH、工作目录
READ_REJECTED应用安全绝对路径、目录逃逸、敏感目录、文件类型与大小
HTTP 403Streamable HTTPOrigin、认证、授权范围
HTTP 404 且带 Session IDlegacy 会话仅对 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 Hub

继续按 MCP 生产部署路径读,而不是堆 guide / tutorial

MCP 内容统一按协议理解、本地 Server、远程部署、OAuth、安全治理、stdio/JSON-RPC 排障和工具对比来承接,避免站内关键词互相抢。

继续阅读

返回专题 →
MCP Server 实战:让 Claude 访问本地 SQLite 的 5 个步骤与避坑手册MCP Server 实战:手把手教你编写连接本地 SQLite 数据库的 MCP Server,实现真正的私有财务账本 AI 审计与数据主权锁定。包含 SQL 黑白名单过滤、安全分页查询设计及大数据量摘要回传策略。MCP OAuth 认证实战:远程 MCP Server 为什么不能裸奔?MCP OAuth 认证实战:实战讲解远程 MCP Server 的 OAuth 认证与授权设计,包括 Protected Resource Metadata、Authorization Server Discovery、Bearer Token、Scope、Resource Indicators、会话隔离和 Tool 权限边界。MCP Streamable HTTP 实战:从本地 stdio Server 到远程 MCP 服务部署MCP Streamable HTTP 怎么部署?本文按 MCP 2026-07-28 与官方 Python SDK 重写远程部署流程,覆盖 streamable-http、stateless 请求、反向代理、认证、Origin、超时、业务状态和 legacy Session 兼容。MCP Filesystem Server 实战:让 Claude / Cursor 安全读取本地文件MCP Filesystem Server 实战:实战讲解如何构建 MCP Filesystem Server,让 Claude / Cursor 安全读取本地文件,并通过路径白名单、Roots、Tool Scope、只读权限和 Prompt Injection 防护控制风险。

AI 工程周报

只发真正改变工程判断的变化、故障、实验和新资产。

评论与补充证据

参与讨论

问题、验证与勘误

登录后可发表评论。所有新评论先进入审核;审核期间仅评论者本人和管理员可见,通过后才公开。

登录评论 审核后公开
正在加载评论区…