Files
mcp-server/files_mcp/server.py
T
ClaudeandClaude Fable 5 8c110329b2 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>
2026-06-30 18:09:35 +08:00

186 lines
5.2 KiB
Python

# 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)