MCP 文件服务器实战:Resources、Tools、Roots 与安全沙箱
这篇文章记录了我在贵阳实验室的实战过程。我坚信,在技术下行的时代,程序员唯一的护城河就是通过 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 | 用户主动选择 | 提供可复用的任务模板 | “审查当前配置”“总结指定日志” |
| 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-Session-Id,不要把会话 ID 写入公开日志。 - 在初始化后携带协商出的
MCP-Protocol-Version。
部署细节见:MCP Streamable HTTP 实战;授权边界见:MCP OAuth 认证实战。
常见故障:先判断属于哪一层
| 症状 | 所属层 | 优先检查 |
|---|---|---|
-32700 Parse error | stdio / JSON-RPC | stdout 普通文本、消息截断、非法 JSON、编码 |
Tool list failed | 生命周期 / 能力协商 | initialize 是否完成、是否声明 tools capability、tools/list 结构 |
spawn ENOENT | 本地进程 | command 绝对路径、虚拟环境、PATH、工作目录 |
READ_REJECTED | 应用安全 | 绝对路径、目录逃逸、敏感目录、文件类型与大小 |
| HTTP 403 | Streamable HTTP | Origin、认证、授权范围 |
| 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 Specification 2025-11-25
- MCP Resources
- MCP Tools
- MCP Prompts
- MCP Roots
- MCP Transports
- 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 排障和工具对比来承接,避免站内关键词互相抢。
下一步阅读
返回专题入口 →MCP Server 实战:让 Claude 访问本地 SQLite 的 5 个步骤与避坑手册
手把手教你编写连接本地 SQLite 数据库的 MCP Server,实现真正的私有财务账本 AI 审计与数据主权锁定。包含 SQL 黑白名单过滤、安全分页查询设计及大数据量摘要回传策略。
MCP -32700 Parse error 怎么修?stdout、stdio 与 Tool list failed 排查
用 5 步和 30 秒命令排查 MCP -32700 Parse error、Unexpected non-JSON line、Tool list failed 与 spawn ENOENT,覆盖 macOS/Linux、Windows PowerShell、stdout/stderr 和 JSON-RPC 生命周期。
MCP OAuth 认证实战:远程 MCP Server 为什么不能裸奔?
实战讲解远程 MCP Server 的 OAuth 认证与授权设计,包括 Protected Resource Metadata、Authorization Server Discovery、Bearer Token、Scope、Resource Indicators、会话隔离和 Tool 权限边界。
MCP Streamable HTTP 实战:从本地 stdio Server 到远程 MCP 服务部署
实战讲解 MCP Server 如何从本地 stdio 模式迁移到 Streamable HTTP 远程部署,包括 HTTP POST/GET、SSE 流式消息、会话管理、反向代理、认证边界和生产环境排坑。

小白
Full-Stack AI Engineer
小白,全栈 AI 工程师,持续构建生产级 Agent 系统、产品工具与独立软件资产。
了解小白与 XBSTACK →