feat: add background_tasks + list_snapshots tools (v0.3.4)

- background_tasks: SYNO.FileStation.BackgroundTask::list (v3) — paginated
  table of active/recent copy/move/delete/extract/compress tasks
- list_snapshots: SYNO.FileStation.Snapshot::list (v2) — Btrfs snapshots
  per share; maps error 400 to a clear Btrfs-required message
- 20 new tests (107 total)
- SPEC.md and CLAUDE.md updated (26 tools)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 21:54:03 +02:00
parent b88677a20c
commit 4c6de3bfc7
6 changed files with 456 additions and 13 deletions
+17 -10
View File
@@ -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 (v1v3).
No stop/cancel/clear methods exist on this firmware.
## Roadmap
### v0.3next
| 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.4candidates
No further tools currently planned.
+107 -1
View File
@@ -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
+1 -1
View File
@@ -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 = [
+1 -1
View File
@@ -1,3 +1,3 @@
"""MCP server for Synology FileStation."""
__version__ = "0.3.3"
__version__ = "0.3.4"
@@ -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)
+214
View File
@@ -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()