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:
@@ -146,7 +146,7 @@ src/mcp_synology_filestation/
|
|||||||
└── filestation.py # register_filestation(mcp, config, client)
|
└── filestation.py # register_filestation(mcp, config, client)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Implemented Tools (v0.2.10 — 20 tools)
|
## Implemented Tools (v0.3.4 — 26 tools)
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|-----------------------|--------------------------------------------------------------|
|
|-----------------------|--------------------------------------------------------------|
|
||||||
@@ -170,17 +170,24 @@ src/mcp_synology_filestation/
|
|||||||
| `create_sharing_link` | Create a public sharing link (optional password + expiry) |
|
| `create_sharing_link` | Create a public sharing link (optional password + expiry) |
|
||||||
| `list_sharing_links` | List all sharing links (paginated table) |
|
| `list_sharing_links` | List all sharing links (paginated table) |
|
||||||
| `delete_sharing_link` | Delete a sharing link by ID |
|
| `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.
|
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
|
## Roadmap
|
||||||
|
|
||||||
### v0.3 — next
|
### v0.4 — candidates
|
||||||
| Tool | API |
|
No further tools currently planned.
|
||||||
|--------------------|----------------------------------|
|
|
||||||
| `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`|
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ regardless of service state.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tools (v0.2.10 — 20 tools)
|
## Tools (v0.3.4 — 26 tools)
|
||||||
|
|
||||||
### Read-only
|
### Read-only
|
||||||
|
|
||||||
@@ -134,6 +134,22 @@ regardless of service state.
|
|||||||
| `list_sharing_links` | List all sharing links (paginated table) |
|
| `list_sharing_links` | List all sharing links (paginated table) |
|
||||||
| `delete_sharing_link` | Delete a sharing link by ID |
|
| `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
|
### 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
|
## Error Handling Strategy
|
||||||
|
|
||||||
### Principles
|
### Principles
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcp-synology-filestation"
|
name = "mcp-synology-filestation"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
description = "MCP server for Synology FileStation"
|
description = "MCP server for Synology FileStation"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""MCP server for Synology FileStation."""
|
"""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"Error: {e}"
|
||||||
|
|
||||||
return f"Deleted favorite for {path}"
|
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)
|
||||||
|
|||||||
@@ -1766,3 +1766,217 @@ async def test_get_md5_missing_hash_in_response(config: AppConfig) -> None:
|
|||||||
|
|
||||||
assert result.startswith("Error:")
|
assert result.startswith("Error:")
|
||||||
assert "md5" in result.lower() or "hash" in result.lower()
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user