diff --git a/files_mcp/server.py b/files_mcp/server.py new file mode 100644 index 0000000..8c5d70a --- /dev/null +++ b/files_mcp/server.py @@ -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) diff --git a/tests/integration/test_files_mcp_routes.py b/tests/integration/test_files_mcp_routes.py new file mode 100644 index 0000000..94d6902 --- /dev/null +++ b/tests/integration/test_files_mcp_routes.py @@ -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"