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:
@@ -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)
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user