diff --git a/CLAUDE.md b/CLAUDE.md index 9803984..d4a7dce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,7 +146,7 @@ src/mcp_synology_filestation/ └── filestation.py # register_filestation(mcp, config, client) ``` -## Implemented Tools (v0.2.10 — 20 tools) +## Implemented Tools (v0.3.4 — 26 tools) | Tool | Description | |-----------------------|--------------------------------------------------------------| @@ -170,17 +170,24 @@ src/mcp_synology_filestation/ | `create_sharing_link` | Create a public sharing link (optional password + expiry) | | `list_sharing_links` | List all sharing links (paginated table) | | `delete_sharing_link` | Delete a sharing link by ID | +| `get_thumbnail` | Fetch a thumbnail for an image/video file as base64 | +| `list_favorites` | List all FileStation user favorites | +| `add_favorite` | Pin a path as a FileStation favorite | +| `delete_favorite` | Remove a path from FileStation favorites | +| `background_tasks` | List FileStation background tasks (copy, move, delete, etc.) | +| `list_snapshots` | List Btrfs snapshots for a share (requires Btrfs volume) | See [SPEC.md](SPEC.md) for full tool specifications and DSM call details. +## DSM Quirks (continued) + +- **`SYNO.FileStation.Snapshot::list`:** Returns error 400 when the share is not on a + Btrfs-formatted volume. Confirmed: this NAS has no Btrfs volumes — `list_snapshots` + maps error 400 to a clear "requires Btrfs-formatted volume" message. +- **`SYNO.FileStation.BackgroundTask`:** Only the `list` method is available (v1–v3). + No stop/cancel/clear methods exist on this firmware. + ## Roadmap -### v0.3 — next -| Tool | API | -|--------------------|----------------------------------| -| `get_thumbnail` | `SYNO.FileStation.Thumb` | -| `list_favorites` | `SYNO.FileStation.Favorite` | -| `add_favorite` | `SYNO.FileStation.Favorite` | -| `delete_favorite` | `SYNO.FileStation.Favorite` | -| `list_snapshots` | `SYNO.FileStation.Snapshot` | -| `background_tasks` | `SYNO.FileStation.BackgroundTask`| +### v0.4 — candidates +No further tools currently planned. diff --git a/SPEC.md b/SPEC.md index 5317d49..f2e8b7c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -97,7 +97,7 @@ regardless of service state. --- -## Tools (v0.2.10 — 20 tools) +## Tools (v0.3.4 — 26 tools) ### Read-only @@ -134,6 +134,22 @@ regardless of service state. | `list_sharing_links` | List all sharing links (paginated table) | | `delete_sharing_link` | Delete a sharing link by ID | +### Thumbnail & Favorites + +| Tool | Description | +|------|-------------| +| `get_thumbnail` | Fetch a thumbnail for an image/video file as base64 | +| `list_favorites` | List all FileStation user favorites | +| `add_favorite` | Add a path to FileStation favorites | +| `delete_favorite` | Remove a path from FileStation favorites | + +### Background Tasks & Snapshots + +| Tool | Description | +|------|-------------| +| `background_tasks` | List FileStation background tasks (copy, move, delete, etc.) | +| `list_snapshots` | List Btrfs snapshots for a shared folder (requires Btrfs volume) | + --- ### Tool details @@ -439,6 +455,96 @@ pagination hint when more results are available. --- +#### `get_thumbnail` +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `path` | str | yes | — | Share-relative path to the image or video file | +| `size` | str | no | `large` | `small`, `medium`, `large`, or `original` | + +**Returns:** JSON `{filename, size_bytes, content_base64}` (JPEG bytes encoded as base64). + +**DSM call:** `SYNO.FileStation.Thumb::get` (POST, v2) + +> DSM returns image bytes directly when the file has a thumbnail. Non-image content-type +> indicates a DSM error envelope — the tool parses and surfaces the error code. + +--- + +#### `list_favorites` +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `offset` | int | no | 0 | Pagination offset | +| `limit` | int | no | 200 | Max items to return | + +**Returns:** Table with name, path, type, status, real path, modified time. + +**DSM call:** `SYNO.FileStation.Favorite::list` (v2, `additional=["real_path","size","time"]`) + +--- + +#### `add_favorite` +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `path` | str | yes | Share-relative path to pin | +| `name` | str | yes | Display label for the favorite | + +**Returns:** `"Added favorite '{name}' → {path}"` or error. + +**DSM call:** `SYNO.FileStation.Favorite::add` (v2, `index=-1`) + +--- + +#### `delete_favorite` +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `path` | str | yes | Share-relative path of the favorite to remove | + +**Returns:** `"Deleted favorite for {path}"` or error. + +**DSM call:** `SYNO.FileStation.Favorite::delete` (v2) + +--- + +#### `background_tasks` +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `offset` | int | no | 0 | Pagination offset | +| `limit` | int | no | 100 | Max tasks to return (capped at 200) | + +**Returns:** Table with task ID, type, status, path, and file progress. Includes total count +and a pagination hint when more results are available. + +**DSM call:** `SYNO.FileStation.BackgroundTask::list` (v3) + +> Only the `list` method is available on this NAS — tasks cannot be stopped or cleared +> via this API. + +--- + +#### `list_snapshots` +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `share_path` | str | yes | — | Share-relative root path (e.g. `/docker`) | +| `offset` | int | no | 0 | Pagination offset | +| `limit` | int | no | 100 | Max snapshots to return (capped at 500) | + +**Returns:** Table with snapshot ID, creation time, description, and locked flag. Includes +total count and a pagination hint when more results are available. + +**DSM call:** `SYNO.FileStation.Snapshot::list` (v2, `folder_path={share_path}`) + +> **Btrfs required:** DSM returns error 400 when the share is not on a Btrfs volume. +> The tool maps this to a clear message: *"Snapshots not available — requires Btrfs-formatted +> volume."* + +--- + ## Error Handling Strategy ### Principles diff --git a/pyproject.toml b/pyproject.toml index a467910..d8e8203 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-synology-filestation" -version = "0.3.3" +version = "0.3.4" description = "MCP server for Synology FileStation" requires-python = ">=3.12" dependencies = [ diff --git a/src/mcp_synology_filestation/__init__.py b/src/mcp_synology_filestation/__init__.py index 9da8638..cabd3c2 100644 --- a/src/mcp_synology_filestation/__init__.py +++ b/src/mcp_synology_filestation/__init__.py @@ -1,3 +1,3 @@ """MCP server for Synology FileStation.""" -__version__ = "0.3.3" +__version__ = "0.3.4" diff --git a/src/mcp_synology_filestation/tools/filestation.py b/src/mcp_synology_filestation/tools/filestation.py index 8dcfd36..311a283 100644 --- a/src/mcp_synology_filestation/tools/filestation.py +++ b/src/mcp_synology_filestation/tools/filestation.py @@ -1237,3 +1237,119 @@ def register_filestation( return f"Error: {e}" return f"Deleted favorite for {path}" + + # ── background task + snapshot tools ────────────────────────────────── + + @mcp.tool() + async def background_tasks(offset: int = 0, limit: int = 100): + """List FileStation background tasks (copy, move, delete, extract, compress, etc.).""" + from mcp_synology_filestation.client import SynologyError + + limit = max(1, min(limit, 200)) + offset = max(0, offset) + + try: + data = await client.request( + "SYNO.FileStation.BackgroundTask", + "list", + version=3, + params={"offset": offset, "limit": limit}, + ) + except SynologyError as e: + return f"Error: {e}" + + tasks: list[dict] = data.get("tasks", []) + total: int = data.get("total", len(tasks)) + + if not tasks: + return "No background tasks found." + + rows: list[tuple[str, ...]] = [] + for task in tasks: + taskid = str(task.get("taskid", "")) + ttype = str(task.get("type", "")) + status = str(task.get("status", "")) + path = str(task.get("path", task.get("dest_folder_path", ""))) + processed = task.get("processed_num_file", 0) + total_files = task.get("total_num_file", 0) + progress = f"{processed}/{total_files}" if total_files else "-" + rows.append((taskid, ttype, status, path, progress)) + + headers = ("Task ID", "Type", "Status", "Path", "Progress") + 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{total} task(s) total.") + if total > offset + limit: + lines.append( + f"Showing {offset + 1}–{offset + len(tasks)} of {total}. Use offset to page." + ) + + return "\n".join(lines) + + @mcp.tool() + async def list_snapshots(share_path: str, offset: int = 0, limit: int = 100): + """List Btrfs snapshots for a shared folder (requires Btrfs volume).""" + from mcp_synology_filestation.client import SynologyError + + limit = max(1, min(limit, 500)) + offset = max(0, offset) + + try: + data = await client.request( + "SYNO.FileStation.Snapshot", + "list", + version=2, + params={"folder_path": share_path, "offset": offset, "limit": limit}, + ) + except SynologyError as e: + if e.code == 400: + return ( + f"Error: Snapshots not available for '{share_path}' — " + "this feature requires a Btrfs-formatted volume." + ) + return f"Error: {e}" + + snapshots: list[dict] = data.get("snapshots", []) + total: int = data.get("total", len(snapshots)) + + if not snapshots: + return f"No snapshots found for '{share_path}'." + + rows: list[tuple[str, ...]] = [] + for snap in snapshots: + snap_id = str(snap.get("id", snap.get("snapshot_id", ""))) + snap_time = _fmt_time(snap.get("time", snap.get("create_time"))) + snap_desc = str(snap.get("description", snap.get("desc", ""))) + snap_lock = "Yes" if snap.get("lock", False) else "No" + rows.append((snap_id, snap_time, snap_desc, snap_lock)) + + headers = ("Snapshot ID", "Created", "Description", "Locked") + 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{total} snapshot(s) for '{share_path}'.") + if total > offset + limit: + lines.append( + f"Showing {offset + 1}–{offset + len(snapshots)} of {total}. Use offset to page." + ) + + return "\n".join(lines) diff --git a/tests/test_tools_filestation.py b/tests/test_tools_filestation.py index 1a9bb6b..a7f793f 100644 --- a/tests/test_tools_filestation.py +++ b/tests/test_tools_filestation.py @@ -1766,3 +1766,217 @@ async def test_get_md5_missing_hash_in_response(config: AppConfig) -> None: assert result.startswith("Error:") assert "md5" in result.lower() or "hash" in result.lower() + + +# ────────────────────────────────────────────────────────────────────────── +# background_tasks +# ────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_background_tasks_empty(config: AppConfig) -> None: + """background_tasks returns a 'no tasks' message when the list is empty.""" + client = MagicMock() + client.request = AsyncMock(return_value={"offset": 0, "tasks": [], "total": 0}) + tools = _make_mcp_and_tools(config, client) + + result = await tools["background_tasks"]() + + assert "No background tasks" in result + + +@pytest.mark.asyncio +async def test_background_tasks_with_tasks(config: AppConfig) -> None: + """background_tasks returns a formatted table when tasks are present.""" + client = MagicMock() + client.request = AsyncMock( + return_value={ + "offset": 0, + "total": 1, + "tasks": [ + { + "taskid": "FileStation_CopyMove_1", + "type": "CopyMove", + "status": "running", + "path": "/docker/dest", + "processed_num_file": 3, + "total_num_file": 10, + } + ], + } + ) + tools = _make_mcp_and_tools(config, client) + + result = await tools["background_tasks"]() + + assert "FileStation_CopyMove_1" in result + assert "CopyMove" in result + assert "running" in result + assert "/docker/dest" in result + assert "3/10" in result + assert "1 task(s)" in result + + +@pytest.mark.asyncio +async def test_background_tasks_pagination_hint(config: AppConfig) -> None: + """background_tasks shows a pagination hint when there are more results.""" + client = MagicMock() + tasks = [ + { + "taskid": f"task_{i}", + "type": "Delete", + "status": "running", + "path": f"/share/item{i}", + "total_num_file": 0, + } + for i in range(5) + ] + client.request = AsyncMock(return_value={"offset": 0, "total": 20, "tasks": tasks}) + tools = _make_mcp_and_tools(config, client) + + result = await tools["background_tasks"](offset=0, limit=5) + + assert "20 task(s)" in result + assert "offset" in result.lower() + + +@pytest.mark.asyncio +async def test_background_tasks_dsm_error(config: AppConfig) -> None: + """background_tasks returns Error: when DSM raises an exception.""" + client = MagicMock() + client.request = AsyncMock( + side_effect=SynologyError("Permission denied — check DSM user permissions", code=105) + ) + tools = _make_mcp_and_tools(config, client) + + result = await tools["background_tasks"]() + + assert result.startswith("Error:") + + +@pytest.mark.asyncio +async def test_background_tasks_dsm_api_call(config: AppConfig) -> None: + """background_tasks calls the correct DSM API with offset and limit.""" + client = MagicMock() + client.request = AsyncMock(return_value={"offset": 0, "tasks": [], "total": 0}) + tools = _make_mcp_and_tools(config, client) + + await tools["background_tasks"](offset=10, limit=50) + + call = client.request.call_args + assert call[0][0] == "SYNO.FileStation.BackgroundTask" + assert call[0][1] == "list" + assert call[1]["version"] == 3 + assert call[1]["params"]["offset"] == 10 + assert call[1]["params"]["limit"] == 50 + + +# ────────────────────────────────────────────────────────────────────────── +# list_snapshots +# ────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_list_snapshots_btrfs_not_available(config: AppConfig) -> None: + """list_snapshots returns a Btrfs-specific error message on DSM error 400.""" + client = MagicMock() + client.request = AsyncMock(side_effect=SynologyError("Invalid parameter", code=400)) + tools = _make_mcp_and_tools(config, client) + + result = await tools["list_snapshots"](share_path="/docker") + + assert result.startswith("Error:") + assert "Btrfs" in result + + +@pytest.mark.asyncio +async def test_list_snapshots_empty(config: AppConfig) -> None: + """list_snapshots returns a 'no snapshots' message when the list is empty.""" + client = MagicMock() + client.request = AsyncMock(return_value={"snapshots": [], "total": 0}) + tools = _make_mcp_and_tools(config, client) + + result = await tools["list_snapshots"](share_path="/docker") + + assert "No snapshots" in result + assert "/docker" in result + + +@pytest.mark.asyncio +async def test_list_snapshots_with_data(config: AppConfig) -> None: + """list_snapshots returns a formatted table when snapshots are present.""" + client = MagicMock() + client.request = AsyncMock( + return_value={ + "total": 2, + "snapshots": [ + { + "id": "snap_001", + "time": 1700000000, + "description": "Before upgrade", + "lock": True, + }, + {"id": "snap_002", "time": 1700100000, "description": "", "lock": False}, + ], + } + ) + tools = _make_mcp_and_tools(config, client) + + result = await tools["list_snapshots"](share_path="/docker") + + assert "snap_001" in result + assert "snap_002" in result + assert "Before upgrade" in result + assert "Yes" in result # locked + assert "No" in result # not locked + assert "2 snapshot(s)" in result + + +@pytest.mark.asyncio +async def test_list_snapshots_dsm_error_other(config: AppConfig) -> None: + """list_snapshots surfaces non-400 DSM errors as 'Error: …'.""" + client = MagicMock() + client.request = AsyncMock( + side_effect=SynologyError("Permission denied — check DSM user permissions", code=105) + ) + tools = _make_mcp_and_tools(config, client) + + result = await tools["list_snapshots"](share_path="/docker") + + assert result.startswith("Error:") + assert "Permission" in result + + +@pytest.mark.asyncio +async def test_list_snapshots_dsm_api_call(config: AppConfig) -> None: + """list_snapshots calls SYNO.FileStation.Snapshot::list v2 with the correct params.""" + client = MagicMock() + client.request = AsyncMock(return_value={"snapshots": [], "total": 0}) + tools = _make_mcp_and_tools(config, client) + + await tools["list_snapshots"](share_path="/data", offset=0, limit=50) + + call = client.request.call_args + assert call[0][0] == "SYNO.FileStation.Snapshot" + assert call[0][1] == "list" + assert call[1]["version"] == 2 + assert call[1]["params"]["folder_path"] == "/data" + assert call[1]["params"]["offset"] == 0 + assert call[1]["params"]["limit"] == 50 + + +@pytest.mark.asyncio +async def test_list_snapshots_pagination_hint(config: AppConfig) -> None: + """list_snapshots shows a pagination hint when more results are available.""" + client = MagicMock() + snaps = [ + {"id": f"snap_{i}", "time": 1700000000 + i * 3600, "description": "", "lock": False} + for i in range(3) + ] + client.request = AsyncMock(return_value={"snapshots": snaps, "total": 10}) + tools = _make_mcp_and_tools(config, client) + + result = await tools["list_snapshots"](share_path="/data", offset=0, limit=3) + + assert "10 snapshot(s)" in result + assert "offset" in result.lower()