Adds docs/superpowers/specs/2026-06-30-files-mcp-design.md describing a sibling FastAPI sub-app (mount: /files-mcp) that exposes 9 file operations (create/read/update/delete/list_dir/stat/search/move/copy) over fastapi-mcp, sandboxed to FILES_MCP_ROOT with utf-8 text only and atomic writes. Branch: feat/files-mcp Mount path: /files-mcp New package: files_mcp/ Backward compat: 0 existing tools / tests / dependencies changed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
16 KiB
Files MCP — Design Spec
Date: 2026-06-30
Branch: feat/files-mcp
Mount path: /files-mcp
New package: files_mcp/
Status: Design approved; awaiting implementation plan
Goal
Add a sibling MCP service that exposes file-system CRUD (plus a handful of
introspection and rearrangement operations) over the same fastapi-mcp transport
the existing spark-executor-mcp already uses. The service is sandboxed to a
configurable root directory and only handles utf-8 text content.
Background & Motivation
spark-executor-mcp ships 12 tools for the full Spark-on-YARN lifecycle
(submit, status, logs, kill, connections, plus Stage-2 PySpark file helpers).
The MCP_SERVICES registry in main.py was designed for additional sibling
services — the comment block above the list literally shows the intended
pattern: McpService(name="metrics", app=metrics_app, mount_path="/metrics-mcp").
This spec fills in one of those slots: a generic, sandboxed file-CRUD service. Use cases the LLM can now express through the same MCP transport:
- Read/write configuration snippets (YAML, JSON,
.env) on the host. - Stage input datasets in a known working directory before submitting a Spark job.
- Inspect prior job logs and outputs.
- Move/rename artifacts after a run completes.
Non-Goals
- Binary files. MCP is JSON; binary would force base64 round-trips and
inflates the LLM context. We only do utf-8 text. (Mirrors the existing
job_writer"reject code that is not valid UTF-8" rule.) - Streaming / partial reads. Read returns the whole file, capped at 1 MB.
YARN log tails are a separate tool (
get_job_logs) on the other service. - Multi-tenant isolation / per-user roots. Stage 1 ships a single shared
root from one env var. Per-user roots are a Stage 3 concern (per the existing
plan
docs/superpowers/plans/2026-06-24-spark-executor-mcp.md). - File locking / concurrent writer arbitration. Each write is atomic
(
tempfile + os.replace) but we do not serialize writers. This is fine for the actual usage (one LLM agent at a time per process); revisit if needed.
Architecture
main.py # append one McpService entry
└── FastApiMCP(files_app, mount_path="/files-mcp")
files_mcp/ # new sibling package
__init__.py # re-exports app; triggers root initialization
server.py # FastAPI("Files MCP") + 9 routes + exception handlers
models.py # FileEntry, OperationResult
core/
path_guard.py # resolve_and_check(path) -> Path; sandbox check
fs_ops.py # pure file operations on already-resolved Path
tools/
files.py # business wrappers: log + delegate to fs_ops
requests.py # Pydantic body models (fastapi-mcp requires)
tests/
unit/test_path_guard.py
unit/test_fs_ops.py
unit/test_files_tool.py
integration/test_files_mcp_routes.py
Why a sibling package (not a sub-module of spark_executor)?
The existing MCP_SERVICES registry is the extension point the codebase was
designed around. Adding a sibling:
- Uses the reserved extension point without bypassing it.
- Keeps the 12 Spark tools and 9 file tools on separate mount paths, so
tools/listfor/spark-executor-mcpis not bloated. - Aligns with the project's stated principle of additive change: 0 existing
files get modified beyond the one-line append in
main.py.
Components
core/path_guard.py — the sandbox
_ROOT: Path # set once at import time from FILES_MCP_ROOT
def _init_root() -> Path:
root = os.environ.get("FILES_MCP_ROOT")
if not root:
raise RuntimeError("FILES_MCP_ROOT env var is required")
p = Path(root).expanduser().resolve()
if not p.is_dir():
raise RuntimeError(f"FILES_MCP_ROOT={root!r} is not an existing directory")
return p
def resolve_and_check(path: str) -> Path:
"""Resolve an arbitrary input path and verify it lies inside the sandbox.
Raises:
ValueError: empty, contains NUL byte, or escapes the root.
"""
if not path:
raise ValueError("path is empty")
if "\x00" in path:
raise ValueError("path contains NUL byte")
p = Path(path).expanduser().resolve()
try:
p.relative_to(_ROOT)
except ValueError:
raise ValueError(f"path escapes sandbox root {_ROOT}: got {p}") from None
return p
def set_root_for_testing(p: Path) -> None:
"""Test-only escape hatch; never call from production code."""
global _ROOT
_ROOT = p
Key properties:
- One
.resolve()call handles~,.,.., symlinks, double slashes. relative_tois the only sandbox check — no whitelist to keep in sync.- A symlink inside the root pointing outside is rejected because
resolve()follows the link before we check. set_root_for_testingis the only mutation point, used by theconftest.pyautouse fixture so each test gets a freshtmp_pathroot.
core/fs_ops.py — pure file operations
All functions take an already-resolved Path (the caller is responsible for
running path_guard.resolve_and_check). This keeps fs_ops trivially
testable — no global state, no side effects beyond the operation it performs.
MAX_BYTES = 1_048_576 # 1 MB cap, mirrors read_job_file / update_job_file
def create(path: Path, content: str, overwrite: bool) -> OperationResult: ...
def read(path: Path) -> dict: ...
def update(path: Path, content: str) -> OperationResult: ...
def delete(path: Path, recursive: bool) -> OperationResult: ...
def list_dir(path: Path, recursive: bool, max_depth: int) -> list[FileEntry]: ...
def stat(path: Path) -> FileEntry: ...
def search(path: Path, pattern: str, recursive: bool) -> list[str]: ...
def move(src: Path, dst: Path, overwrite: bool) -> OperationResult: ...
def copy(src: Path, dst: Path, overwrite: bool) -> OperationResult: ...
Atomic write pattern (used by create and update):
fd, tmp_path = tempfile.mkstemp(dir=path.parent, prefix=".tmp_", suffix=path.suffix)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
os.replace(tmp_path, path) # atomic on same fs
except BaseException:
Path(tmp_path).unlink(missing_ok=True)
raise
tools/files.py — business wrappers
One function per tool, each:
- Logs DEBUG on entry.
- Calls
path_guard.resolve_and_checkon every user-supplied path. - Delegates to
fs_ops. - Returns a Pydantic-serialized dict.
No business logic of its own — same shape as
spark_executor/tools/connections.py.
tools/requests.py — Pydantic body models
One model per route. fastapi-mcp passes tool args as a JSON body, so dict-typed
params (none here, but the pattern matters) need a model, not query params.
Mirrors the pattern in spark_executor/tools/requests.py.
server.py — FastAPI app + routes
9 routes, all POST, each with a summary and a description. The same
exception handlers as spark_executor/server.py:
KeyError→ 404ValueError→ 400FileExistsError→ 409 (new in this service — needed for create / move / copy)
The /health route is intentionally not added: the root app already has
/health. (Linus: don't add a duplicate.)
Data Model
# files_mcp/models.py
from typing import Literal
from pydantic import BaseModel
class FileEntry(BaseModel):
path: str # absolute, resolved, inside sandbox
name: str
type: Literal["file", "directory", "symlink", "other"]
size: int # bytes; 0 for directories
mtime: float # unix seconds
mode: int # st_mode (octal, e.g. 0o644)
class OperationResult(BaseModel):
path: str
status: str # "CREATED" | "UPDATED" | "DELETED" | "MOVED" | "COPIED"
size: int | None # None for delete/move
API Surface
| Tool | Inputs | Returns | Errors |
|---|---|---|---|
create_file |
path, content, overwrite=false |
OperationResult |
409 if exists & !overwrite; 400 if >1MB or outside root |
read_file |
path |
{path, content, size, encoding:"utf-8"} |
404 if not found; 400 if dir / >1MB / non-utf-8 / outside root |
update_file |
path, content |
OperationResult |
404 if not found; 400 if >1MB / outside root |
delete_file |
path, recursive=false |
OperationResult |
404 if not found; 400 if dir & !recursive |
list_dir |
path, recursive=false, max_depth=1 |
{path, entries: [FileEntry]} |
404 if not found; 400 if not dir |
stat |
path |
FileEntry |
404 if not found; 400 if outside root |
search |
path, pattern, recursive=true |
{pattern, matches: [str]} |
404 if root not found; 400 if outside root |
move |
src, dst, overwrite=false |
OperationResult |
404 if src missing; 409 if dst exists & !overwrite |
copy |
src, dst, overwrite=false |
OperationResult |
Same as move |
Error Handling
| Exception | HTTP | When |
|---|---|---|
KeyError("path 'X' not found") |
404 | read/update/delete/stat on missing path |
ValueError("path 'X' is not a directory") |
400 | list_dir with a file path |
ValueError("path escapes sandbox") |
400 | path_guard rejects |
ValueError("file 'X' already exists") |
409 | create / move / copy with dst existing and overwrite=False |
ValueError("content too large") |
400 | write > 1 MB |
ValueError("not valid utf-8") |
400 | read encounters non-utf-8 bytes |
FileExistsError |
409 | (extra handler) atomic rename target exists |
IsADirectoryError / NotADirectoryError |
400 | operation type mismatches path type |
Other OSError |
500 | real I/O failure (EACCES, ENOSPC, etc.) |
The FileExistsError handler is new (spark_executor has only the first
two). It is registered on files_mcp.server.app, not on the root app, so
existing KeyError / ValueError handlers in spark_executor.server are
unaffected.
Configuration
FILES_MCP_ROOT(required env var): absolute path to the sandbox root. Must exist and be a directory at process start, otherwiseRuntimeErrorhalts startup with a clear message. (Mirrors the waySPARK_EXECUTOR_DATA_DIRis documented in the project plan.)- No new dependencies. All operations use stdlib (
pathlib,shutil,tempfile,os,os.replace). - No config file. A single env var is enough; multi-tenant per-user roots is explicitly out of scope.
Integration with main.py
One import, one new entry:
# main.py
from files_mcp import app as files_app
MCP_SERVICES: list[McpService] = [
McpService(name="spark_executor", app=spark_executor_app, mount_path="/spark-executor-mcp"),
McpService(name="files", app=files_app, mount_path="/files-mcp"),
]
The existing lifespan already iterates MCP_SERVICES and calls
init_mcp_server(svc.app).mount_http(...) for each. No edit to the lifespan
function.
Backward Compatibility
- 0 existing tools changed.
- 0 existing tests changed (only
conftest.pygains a new autouse fixture for the files_mcp tests; spark_executor tests are unaffected). - 0 new dependencies.
- API change is purely additive: the new
/files-mcpendpoint exists;/spark-executor-mcpis byte-for-byte identical. - The existing
GUNICORN_WORKERS=1warning now covers both mounts, because both share the root app process. No edit togunicorn.conf.pyis needed — the comment block there already says the warning is process-wide.
Testing
tests/unit/test_path_guard.py — 7 cases
tmp/a.txt→ accept, resolved equals expected.../etc/passwd→ reject./etc/passwd(root not/) → reject.tmp/../etc/passwd→ reject.~/outside(root not under home) → reject.- symlink
tmp/link → /etc/passwd→ reject (resolve follows link). - empty / NUL → reject.
tests/unit/test_fs_ops.py — ~10 cases
- create: success / overwrite=True / overwrite=False + existing →
FileExistsError. - read: success / directory path →
ValueError/ >1MB →ValueError/ non-utf-8 bytes →ValueError. - update: success / missing →
KeyError. - delete: success / missing →
KeyError/ dir without recursive →ValueError. - list_dir: single / recursive / depth cap.
- search: simple glob / recursive / zero matches.
- move/copy: same-fs rename / cross-fs (non-atomic but allowed) / dst exists →
FileExistsError.
tests/unit/test_files_tool.py — ~6 cases
- Business function calls
path_guard; escape attempt →ValueError. - DEBUG log line emitted.
- Returns Pydantic-serialized dict.
tests/integration/test_files_mcp_routes.py — ~12 cases
TestClient(files_app), one happy-path POST per tool (9), plus error
coverage: 404 (read missing), 400 (escape), 400 (>1MB), 409 (create hit
existing). Mirrors tests/integration/test_mcp_routes.py style for
spark_executor.
tests/conftest.py addition
@pytest.fixture(autouse=True)
def _isolate_files_root(monkeypatch, tmp_path):
"""Point files_mcp at a fresh tmp_path for every test."""
monkeypatch.setenv("FILES_MCP_ROOT", str(tmp_path))
from files_mcp.core import path_guard
path_guard.set_root_for_testing(tmp_path.resolve())
yield
Total expected: existing 242 tests + ~36 new, full suite under 5 s.
Risks
- Symlink-based escape. Mitigated by
.resolve()following the link beforerelative_toruns. Verified by the symlink test case. - Write race between agents. Two writers calling
create_filefor the same path in the same process can still both succeed (each holds a differenttempfile); the loser wins theos.replace. This is acceptable for the stated usage (single agent per process) and explicitly called out in Non-Goals. FILES_MCP_ROOTmisconfiguration. If the env var points at/, any path is "inside" the sandbox. The startup-time check that it is a directory is the only guard. Document in the README that operators must choose a narrow root.- Pydantic model drift. Mirrors existing patterns, low risk.
- MCP session affinity. New endpoint shares the existing constraint
(
GUNICORN_WORKERS=1). The existinggunicorn.conf.pywarning already fires for any workers > 1 regardless of which mount the request targets.
Open Questions
None — all five clarifying questions resolved before this spec was written
(scope=sandbox; ops=CRUD+list+stat+search+move+copy; content=utf-8 only;
write=atomic+strict create/update; naming=feat/files-mcp + /files-mcp +
files_mcp/).
Implementation Order (preview)
This is a preview only — the full plan comes from the writing-plans skill.
files_mcp/__init__.py+core/path_guard.py+tests/unit/test_path_guard.pyfiles_mcp/core/fs_ops.py+tests/unit/test_fs_ops.pyfiles_mcp/models.py+tools/requests.py+tools/files.pytests/unit/test_files_tool.py
files_mcp/server.py+tests/integration/test_files_mcp_routes.pymain.pyone-line appendtests/conftest.pyautouse fixture- Run full suite; commit.
Acceptance Criteria
uv run pytestpasses (existing 242 + new ~36).uv run main.pystarts cleanly withFILES_MCP_ROOTset; fails with a clearRuntimeErrorif it is unset or points at a non-directory.tools/listagainst/files-mcpreturns 9 tools with their descriptions.- End-to-end smoke: a
TestClientPOST tocreate_filefollowed byread_filereturns the same content. git diff mainshows changes only in: newfiles_mcp/package, new tests, the one-line append inmain.py, and the conftest fixture addition.- No new top-level dependencies in
pyproject.toml.