feat(files-mcp): FastAPI app with 9 tool routes and integration tests

Adds files_mcp/server.py (FastAPI title=Files MCP, 9 POST routes, and
3 exception handlers: KeyError->404, ValueError->400, FileExistsError->409).
The third handler is the only one not present in spark_executor.server;
handlers are registered on this app only, so spark_executor is unaffected.

Adds tests/integration/test_files_mcp_routes.py with 12 end-to-end HTTP
tests covering CRUD happy paths, error codes (404/400/409), and
introspection (list_dir, stat, search, move, copy).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-30 18:09:35 +08:00
co-authored by Claude Fable 5
parent 36da3f7024
commit 8c110329b2
2 changed files with 326 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
# coding=utf-8
"""
@Time :2026/6/30
@Author :tao.chen
"""
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from files_mcp.tools.files import (
copy as copy_file,
create_file,
delete_file,
list_dir,
move as move_file,
read_file,
search,
stat,
update_file,
)
from files_mcp.tools.requests import (
CopyRequest,
CreateFileRequest,
DeleteFileRequest,
ListDirRequest,
MoveRequest,
ReadFileRequest,
SearchRequest,
StatRequest,
UpdateFileRequest,
)
app = FastAPI(
title="Files MCP",
version="0.0.1",
description="Files MCP Server — sandboxed file-system CRUD over fastapi-mcp",
)
# --- Exception handlers ---
# KeyError -> 404 (path not found)
# ValueError -> 400 (bad input: escape, non-utf-8, too large, wrong type)
# FileExistsError -> 409 (create/move/copy hit existing dst without overwrite)
#
# Only the third handler is new here; the first two mirror the
# spark_executor.server convention. They live on this app only, so the
# spark_executor handlers are unaffected.
@app.exception_handler(KeyError)
async def _keyerror_handler(_request: Request, exc: KeyError) -> JSONResponse:
return JSONResponse(status_code=404, content={"detail": str(exc)})
@app.exception_handler(ValueError)
async def _valueerror_handler(_request: Request, exc: ValueError) -> JSONResponse:
return JSONResponse(status_code=400, content={"detail": str(exc)})
@app.exception_handler(FileExistsError)
async def _fileexists_handler(_request: Request, exc: FileExistsError) -> JSONResponse:
return JSONResponse(status_code=409, content={"detail": str(exc)})
# --- Routes ---
@app.post(
"/create_file",
operation_id="create_file",
summary="Create a new text file inside the sandbox",
description=(
"Writes `content` to `path` (resolved against FILES_MCP_ROOT). "
"Atomic write via tempfile + os.replace. Default fails with 409 "
"if the file already exists; pass overwrite=true to replace. "
"Content is capped at 1 MB. utf-8 only."
),
)
def _create_file(req: CreateFileRequest):
return create_file(path=req.path, content=req.content, overwrite=req.overwrite)
@app.post(
"/read_file",
operation_id="read_file",
summary="Read a text file inside the sandbox",
description=(
"Returns the full utf-8 content. Files larger than 1 MB and "
"non-utf-8 files are rejected with 400. Directories are rejected "
"with 400; use list_dir instead."
),
)
def _read_file(req: ReadFileRequest):
return read_file(path=req.path)
@app.post(
"/update_file",
operation_id="update_file",
summary="Overwrite an existing text file inside the sandbox",
description=(
"Replaces the file in full. 404 if the path does not exist. "
"Atomic write, 1 MB cap, utf-8 only."
),
)
def _update_file(req: UpdateFileRequest):
return update_file(path=req.path, content=req.content)
@app.post(
"/delete_file",
operation_id="delete_file",
summary="Delete a file or empty directory",
description=(
"404 if the path does not exist. To delete a non-empty directory, "
"pass recursive=true."
),
)
def _delete_file(req: DeleteFileRequest):
return delete_file(path=req.path, recursive=req.recursive)
@app.post(
"/list_dir",
operation_id="list_dir",
summary="List entries in a directory",
description=(
"Returns a list of FileEntry records. recursive=false lists only "
"the directory's direct children; recursive=true descends up to "
"max_depth levels (0 = the directory itself, 1 = one level deep)."
),
)
def _list_dir(req: ListDirRequest):
return list_dir(path=req.path, recursive=req.recursive, max_depth=req.max_depth)
@app.post(
"/stat",
operation_id="stat",
summary="Get a single FileEntry (path, type, size, mtime, mode)",
description="404 if the path does not exist.",
)
def _stat(req: StatRequest):
return stat(path=req.path)
@app.post(
"/search",
operation_id="search",
summary="Find paths matching a glob pattern",
description=(
"Returns the absolute paths of entries inside `path` whose name "
"matches the glob `pattern`. recursive=true descends into "
"subdirectories; recursive=false matches the directory's direct "
"children only."
),
)
def _search(req: SearchRequest):
return search(path=req.path, pattern=req.pattern, recursive=req.recursive)
@app.post(
"/move",
operation_id="move_file",
summary="Rename or move a file or directory",
description=(
"404 if src does not exist. 409 if dst exists and overwrite=false. "
"Cross-filesystem moves are not atomic."
),
)
def _move(req: MoveRequest):
return move_file(src=req.src, dst=req.dst, overwrite=req.overwrite)
@app.post(
"/copy",
operation_id="copy_file",
summary="Copy a file or directory",
description=(
"404 if src does not exist. 409 if dst exists and overwrite=false. "
"For files, metadata is preserved (shutil.copy2). For directories, "
"the entire tree is copied (shutil.copytree)."
),
)
def _copy(req: CopyRequest):
return copy_file(src=req.src, dst=req.dst, overwrite=req.overwrite)
+141
View File
@@ -0,0 +1,141 @@
# coding=utf-8
"""
@Time :2026/6/30
@Author :tao.chen
End-to-end HTTP tests for files_mcp.server.app. Uses FastAPI TestClient
directly (no MCP transport) — same pattern as
tests/integration/test_mcp_routes.py.
"""
from fastapi.testclient import TestClient
from files_mcp.core import path_guard
from files_mcp.server import app
def _client(tmp_path) -> TestClient:
import os
os.environ["FILES_MCP_ROOT"] = str(tmp_path)
path_guard.set_root_for_testing(tmp_path.resolve())
return TestClient(app)
class TestCrud:
def test_create_then_read(self, tmp_path):
with _client(tmp_path) as c:
r = c.post("/create_file", json={"path": "a.txt", "content": "hello"})
assert r.status_code == 200
assert r.json() == {"path": str(tmp_path / "a.txt"), "status": "CREATED", "size": 5}
r = c.post("/read_file", json={"path": "a.txt"})
assert r.status_code == 200
assert r.json()["content"] == "hello"
def test_update_existing(self, tmp_path):
with _client(tmp_path) as c:
c.post("/create_file", json={"path": "a.txt", "content": "old"})
r = c.post("/update_file", json={"path": "a.txt", "content": "new"})
assert r.status_code == 200
assert r.json()["status"] == "UPDATED"
r = c.post("/read_file", json={"path": "a.txt"})
assert r.json()["content"] == "new"
def test_delete(self, tmp_path):
with _client(tmp_path) as c:
c.post("/create_file", json={"path": "a.txt", "content": "x"})
r = c.post("/delete_file", json={"path": "a.txt"})
assert r.status_code == 200
assert r.json()["status"] == "DELETED"
r = c.post("/read_file", json={"path": "a.txt"})
assert r.status_code == 404
class TestErrors:
def test_create_existing_returns_409(self, tmp_path):
with _client(tmp_path) as c:
c.post("/create_file", json={"path": "a.txt", "content": "x"})
r = c.post("/create_file", json={"path": "a.txt", "content": "y"})
assert r.status_code == 409
def test_update_missing_returns_404(self, tmp_path):
with _client(tmp_path) as c:
r = c.post("/update_file", json={"path": "nope.txt", "content": "x"})
assert r.status_code == 404
def test_escape_returns_400(self, tmp_path):
with _client(tmp_path) as c:
r = c.post("/read_file", json={"path": "/etc/passwd"})
assert r.status_code == 400
assert "escapes sandbox" in r.json()["detail"]
def test_too_large_returns_400(self, tmp_path):
with _client(tmp_path) as c:
r = c.post(
"/create_file",
json={"path": "big.txt", "content": "x" * (1024 * 1024 + 1)},
)
assert r.status_code == 400
assert "too large" in r.json()["detail"]
def test_non_utf8_returns_400(self, tmp_path):
with _client(tmp_path) as c:
(tmp_path / "bin").write_bytes(b"\xff\xfe\x00")
r = c.post("/read_file", json={"path": "bin"})
assert r.status_code == 400
assert "utf-8" in r.json()["detail"]
class TestIntrospection:
def test_list_dir(self, tmp_path):
with _client(tmp_path) as c:
(tmp_path / "a.txt").write_text("a", encoding="utf-8")
(tmp_path / "b").mkdir()
r = c.post("/list_dir", json={"path": ".", "recursive": False, "max_depth": 1})
assert r.status_code == 200
names = sorted(e["name"] for e in r.json()["entries"])
assert names == ["a.txt", "b"]
def test_stat(self, tmp_path):
with _client(tmp_path) as c:
(tmp_path / "f.txt").write_text("hi", encoding="utf-8")
r = c.post("/stat", json={"path": "f.txt"})
assert r.status_code == 200
assert r.json()["type"] == "file"
assert r.json()["size"] == 2
def test_search(self, tmp_path):
with _client(tmp_path) as c:
(tmp_path / "a.py").write_text("a", encoding="utf-8")
(tmp_path / "b.txt").write_text("b", encoding="utf-8")
r = c.post("/search", json={"path": ".", "pattern": "*.py", "recursive": False})
assert r.status_code == 200
assert len(r.json()["matches"]) == 1
class TestMoveCopy:
def test_move(self, tmp_path):
with _client(tmp_path) as c:
c.post("/create_file", json={"path": "a.txt", "content": "x"})
r = c.post("/move", json={"src": "a.txt", "dst": "b.txt"})
assert r.status_code == 200
assert r.json()["status"] == "MOVED"
r = c.post("/read_file", json={"path": "b.txt"})
assert r.json()["content"] == "x"
def test_move_existing_dst_409(self, tmp_path):
with _client(tmp_path) as c:
c.post("/create_file", json={"path": "a.txt", "content": "x"})
c.post("/create_file", json={"path": "b.txt", "content": "y"})
r = c.post("/move", json={"src": "a.txt", "dst": "b.txt"})
assert r.status_code == 409
def test_copy(self, tmp_path):
with _client(tmp_path) as c:
c.post("/create_file", json={"path": "a.txt", "content": "x"})
r = c.post("/copy", json={"src": "a.txt", "dst": "b.txt"})
assert r.status_code == 200
assert r.json()["status"] == "COPIED"
# source unchanged
r = c.post("/read_file", json={"path": "a.txt"})
assert r.json()["content"] == "x"
r = c.post("/read_file", json={"path": "b.txt"})
assert r.json()["content"] == "x"