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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user