MCP 文件服务器 Resources Tools Roots 安全沙箱架构 - XBSTACK

MCP 文件服务器实战:Resources、Tools、Roots 与安全沙箱

Release Date
2026-05-24
Reading Time
12分钟
Content Size
5,794 chars
MCP 协议
MCP Server
Resources
工具
Roots
Python
文件服务器
安全沙箱
Xiaobai's Note / 实验室笔记

这篇文章记录了我在贵阳实验室的实战过程。我坚信,在技术下行的时代,程序员唯一的护城河就是通过 AI 建立属于自己的数字资产。

先给结论

  • MCP 2025-11-25 规范中,Resources 是应用驱动的上下文,Prompts 是用户主动选择的模板,Tools 是模型可调用的动作;三者不能因为都由 Server 暴露就混成同一种接口。
  • Roots 只是客户端告知 Server 的候选目录边界;即使输入中没有 ../,符号链接仍可能把请求带出工作区,Server 必须对解析后的真实路径再次校验。
  • 当前标准传输是 stdio 与 Streamable HTTP;旧 HTTP+SSE 已被 Streamable HTTP 替代,SSE 只是 Streamable HTTP 可选的流式响应方式。
  • 生产文件网关应默认只读,写操作单独注册并要求人工确认,同时限制扩展名、文件大小、返回长度和审计字段。
  • stdio 模式下 stdout 只能承载合法 MCP 消息,普通日志必须写 stderr 或独立日志文件。

适合谁读

  • 正在把 MCP / MCP Server / Resources / Tools 落到真实项目里的开发者。
  • 不想只看概念,希望知道取舍、边界、风险和下一步怎么做的独立开发者。
  • 正在做技术选型、工具链治理、自动化工作流或个人数字资产建设的读者。

本文解决的问题

  • MCP Resources、Tools、Prompts 和 Roots 有什么区别?
  • 怎样实现安全的 MCP 文件服务器?
  • MCP Roots 能否防止目录穿越?
  • MCP stdio 与 Streamable HTTP 怎么选?
  • 为什么 MCP Server 会出现 stdout 污染和 Parse error?

2026-07-15 更新说明:本文按 MCP 2025-11-25 规范重构。旧版将远程传输写成“stdio 与 SSE 二选一”,现已修正为 stdio 与 Streamable HTTP;本次继续补充符号链接逃逸复现、修复后的攻击路径验证,以及生产环境仍需处理的竞态风险。

我重新检查这个实现时,先遇到的不是 ../

客户端已经让用户选择了工作区,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-Session-Id,不要把会话 ID 写入公开日志。
  • 在初始化后携带协商出的 MCP-Protocol-Version

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

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

症状所属层优先检查
-32700 Parse errorstdio / JSON-RPCstdout 普通文本、消息截断、非法 JSON、编码
Tool list failed生命周期 / 能力协商initialize 是否完成、是否声明 tools capability、tools/list 结构
spawn ENOENT本地进程command 绝对路径、虚拟环境、PATH、工作目录
READ_REJECTED应用安全绝对路径、目录逃逸、敏感目录、文件类型与大小
HTTP 403Streamable HTTPOrigin、认证、授权范围
HTTP 404 且带 Session ID会话Session 是否终止,Client 是否应重新 initialize
返回内容过大资源治理分页、摘要、搜索、字符上限、二进制处理

排错时从第一条异常开始,不要只盯着最后出现的 EPIPE 或“连接已关闭”。后者通常是前面解析失败或进程崩溃后的连锁结果。

上线前验收清单

  • Client 只暴露用户明确选择的 Roots。
  • Server 对每次文件请求重新校验 Root 边界。
  • 默认能力只读,写入和删除需要独立授权。
  • 不存在万能 shell、任意路径读取或任意 URL 请求工具。
  • stdout 只包含合法 MCP 消息,日志全部走 stderr 或文件。
  • 文件大小、返回长度、并发、超时和速率都有上限。
  • 审计日志能回答“谁在何时对哪个相对路径执行了什么动作”。
  • Server 使用低权限用户或容器,不以管理员身份运行。
  • Streamable HTTP 已配置 Origin 校验、认证和安全会话 ID。
  • 使用 MCP Inspector 和自动测试验证 initialize、list、read/call 与错误分支。

什么时候不该使用 MCP

MCP 解决的是 Client 与 Server 之间的能力发现和上下文交换标准化,不会自动解决所有权限、业务或部署问题。

下面几种情况,传统 API 或单一 Function Calling 反而更简单:

  • 只有一个固定接口,调用方也只有一个应用。
  • 服务本身已经有成熟 REST API,不需要多客户端自动发现。
  • 操作必须经过复杂事务和强一致审批,现有业务系统已经承担这些职责。
  • 团队还没有身份、审计、限流和密钥管理,却准备先把远程 MCP 暴露到公网。

更完整的选型对比见:MCP vs Function Calling

官方规范与继续阅读

本文按 2026-07-15 可访问的 MCP 2025-11-25 规范核对:

站内延伸:

专题入口 / MCP Hub

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

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

下一步阅读

返回专题入口 →
小白

小白

Full-Stack AI Engineer

小白,全栈 AI 工程师,持续构建生产级 Agent 系统、产品工具与独立软件资产。

了解小白与 XBSTACK →

喜欢这篇文章?
加入小白实验室的周刊

每期只整理 AI 工程变化、真实故障、可复现实验、值得尝试的工具和 XBSTACK 新资产,不做泛新闻汇总,也不为周更凑数。

Comments