feat: add get_info tool using SYNO.FileStation.List::getinfo

SYNO.FileStation.Stat is absent from this NAS's API registry.
SYNO.FileStation.List::getinfo returns identical data and is confirmed
working.

- tools/filestation.py: new get_info tool — accepts one or more
  comma-separated paths, calls getinfo with real_path/size/time/perm/
  owner/type additional fields, returns a 9-column table
- tests: 6 new tests covering single file, directory, multi-path,
  empty input, DSM error, and correct API method assertion
- SPEC.md: remove SYNO.FileStation.Stat from API table, rewrite get_info
  tool section to reference getinfo, update list_dir note
- CLAUDE.md: update Implemented Tools list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 09:28:04 +02:00
parent 8fc2f731ce
commit 014af1aefe
4 changed files with 280 additions and 9 deletions
+162
View File
@@ -251,3 +251,165 @@ async def test_list_dir_dsm_error(config: AppConfig) -> None:
assert result.startswith("Error:")
assert "not found" in result.lower()
# ──────────────────────────────────────────────────────────────────────────
# get_info
# ──────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_info_single_file(config: AppConfig) -> None:
"""get_info returns a table with metadata for a single file."""
client = MagicMock()
client.request = AsyncMock(
return_value={
"files": [
{
"path": "/dev/notes.txt",
"name": "notes.txt",
"isdir": False,
"additional": {
"real_path": "/volume1/dev/notes.txt",
"size": 1024,
"time": {"mtime": 1700000000, "crtime": 1690000000},
"owner": {"user": "marcus", "group": "users"},
"perm": {"posix": 0o644},
},
}
]
}
)
tools = _make_mcp_and_tools(config, client)
result = await tools["get_info"](path="/dev/notes.txt")
assert "/dev/notes.txt" in result
assert "file" in result
assert "1 KB" in result or "1024 B" in result
assert "marcus" in result
assert "users" in result
assert "644" in result
assert "/volume1/dev/notes.txt" in result
assert "1 item(s)" in result
@pytest.mark.asyncio
async def test_get_info_directory(config: AppConfig) -> None:
"""get_info shows '-' for size of a directory."""
client = MagicMock()
client.request = AsyncMock(
return_value={
"files": [
{
"path": "/dev",
"name": "dev",
"isdir": True,
"additional": {
"real_path": "/volume1/dev",
"size": 0,
"time": {"mtime": 1700000000, "crtime": 1690000000},
"owner": {"user": "marcus", "group": "users"},
"perm": {"posix": 0o755},
},
}
]
}
)
tools = _make_mcp_and_tools(config, client)
result = await tools["get_info"](path="/dev")
assert "dir" in result
assert "755" in result
# Size for directory should be "-"
rows = [line for line in result.splitlines() if "/dev" in line and "|" in line]
assert rows, "expected a data row containing /dev"
size_col = rows[0].split("|")[3].strip()
assert size_col == "-"
@pytest.mark.asyncio
async def test_get_info_multiple_paths(config: AppConfig) -> None:
"""get_info handles comma-separated paths and returns one row per item."""
client = MagicMock()
client.request = AsyncMock(
return_value={
"files": [
{
"path": "/dev/a.txt",
"name": "a.txt",
"isdir": False,
"additional": {
"size": 100,
"time": {"mtime": 1700000000, "crtime": 1690000000},
"owner": {"user": "marcus", "group": "users"},
"perm": {"posix": 0o644},
},
},
{
"path": "/data/b.txt",
"name": "b.txt",
"isdir": False,
"additional": {
"size": 200,
"time": {"mtime": 1700000001, "crtime": 1690000001},
"owner": {"user": "marcus", "group": "users"},
"perm": {"posix": 0o644},
},
},
]
}
)
tools = _make_mcp_and_tools(config, client)
result = await tools["get_info"](path="/dev/a.txt,/data/b.txt")
assert "/dev/a.txt" in result
assert "/data/b.txt" in result
assert "2 item(s)" in result
# Verify the API received both paths as a comma-joined string
call_params = client.request.call_args[1]["params"]
assert call_params["path"] == "/dev/a.txt,/data/b.txt"
@pytest.mark.asyncio
async def test_get_info_empty_path(config: AppConfig) -> None:
"""get_info returns Error: when path is empty."""
client = MagicMock()
client.request = AsyncMock()
tools = _make_mcp_and_tools(config, client)
result = await tools["get_info"](path=" ")
assert result.startswith("Error:")
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_get_info_dsm_error(config: AppConfig) -> None:
"""get_info returns Error: on SynologyError."""
client = MagicMock()
client.request = AsyncMock(side_effect=SynologyError("File or folder not found", code=1800))
tools = _make_mcp_and_tools(config, client)
result = await tools["get_info"](path="/dev/missing.txt")
assert result.startswith("Error:")
assert "not found" in result.lower()
@pytest.mark.asyncio
async def test_get_info_uses_getinfo_method(config: AppConfig) -> None:
"""get_info calls SYNO.FileStation.List with method='getinfo'."""
client = MagicMock()
client.request = AsyncMock(return_value={"files": []})
tools = _make_mcp_and_tools(config, client)
await tools["get_info"](path="/dev/file.txt")
client.request.assert_called_once()
call_args = client.request.call_args
assert call_args[0][0] == "SYNO.FileStation.List"
assert call_args[0][1] == "getinfo"