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
+5 -1
View File
@@ -90,6 +90,10 @@ src/mcp_synology_filestation/
## Implemented Tools
_(none yet — pending implementation approval)_
| Tool | Description |
|------|-------------|
| `list_shares` | List all shared folders with volume usage |
| `list_dir` | List directory contents with pagination and sorting |
| `get_info` | Get detailed metadata for one or more paths |
See [SPEC.md](SPEC.md) for the full planned tool set.
+12 -8
View File
@@ -54,8 +54,7 @@ All requests use `GET /webapi/entry.cgi` with query parameters unless noted.
|-----|---------|--------------|
| `SYNO.API.Auth` | 7 | `login`, `logout` |
| `SYNO.FileStation.Info` | 2 | `get` |
| `SYNO.FileStation.List` | 2 | `list_share`, `list` |
| `SYNO.FileStation.Stat` | 3 | `get` |
| `SYNO.FileStation.List` | 2 | `list_share`, `list`, `getinfo` |
| `SYNO.FileStation.Search` | 2 | `start`, `list`, `stop`, `clean` |
| `SYNO.FileStation.Download` | 2 | `download` |
| `SYNO.FileStation.Upload` | 3 | `upload` (POST multipart) |
@@ -123,8 +122,8 @@ additional=["size","time"]
> (`"size,time"`) is silently ignored by DSM — the `additional` field will be absent from
> every file entry.
>
> **`SYNO.FileStation.Stat`:** Not available on all NAS firmware versions. Do not use for
> `get_info`; fall back to `SYNO.FileStation.List::list` with `additional=["size","time","perm","owner"]`.
> **`SYNO.FileStation.Stat`:** Not available on this NAS's API registry. Use
> `SYNO.FileStation.List::getinfo` for per-path metadata instead.
---
@@ -134,16 +133,21 @@ Get detailed metadata for one or more files or folders.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `path` | str or list[str] | yes | Absolute path(s) on the NAS |
| `path` | str | yes | One or more share-relative paths, comma-separated |
**Returns:** Per-path details: type, size, owner, group, permissions, timestamps, real path.
**Returns:** Table with type, size, owner, group, permissions (octal), modification time,
creation time, and real volume path for each requested item.
**DSM call:** `SYNO.FileStation.Stat::get`
**DSM call:** `SYNO.FileStation.List::getinfo`
```
path={comma-joined paths},
additional=["real_path","size","time","perm","owner","mount_point_type","type"]
additional=["real_path","size","time","perm","owner","type"]
```
> **Note:** `SYNO.FileStation.Stat` is not available on all NAS firmware versions and is
> absent from this NAS's API registry. `SYNO.FileStation.List::getinfo` returns identical
> data and is confirmed working.
---
#### `search`
@@ -211,3 +211,104 @@ def register_filestation(
lines.append(f"Use offset={end} to fetch the next page.")
return "\n".join(lines)
@mcp.tool()
async def get_info(path: str) -> str:
"""Get detailed metadata for one or more files or folders on the NAS.
Accepts a single path or a comma-separated list of paths.
Use share paths as returned by list_shares (e.g. "/dev/file.txt").
Args:
path: One or more share-relative paths, comma-separated
(e.g. "/dev/notes.txt" or "/dev/notes.txt,/data/photo.jpg").
Returns:
Formatted table with type, size, owner, permissions, and timestamps
for each requested path.
"""
from mcp_synology_filestation.client import SynologyError
paths = [p.strip() for p in path.split(",") if p.strip()]
if not paths:
return "Error: no path provided."
try:
data = await client.request(
"SYNO.FileStation.List",
"getinfo",
params={
"path": ",".join(paths),
"additional": json.dumps(
["real_path", "size", "time", "perm", "owner", "type"]
),
},
)
except SynologyError as e:
return f"Error: {e}"
files: list[dict] = data.get("files", [])
if not files:
return "No information returned for the given path(s)."
rows = []
for f in files:
item_path = f.get("path") or f.get("name", "")
is_dir = f.get("isdir", False)
ftype = "dir" if is_dir else "file"
add = f.get("additional", {})
size_str = "-" if is_dir else _fmt_size(add.get("size"))
mtime_str = _fmt_time((add.get("time") or {}).get("mtime"))
crtime_str = _fmt_time((add.get("time") or {}).get("crtime"))
owner_info = add.get("owner") or {}
owner = owner_info.get("user", "-")
group = owner_info.get("group", "-")
perm_info = add.get("perm") or {}
posix = perm_info.get("posix", 0)
perm_str = oct(posix)[2:] if posix else "-"
real_path = (add.get("real_path") or "").strip() or "-"
rows.append(
(
item_path,
ftype,
size_str,
owner,
group,
perm_str,
mtime_str,
crtime_str,
real_path,
)
)
headers = (
"Path",
"Type",
"Size",
"Owner",
"Group",
"Perm",
"Modified",
"Created",
"Real path",
)
col_widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)]
def _sep() -> str:
return "+" + "+".join("-" * (w + 2) for w in col_widths) + "+"
def _row(vals: tuple[str, ...]) -> str:
return "| " + " | ".join(f"{v:<{col_widths[i]}}" for i, v in enumerate(vals)) + " |"
lines = [_sep(), _row(headers), _sep()]
for r in rows:
lines.append(_row(r))
lines.append(_sep())
lines.append(f"\n{len(rows)} item(s).")
return "\n".join(lines)
+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"