diff --git a/CLAUDE.md b/CLAUDE.md index 7fbdb0d..266555e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/SPEC.md b/SPEC.md index 2892692..e3899ed 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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` diff --git a/src/mcp_synology_filestation/tools/filestation.py b/src/mcp_synology_filestation/tools/filestation.py index fa3b4f9..9e4df5a 100644 --- a/src/mcp_synology_filestation/tools/filestation.py +++ b/src/mcp_synology_filestation/tools/filestation.py @@ -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) diff --git a/tests/test_tools_filestation.py b/tests/test_tools_filestation.py index 606f4bb..2834576 100644 --- a/tests/test_tools_filestation.py +++ b/tests/test_tools_filestation.py @@ -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"