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>
186 lines
5.2 KiB
Python
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)
|