Adds files_mcp/tools/requests.py with 9 Pydantic body models (one per tool, since fastapi-mcp passes args as JSON body) and files_mcp/tools/files.py with 9 thin wrappers that call path_guard then fs_ops and return model_dump() form. Adds tests/unit/test_files_tool.py covering happy path, sandbox escape, and KeyError/FileExistsError propagation for each wrapper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
117 lines
4.2 KiB
Python
117 lines
4.2 KiB
Python
# coding=utf-8
|
|
"""
|
|
@Time :2026/6/30
|
|
@Author :tao.chen
|
|
"""
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from files_mcp.core import path_guard
|
|
from files_mcp.tools import files
|
|
|
|
|
|
@pytest.fixture
|
|
def sandbox_root(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("FILES_MCP_ROOT", str(tmp_path))
|
|
path_guard.set_root_for_testing(tmp_path.resolve())
|
|
return tmp_path.resolve()
|
|
|
|
|
|
class TestCreateFile:
|
|
def test_creates_file(self, sandbox_root: Path):
|
|
result = files.create_file(path="new.txt", content="hello")
|
|
assert result["status"] == "CREATED"
|
|
assert (sandbox_root / "new.txt").read_text(encoding="utf-8") == "hello"
|
|
|
|
def test_escape_raises_value_error(self, sandbox_root: Path):
|
|
with pytest.raises(ValueError, match="escapes sandbox"):
|
|
files.create_file(path="../escape.txt", content="x")
|
|
|
|
def test_existing_raises_file_exists(self, sandbox_root: Path):
|
|
(sandbox_root / "f.txt").write_text("old", encoding="utf-8")
|
|
with pytest.raises(FileExistsError):
|
|
files.create_file(path="f.txt", content="new", overwrite=False)
|
|
|
|
|
|
class TestReadFile:
|
|
def test_reads_existing(self, sandbox_root: Path):
|
|
(sandbox_root / "f.txt").write_text("hi", encoding="utf-8")
|
|
out = files.read_file(path="f.txt")
|
|
assert out["content"] == "hi"
|
|
assert out["encoding"] == "utf-8"
|
|
|
|
def test_escape_raises_value_error(self, sandbox_root: Path):
|
|
with pytest.raises(ValueError, match="escapes sandbox"):
|
|
files.read_file(path="/etc/passwd")
|
|
|
|
|
|
class TestUpdateFile:
|
|
def test_updates_existing(self, sandbox_root: Path):
|
|
(sandbox_root / "f.txt").write_text("old", encoding="utf-8")
|
|
result = files.update_file(path="f.txt", content="new")
|
|
assert result["status"] == "UPDATED"
|
|
assert (sandbox_root / "f.txt").read_text(encoding="utf-8") == "new"
|
|
|
|
def test_missing_raises_keyerror(self, sandbox_root: Path):
|
|
with pytest.raises(KeyError, match="not found"):
|
|
files.update_file(path="nope.txt", content="x")
|
|
|
|
|
|
class TestDeleteFile:
|
|
def test_deletes_file(self, sandbox_root: Path):
|
|
(sandbox_root / "f.txt").write_text("x", encoding="utf-8")
|
|
result = files.delete_file(path="f.txt")
|
|
assert result["status"] == "DELETED"
|
|
|
|
|
|
class TestListDir:
|
|
def test_returns_entries(self, sandbox_root: Path):
|
|
(sandbox_root / "a.txt").write_text("a", encoding="utf-8")
|
|
(sandbox_root / "b").mkdir()
|
|
out = files.list_dir(path=".", recursive=False, max_depth=1)
|
|
assert out["path"] == str(sandbox_root)
|
|
names = sorted(e["name"] for e in out["entries"])
|
|
assert names == ["a.txt", "b"]
|
|
|
|
|
|
class TestStat:
|
|
def test_returns_file_entry(self, sandbox_root: Path):
|
|
(sandbox_root / "f.txt").write_text("hi", encoding="utf-8")
|
|
out = files.stat(path="f.txt")
|
|
assert out["name"] == "f.txt"
|
|
assert out["type"] == "file"
|
|
|
|
|
|
class TestSearch:
|
|
def test_finds_matches(self, sandbox_root: Path):
|
|
(sandbox_root / "a.py").write_text("a", encoding="utf-8")
|
|
(sandbox_root / "b.txt").write_text("b", encoding="utf-8")
|
|
out = files.search(path=".", pattern="*.py", recursive=False)
|
|
assert out["pattern"] == "*.py"
|
|
assert len(out["matches"]) == 1
|
|
assert out["matches"][0].endswith("a.py")
|
|
|
|
|
|
class TestMove:
|
|
def test_moves_file(self, sandbox_root: Path):
|
|
(sandbox_root / "a.txt").write_text("x", encoding="utf-8")
|
|
result = files.move(src="a.txt", dst="b.txt")
|
|
assert result["status"] == "MOVED"
|
|
assert not (sandbox_root / "a.txt").exists()
|
|
assert (sandbox_root / "b.txt").exists()
|
|
|
|
def test_escape_on_dst_raises(self, sandbox_root: Path):
|
|
(sandbox_root / "a.txt").write_text("x", encoding="utf-8")
|
|
with pytest.raises(ValueError, match="escapes sandbox"):
|
|
files.move(src="a.txt", dst="../escape.txt")
|
|
|
|
|
|
class TestCopy:
|
|
def test_copies_file(self, sandbox_root: Path):
|
|
(sandbox_root / "a.txt").write_text("x", encoding="utf-8")
|
|
result = files.copy(src="a.txt", dst="b.txt")
|
|
assert result["status"] == "COPIED"
|
|
assert (sandbox_root / "a.txt").exists() # source unchanged
|
|
assert (sandbox_root / "b.txt").read_text(encoding="utf-8") == "x"
|