Compare commits
18 Commits
v0.1.0
...
451ee7116f
| Author | SHA1 | Date | |
|---|---|---|---|
| 451ee7116f | |||
| 8b2f07d9c3 | |||
| 62f8e41931 | |||
| 0e8ffaa6df | |||
| 6510493930 | |||
| 4bf655236d | |||
| c0d4c347c5 | |||
| e3fa71b458 | |||
| 4d8eae752d | |||
| c923da6f6a | |||
| d8d7c6fd47 | |||
| 4145d929a6 | |||
| 04caaef003 | |||
| 1d0cf940b4 | |||
| fc706fb809 | |||
| 500dc73324 | |||
| 473c771c20 | |||
| dbab842738 |
@@ -402,3 +402,27 @@ Validate config, resolve credentials, test login, list available FileStation API
|
||||
Load config and credentials, create `FileStationClient` (lazy — no immediate connection),
|
||||
create and run `FastMCP("mcp-synology-filestation")` over stdio. Uses `anyio.run()` for
|
||||
Windows compatibility.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### `dir_size` / `get_md5`: occasional "DSM error code 599" on large directories
|
||||
|
||||
DSM's `DirSize` and `MD5` APIs are one-shot: once `finished=true` is returned
|
||||
by the status endpoint, the task is removed and all subsequent polls return
|
||||
error 599. The MCP server polls immediately after `start` (no initial delay)
|
||||
and retries up to 5 consecutive 599 responses before giving up.
|
||||
|
||||
For small directories and files the result is reliably read on the first or
|
||||
second poll. For large directories (e.g. `/docker`, `/music`) the task takes
|
||||
longer; if DSM removes the completed result between two polls the tool returns
|
||||
`Error: DSM error code 599`. Retrying the operation usually succeeds.
|
||||
|
||||
Root cause is not fully understood. The raw HTTP test script (`test_dirsize_md5.py`)
|
||||
reliably catches `finished=true` for the same paths, suggesting the issue is
|
||||
related to timing in the MCP stdio event loop.
|
||||
|
||||
**Workaround:** retry the `dir_size` call. It succeeds on the second or third attempt
|
||||
for most paths.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mcp-synology-filestation"
|
||||
version = "0.1.0"
|
||||
version = "0.2.9"
|
||||
description = "MCP server for Synology FileStation"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""MCP server for Synology FileStation."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.9"
|
||||
|
||||
@@ -247,8 +247,6 @@ class FileStationClient:
|
||||
Raises:
|
||||
SynologyError: On API errors.
|
||||
"""
|
||||
sys.stderr.write(f"[dsm] request: {api}/{method}\n")
|
||||
sys.stderr.flush()
|
||||
if not self._initializing:
|
||||
await self._ensure_initialized()
|
||||
http = self._get_http()
|
||||
|
||||
@@ -59,15 +59,104 @@ def register_filestation(
|
||||
client: FileStationClient for DSM API calls.
|
||||
"""
|
||||
|
||||
# ── internal polling helper ───────────────────────────────────────────
|
||||
# ── internal polling helpers ──────────────────────────────────────────
|
||||
|
||||
async def _poll_task(api: str, version: int, taskid: str) -> tuple[bool, dict[str, Any] | str]:
|
||||
"""Poll a DSM async task (CopyMove / Delete) until finished or timeout.
|
||||
async def _start_and_poll_oneshot(
|
||||
api: str,
|
||||
start_params: dict[str, Any],
|
||||
start_version: int,
|
||||
poll_version: int,
|
||||
) -> tuple[bool, dict[str, Any] | str]:
|
||||
"""Start a one-shot DSM task and poll until finished, restarting on cold-start 599s.
|
||||
|
||||
DirSize and MD5 are "one-shot" tasks: DSM delivers ``finished=True`` exactly
|
||||
once, then discards the result. Additionally, the DSM background service for
|
||||
these tasks occasionally needs a few seconds to initialise after a period of
|
||||
inactivity ("cold start"). During cold start the service registers task IDs
|
||||
but returns error 599 on every status poll. The correct recovery is to restart
|
||||
the task once the service has had time to wake up.
|
||||
|
||||
Args:
|
||||
api: DSM API name (e.g. "SYNO.FileStation.DirSize").
|
||||
start_params: Parameters forwarded to the ``start`` method.
|
||||
start_version: API version for the ``start`` call.
|
||||
poll_version: API version for the ``status`` call.
|
||||
|
||||
Returns:
|
||||
``(True, status_dict)`` on success, or ``(False, "Error: …")`` on
|
||||
DSM error or timeout.
|
||||
"""
|
||||
from mcp_synology_filestation.client import SynologyError as _SynologyError
|
||||
|
||||
max_restarts = 6
|
||||
timeout = 60.0
|
||||
total_elapsed = 0.0
|
||||
|
||||
for _attempt in range(max_restarts):
|
||||
try:
|
||||
start_data = await client.request(
|
||||
api, "start", version=start_version, params=start_params
|
||||
)
|
||||
except _SynologyError as e:
|
||||
return False, f"Error: {e}"
|
||||
|
||||
taskid: str = start_data.get("taskid", "")
|
||||
if not taskid:
|
||||
return False, "Error: DSM did not return a task ID."
|
||||
|
||||
# Poll with exponential backoff; restart on 5 consecutive 599s
|
||||
delay = 0.2
|
||||
consecutive_599 = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
status_data = await client.request(
|
||||
api, "status", version=poll_version, params={"taskid": taskid}
|
||||
)
|
||||
consecutive_599 = 0
|
||||
if status_data.get("finished"):
|
||||
return True, status_data
|
||||
# Still running — keep polling
|
||||
except _SynologyError as e:
|
||||
if e.code != 599:
|
||||
return False, f"Error: {e}"
|
||||
consecutive_599 += 1
|
||||
if consecutive_599 >= 5:
|
||||
# 5× 599 in a row: either cold start or missed result window.
|
||||
# Restart the task so DSM can re-queue it.
|
||||
break
|
||||
|
||||
if total_elapsed >= timeout:
|
||||
return (
|
||||
False,
|
||||
"Error: Operation timed out after 60 seconds — check NAS manually.",
|
||||
)
|
||||
|
||||
await asyncio.sleep(delay)
|
||||
total_elapsed += delay
|
||||
delay = min(delay * 2, 2.0)
|
||||
|
||||
return (
|
||||
False,
|
||||
"Error: DSM did not return results after multiple retries"
|
||||
" (service may be starting up — try again in a moment).",
|
||||
)
|
||||
|
||||
async def _poll_task(
|
||||
api: str,
|
||||
version: int,
|
||||
taskid: str,
|
||||
initial_delay: float = 0.2,
|
||||
) -> tuple[bool, dict[str, Any] | str]:
|
||||
"""Poll a DSM async task until finished or timeout.
|
||||
|
||||
Args:
|
||||
api: DSM API name (e.g. "SYNO.FileStation.CopyMove").
|
||||
version: API version to use for the status call.
|
||||
taskid: Task ID returned by the corresponding start method.
|
||||
initial_delay: Seconds to wait before the first status poll.
|
||||
Set to 0.0 for tasks that may finish before the first poll
|
||||
interval (e.g. DirSize on small directories, MD5 on small files).
|
||||
|
||||
Returns:
|
||||
``(True, status_dict)`` on success, or ``(False, "Error: …")`` on
|
||||
@@ -76,13 +165,14 @@ def register_filestation(
|
||||
from mcp_synology_filestation.client import SynologyError as _SynologyError
|
||||
|
||||
delay = 0.2
|
||||
elapsed = 0.0
|
||||
elapsed = initial_delay
|
||||
timeout = 60.0
|
||||
consecutive_599 = 0
|
||||
|
||||
if initial_delay > 0:
|
||||
await asyncio.sleep(initial_delay)
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(delay)
|
||||
elapsed += delay
|
||||
|
||||
try:
|
||||
status_data = await client.request(
|
||||
api,
|
||||
@@ -90,11 +180,19 @@ def register_filestation(
|
||||
version=version,
|
||||
params={"taskid": taskid},
|
||||
)
|
||||
consecutive_599 = 0
|
||||
except _SynologyError as e:
|
||||
return False, f"Error: {e}"
|
||||
|
||||
if status_data.get("finished"):
|
||||
return True, status_data
|
||||
if e.code == 599:
|
||||
# 599 can be transient (task just started, not yet available).
|
||||
# Retry up to 5 times before giving up.
|
||||
consecutive_599 += 1
|
||||
if consecutive_599 >= 5:
|
||||
return False, f"Error: {e}"
|
||||
else:
|
||||
return False, f"Error: {e}"
|
||||
else:
|
||||
if status_data.get("finished"):
|
||||
return True, status_data
|
||||
|
||||
if elapsed >= timeout:
|
||||
return (
|
||||
@@ -102,14 +200,13 @@ def register_filestation(
|
||||
"Error: Operation timed out after 60 seconds — check NAS manually.",
|
||||
)
|
||||
|
||||
await asyncio.sleep(delay)
|
||||
elapsed += delay
|
||||
delay = min(delay * 2, 2.0)
|
||||
|
||||
@mcp.tool()
|
||||
async def list_shares() -> str:
|
||||
"""List all shared folders visible to the authenticated user.
|
||||
|
||||
Returns a formatted table with share name, path, and volume status.
|
||||
"""
|
||||
async def list_shares():
|
||||
"""List all shared folders. Returns name/path/volume-usage table."""
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
try:
|
||||
@@ -163,24 +260,9 @@ def register_filestation(
|
||||
limit: int = 100,
|
||||
sort_by: str = "name",
|
||||
sort_direction: str = "asc",
|
||||
) -> str:
|
||||
"""List the contents of a directory on the NAS.
|
||||
|
||||
Use share paths as returned by list_shares (e.g. "/dev", "/data"),
|
||||
not volume paths (e.g. "/volume1/dev" will not work).
|
||||
|
||||
Args:
|
||||
path: Share-relative path on the NAS (e.g. "/dev" or "/data/photos").
|
||||
offset: Number of items to skip (for pagination).
|
||||
limit: Maximum items to return (1-500, default 100).
|
||||
sort_by: Sort field — one of: name, size, user, group, mtime, atime,
|
||||
crtime, posix, type.
|
||||
sort_direction: "asc" or "desc".
|
||||
|
||||
Returns:
|
||||
Formatted table with name and type, plus the total item count
|
||||
for pagination context.
|
||||
"""
|
||||
):
|
||||
"""List directory contents. path: share-relative (e.g. /docker).
|
||||
offset/limit for pagination, sort_by/sort_direction for ordering."""
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
# Validate inputs
|
||||
@@ -265,22 +347,9 @@ def register_filestation(
|
||||
pattern: str,
|
||||
recursive: bool = True,
|
||||
max_results: int = 200,
|
||||
) -> str:
|
||||
"""Search for files matching a glob pattern within a directory.
|
||||
|
||||
Starts an async DSM search task, polls until complete, then cleans up.
|
||||
Use share paths as returned by list_shares (e.g. "/docker").
|
||||
|
||||
Args:
|
||||
path: Root directory to search from (e.g. "/docker").
|
||||
pattern: Filename glob pattern (e.g. "*.yaml", "report*.pdf").
|
||||
recursive: Search subdirectories (default True).
|
||||
max_results: Maximum number of matches to return (default 200, max 1000).
|
||||
|
||||
Returns:
|
||||
Formatted table with path, type, size, and modification time,
|
||||
plus total match count.
|
||||
"""
|
||||
):
|
||||
"""Search files by glob pattern under path. pattern: e.g. "*.yaml".
|
||||
recursive/max_results optional."""
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
limit = max(1, min(max_results, 1000))
|
||||
@@ -399,18 +468,9 @@ def register_filestation(
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def download(path: str) -> str:
|
||||
"""Download a single file from the NAS and return its content as base64.
|
||||
|
||||
Files larger than 10 MB are rejected — use SFTP or another method instead.
|
||||
Use share paths as returned by list_shares (e.g. "/docker/app/config.yaml").
|
||||
|
||||
Args:
|
||||
path: Absolute share-relative path to the file on the NAS.
|
||||
|
||||
Returns:
|
||||
JSON object with "filename", "size" (bytes), and "content_base64".
|
||||
"""
|
||||
async def download(path: str):
|
||||
"""Download a file as base64 (max 10 MB). path: share-relative.
|
||||
Returns JSON {filename, size, content_base64}."""
|
||||
import base64
|
||||
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
@@ -438,20 +498,9 @@ def register_filestation(
|
||||
)
|
||||
|
||||
@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.
|
||||
"""
|
||||
async def get_info(path: str):
|
||||
"""Get metadata (type/size/owner/permissions/timestamps) for one or more paths.
|
||||
path: comma-separated share-relative paths."""
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
paths = [p.strip() for p in path.split(",") if p.strip()]
|
||||
@@ -538,6 +587,49 @@ def register_filestation(
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def check_exist(path: str):
|
||||
"""Check if one or more paths exist. path: comma-separated share-relative paths.
|
||||
Returns Yes/No table."""
|
||||
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": json.dumps(paths),
|
||||
"additional": json.dumps([]),
|
||||
},
|
||||
)
|
||||
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)."
|
||||
|
||||
# A path that doesn't exist still gets an entry but with name=None
|
||||
rows = [(f.get("path", ""), "Yes" if f.get("name") is not None else "No") for f in files]
|
||||
|
||||
w_path = max(len("Path"), *(len(r[0]) for r in rows))
|
||||
w_exists = len("Exists") # "Yes" / "No" always shorter
|
||||
|
||||
sep = f"+{'-' * (w_path + 2)}+{'-' * (w_exists + 2)}+"
|
||||
header = f"| {'Path':<{w_path}} | {'Exists':<{w_exists}} |"
|
||||
|
||||
lines = [sep, header, sep]
|
||||
for item_path, exists_str in rows:
|
||||
lines.append(f"| {item_path:<{w_path}} | {exists_str:<{w_exists}} |")
|
||||
lines.append(sep)
|
||||
lines.append(f"\n{len(rows)} path(s) checked.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── write tools ───────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
@@ -545,18 +637,9 @@ def register_filestation(
|
||||
path: str,
|
||||
name: str,
|
||||
create_parents: bool = False,
|
||||
) -> str:
|
||||
"""Create a new folder on the NAS.
|
||||
|
||||
Args:
|
||||
path: Parent directory path (e.g. "/docker").
|
||||
name: New folder name — not a full path (e.g. "my-app").
|
||||
create_parents: Create missing intermediate parent directories
|
||||
if True (default False).
|
||||
|
||||
Returns:
|
||||
Full path of the created folder, or an Error: message.
|
||||
"""
|
||||
):
|
||||
"""Create a folder. path: parent dir, name: folder name (not full path).
|
||||
create_parents: make missing parents."""
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
try:
|
||||
@@ -577,17 +660,9 @@ def register_filestation(
|
||||
return f"Created: {created_path}"
|
||||
|
||||
@mcp.tool()
|
||||
async def rename(path: str, new_name: str) -> str:
|
||||
"""Rename a file or folder on the NAS.
|
||||
|
||||
Args:
|
||||
path: Absolute share-relative path to the item
|
||||
(e.g. "/docker/old-name.yaml").
|
||||
new_name: New name — not a full path (e.g. "new-name.yaml").
|
||||
|
||||
Returns:
|
||||
New absolute path after rename, or an Error: message.
|
||||
"""
|
||||
async def rename(path: str, new_name: str):
|
||||
"""Rename a file or folder. path: current share-relative path,
|
||||
new_name: bare name only (not full path)."""
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
try:
|
||||
@@ -608,20 +683,9 @@ def register_filestation(
|
||||
return f"Renamed to: {new_path}"
|
||||
|
||||
@mcp.tool()
|
||||
async def copy(src: str, dst: str, overwrite: bool = False) -> str:
|
||||
"""Copy a file or folder to a new location on the NAS.
|
||||
|
||||
WARNING: Set overwrite=True only when you intentionally want to replace
|
||||
an existing item at the destination.
|
||||
|
||||
Args:
|
||||
src: Source absolute path (e.g. "/docker/app/compose.yaml").
|
||||
dst: Destination directory path (e.g. "/backup/docker").
|
||||
overwrite: Replace existing item at destination (default False).
|
||||
|
||||
Returns:
|
||||
Destination path on success, or an Error: message.
|
||||
"""
|
||||
async def copy(src: str, dst: str, overwrite: bool = False):
|
||||
"""Copy src to dst directory.
|
||||
WARNING: overwrite=True replaces existing items (default False)."""
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
try:
|
||||
@@ -653,20 +717,9 @@ def register_filestation(
|
||||
return f"Copied to: {dest_folder}/{filename}"
|
||||
|
||||
@mcp.tool()
|
||||
async def move(src: str, dst: str, overwrite: bool = False) -> str:
|
||||
"""Move a file or folder to a new location on the NAS.
|
||||
|
||||
WARNING: Set overwrite=True only when you intentionally want to replace
|
||||
an existing item at the destination.
|
||||
|
||||
Args:
|
||||
src: Source absolute path (e.g. "/docker/app/old-compose.yaml").
|
||||
dst: Destination directory path (e.g. "/backup/docker").
|
||||
overwrite: Replace existing item at destination (default False).
|
||||
|
||||
Returns:
|
||||
Destination path on success, or an Error: message.
|
||||
"""
|
||||
async def move(src: str, dst: str, overwrite: bool = False):
|
||||
"""Move src to dst directory.
|
||||
WARNING: overwrite=True replaces existing items (default False)."""
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
try:
|
||||
@@ -698,21 +751,9 @@ def register_filestation(
|
||||
return f"Moved to: {dest_folder}/{filename}"
|
||||
|
||||
@mcp.tool()
|
||||
async def delete(path: str, confirmed: bool = False) -> str:
|
||||
"""Delete a file or folder on the NAS.
|
||||
|
||||
WARNING: This operation is irreversible. Without confirmed=True,
|
||||
returns only a preview — no changes are made.
|
||||
|
||||
Args:
|
||||
path: Absolute share-relative path to delete.
|
||||
confirmed: Must be True to actually delete. Defaults to False
|
||||
(preview only — no DSM call).
|
||||
|
||||
Returns:
|
||||
Preview message if confirmed=False; success or Error: message
|
||||
if confirmed=True.
|
||||
"""
|
||||
async def delete(path: str, confirmed: bool = False):
|
||||
"""Delete a file or folder. IRREVERSIBLE.
|
||||
confirmed=False (default) shows preview only; pass confirmed=True to actually delete."""
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
if not confirmed:
|
||||
@@ -744,6 +785,175 @@ def register_filestation(
|
||||
|
||||
return f"Deleted: {path}"
|
||||
|
||||
@mcp.tool()
|
||||
async def compress(
|
||||
paths: list[str],
|
||||
dest_file_path: str,
|
||||
level: str = "moderate",
|
||||
mode: str = "add",
|
||||
format: str = "zip",
|
||||
password: str = "",
|
||||
):
|
||||
"""Compress paths into an archive. dest_file_path: full path incl. filename.
|
||||
level: store/fastest/fast/normal/moderate/maximum. format: zip/7z."""
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
_valid_levels = {"store", "fastest", "fast", "normal", "moderate", "maximum"}
|
||||
_valid_modes = {"add", "update", "refreshen"}
|
||||
_valid_formats = {"zip", "7z"}
|
||||
|
||||
if level not in _valid_levels:
|
||||
return f"Error: level must be one of {sorted(_valid_levels)}"
|
||||
if mode not in _valid_modes:
|
||||
return f"Error: mode must be one of {sorted(_valid_modes)}"
|
||||
if format not in _valid_formats:
|
||||
return f"Error: format must be one of {sorted(_valid_formats)}"
|
||||
if not paths:
|
||||
return "Error: paths list must not be empty."
|
||||
|
||||
try:
|
||||
start_data = await client.request(
|
||||
"SYNO.FileStation.Compress",
|
||||
"start",
|
||||
version=3,
|
||||
params={
|
||||
"path": json.dumps(paths),
|
||||
"dest_file_path": json.dumps(dest_file_path),
|
||||
"level": level,
|
||||
"mode": mode,
|
||||
"format": format,
|
||||
"compress_password": password,
|
||||
},
|
||||
)
|
||||
except SynologyError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
taskid: str = start_data.get("taskid", "")
|
||||
if not taskid:
|
||||
return "Error: DSM did not return a task ID."
|
||||
|
||||
ok, result = await _poll_task("SYNO.FileStation.Compress", 3, taskid)
|
||||
if not ok:
|
||||
return result # type: ignore[return-value]
|
||||
|
||||
return f"Compressed to: {dest_file_path}"
|
||||
|
||||
@mcp.tool()
|
||||
async def extract(
|
||||
file_path: str,
|
||||
dest_folder_path: str,
|
||||
overwrite: bool = False,
|
||||
keep_dir: bool = True,
|
||||
create_subfolder: bool = False,
|
||||
password: str = "",
|
||||
):
|
||||
"""Extract a ZIP or 7z archive to dest_folder_path.
|
||||
overwrite/keep_dir/create_subfolder/password optional."""
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
try:
|
||||
start_data = await client.request(
|
||||
"SYNO.FileStation.Extract",
|
||||
"start",
|
||||
version=2,
|
||||
params={
|
||||
"file_path": json.dumps(file_path),
|
||||
"dest_folder_path": json.dumps(dest_folder_path),
|
||||
"overwrite": "true" if overwrite else "false",
|
||||
"keep_dir": "true" if keep_dir else "false",
|
||||
"create_subfolder": "true" if create_subfolder else "false",
|
||||
"codepage": "enu",
|
||||
"password": password,
|
||||
},
|
||||
)
|
||||
except SynologyError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
taskid: str = start_data.get("taskid", "")
|
||||
if not taskid:
|
||||
return "Error: DSM did not return a task ID."
|
||||
|
||||
ok, result = await _poll_task("SYNO.FileStation.Extract", 2, taskid)
|
||||
if not ok:
|
||||
return result # type: ignore[return-value]
|
||||
|
||||
status: dict[str, Any] = result # type: ignore[assignment]
|
||||
dest = status.get("dest_folder_path", dest_folder_path)
|
||||
return f"Extracted to: {dest}"
|
||||
|
||||
@mcp.tool()
|
||||
async def dir_size(path: str):
|
||||
"""Get total size, file count and folder count for one or more directories.
|
||||
path: comma-separated share-relative paths."""
|
||||
paths = [p.strip() for p in path.split(",") if p.strip()]
|
||||
if not paths:
|
||||
return "Error: no path provided."
|
||||
|
||||
ok, result = await _start_and_poll_oneshot(
|
||||
"SYNO.FileStation.DirSize",
|
||||
start_params={"path": json.dumps(paths)},
|
||||
start_version=2,
|
||||
poll_version=1,
|
||||
)
|
||||
if not ok:
|
||||
return result # type: ignore[return-value]
|
||||
|
||||
status: dict[str, Any] = result # type: ignore[assignment]
|
||||
num_dir = status.get("num_dir", 0)
|
||||
num_file = status.get("num_file", 0)
|
||||
total_size = status.get("total_size", 0)
|
||||
|
||||
path_label = ", ".join(paths)
|
||||
w_path = max(len("Path"), len(path_label))
|
||||
num_dir_str = str(num_dir)
|
||||
num_file_str = str(num_file)
|
||||
size_str = _fmt_size(total_size)
|
||||
|
||||
sep = (
|
||||
f"+{'-' * (w_path + 2)}"
|
||||
f"+{'-' * (max(len('Folders'), len(num_dir_str)) + 2)}"
|
||||
f"+{'-' * (max(len('Files'), len(num_file_str)) + 2)}"
|
||||
f"+{'-' * (max(len('Total Size'), len(size_str)) + 2)}+"
|
||||
)
|
||||
w_dir = max(len("Folders"), len(num_dir_str))
|
||||
w_file = max(len("Files"), len(num_file_str))
|
||||
w_size = max(len("Total Size"), len(size_str))
|
||||
|
||||
sep = (
|
||||
f"+{'-' * (w_path + 2)}+{'-' * (w_dir + 2)}+{'-' * (w_file + 2)}+{'-' * (w_size + 2)}+"
|
||||
)
|
||||
header = (
|
||||
f"| {'Path':<{w_path}} "
|
||||
f"| {'Folders':<{w_dir}} "
|
||||
f"| {'Files':<{w_file}} "
|
||||
f"| {'Total Size':<{w_size}} |"
|
||||
)
|
||||
row = (
|
||||
f"| {path_label:<{w_path}} "
|
||||
f"| {num_dir_str:<{w_dir}} "
|
||||
f"| {num_file_str:<{w_file}} "
|
||||
f"| {size_str:<{w_size}} |"
|
||||
)
|
||||
return "\n".join([sep, header, sep, row, sep])
|
||||
|
||||
@mcp.tool()
|
||||
async def get_md5(path: str):
|
||||
"""Compute the MD5 checksum of a file on the NAS. path: share-relative file path."""
|
||||
ok, result = await _start_and_poll_oneshot(
|
||||
"SYNO.FileStation.MD5",
|
||||
start_params={"file_path": json.dumps(path)},
|
||||
start_version=2,
|
||||
poll_version=1,
|
||||
)
|
||||
if not ok:
|
||||
return result # type: ignore[return-value]
|
||||
|
||||
status: dict[str, Any] = result # type: ignore[assignment]
|
||||
md5 = status.get("md5", "")
|
||||
if not md5:
|
||||
return "Error: DSM returned no MD5 hash."
|
||||
return f"MD5 of {path}: {md5}"
|
||||
|
||||
@mcp.tool()
|
||||
async def upload(
|
||||
path: str,
|
||||
@@ -751,22 +961,9 @@ def register_filestation(
|
||||
content_base64: str,
|
||||
overwrite: bool = False,
|
||||
create_parents: bool = True,
|
||||
) -> str:
|
||||
"""Upload a file to a directory on the NAS from base64-encoded content.
|
||||
|
||||
WARNING: Set overwrite=True only when you intentionally want to replace
|
||||
an existing file.
|
||||
|
||||
Args:
|
||||
path: Destination directory path on the NAS (e.g. "/docker/app").
|
||||
filename: Filename to create (e.g. "compose.yaml").
|
||||
content_base64: Base64-encoded file content.
|
||||
overwrite: Replace existing file at destination (default False).
|
||||
create_parents: Create missing parent directories (default True).
|
||||
|
||||
Returns:
|
||||
Full path of the uploaded file, or an Error: message.
|
||||
"""
|
||||
):
|
||||
"""Upload base64-encoded content as filename into path (max 50 MB).
|
||||
WARNING: overwrite=True replaces existing file (default False)."""
|
||||
import base64
|
||||
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Wegwerfskript: DirSize + MD5 direkt gegen die NAS testen.
|
||||
|
||||
Ausfuehren: uv run python test_dirsize_md5.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from mcp_synology_filestation.auth import AuthManager
|
||||
from mcp_synology_filestation.client import FileStationClient
|
||||
from mcp_synology_filestation.config import load_config
|
||||
|
||||
DIRSIZE_PATHS = ["/test-mcp", "/docker"]
|
||||
MD5_PATH = "/test-mcp/test.zip"
|
||||
|
||||
|
||||
def pp(label: str, data: object, elapsed_ms: float | None = None) -> None:
|
||||
print(f"\n{'='*60}")
|
||||
suffix = f" [{elapsed_ms:.1f} ms]" if elapsed_ms is not None else ""
|
||||
print(f" {label}{suffix}")
|
||||
print("=" * 60)
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
async def raw(http: httpx.AsyncClient, url: str, sid: str, **params) -> dict:
|
||||
r = await http.get(url, params={"_sid": sid, **params})
|
||||
r.raise_for_status()
|
||||
try:
|
||||
return r.json()
|
||||
except Exception:
|
||||
return {"_raw": r.text[:300], "_http_status": r.status_code}
|
||||
|
||||
|
||||
async def probe_dirsize_long(
|
||||
http: httpx.AsyncClient, sid: str, api_url: str, path: str
|
||||
) -> None:
|
||||
"""Start DirSize and poll v1 every 200ms for up to 15s.
|
||||
|
||||
Goal: find out if 599 means 'task still running' (keep polling)
|
||||
or 'task gone' (give up). If the task eventually returns data,
|
||||
599 = 'not ready yet'. If it never returns data, 599 = 'task gone'.
|
||||
"""
|
||||
print(f"\n{'#'*60}")
|
||||
print(f" DIRSIZE {path} — long poll (15s, every 200ms)")
|
||||
print(f"{'#'*60}")
|
||||
|
||||
t0 = time.perf_counter()
|
||||
start_body = await raw(
|
||||
http, api_url, sid,
|
||||
api="SYNO.FileStation.DirSize", version="2", method="start",
|
||||
path=json.dumps([path]),
|
||||
)
|
||||
elapsed_start = (time.perf_counter() - t0) * 1000
|
||||
pp(f"DirSize::start ({path})", start_body, elapsed_start)
|
||||
|
||||
taskid = (start_body.get("data") or {}).get("taskid")
|
||||
if not taskid:
|
||||
print("[!] No taskid.")
|
||||
return
|
||||
|
||||
print(f"\n[*] Polling status v1 every 200ms for up to 15s (taskid={taskid[:12]}...)")
|
||||
for attempt in range(75): # 75 * 200ms = 15s
|
||||
if attempt > 0:
|
||||
await asyncio.sleep(0.2)
|
||||
t = time.perf_counter()
|
||||
r = await raw(
|
||||
http, api_url, sid,
|
||||
api="SYNO.FileStation.DirSize", version="1", method="status",
|
||||
taskid=taskid,
|
||||
)
|
||||
elapsed = (t - t0) * 1000
|
||||
success = r.get("success")
|
||||
data = (r.get("data") or {})
|
||||
finished = data.get("finished")
|
||||
error_code = (r.get("error") or {}).get("code")
|
||||
|
||||
if finished:
|
||||
print(f" [{elapsed:.0f}ms] attempt {attempt+1}: FERTIG! "
|
||||
f"num_dir={data.get('num_dir')} "
|
||||
f"num_file={data.get('num_file')} "
|
||||
f"total_size={data.get('total_size')}")
|
||||
pp(f"DirSize::status final ({path})", r, elapsed)
|
||||
return
|
||||
elif success and not finished:
|
||||
# Still running — show current progress
|
||||
print(f" [{elapsed:.0f}ms] attempt {attempt+1}: running... "
|
||||
f"num_dir={data.get('num_dir', '?')} "
|
||||
f"num_file={data.get('num_file', '?')} "
|
||||
f"total_size={data.get('total_size', '?')}")
|
||||
else:
|
||||
print(f" [{elapsed:.0f}ms] attempt {attempt+1}: error code={error_code}")
|
||||
# Continue polling — 599 might mean 'not ready yet'
|
||||
|
||||
print(f"\n[!] No result after 15s — task never returned data.")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
config = load_config()
|
||||
auth = AuthManager(config)
|
||||
|
||||
async with FileStationClient(config.base_url, config.connection.verify_ssl) as client:
|
||||
client.set_auth_manager(auth)
|
||||
await client._ensure_initialized() # noqa: SLF001
|
||||
sid = client.sid
|
||||
base = config.base_url
|
||||
|
||||
info = client._api_cache.get("SYNO.FileStation.DirSize", {}) # noqa: SLF001
|
||||
api_url = f"{base}/webapi/{info.get('path', 'entry.cgi')}"
|
||||
print(f"[*] DirSize API: {api_url} v{info.get('minVersion')}-v{info.get('maxVersion')}")
|
||||
|
||||
async with httpx.AsyncClient(verify=config.connection.verify_ssl, timeout=30.0) as http:
|
||||
for path in DIRSIZE_PATHS:
|
||||
await probe_dirsize_long(http, sid, api_url, path)
|
||||
|
||||
await auth.logout(client)
|
||||
print("\n[*] Logout OK.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1075,3 +1075,694 @@ async def test_upload_dsm_error(config: AppConfig) -> None:
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "permission" in result.lower()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# check_exist
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_exist_single_existing(config: AppConfig) -> None:
|
||||
"""check_exist returns Yes for a path that exists."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(
|
||||
return_value={
|
||||
"files": [
|
||||
{"path": "/docker", "name": "docker", "isdir": True, "additional": {}},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["check_exist"](path="/docker")
|
||||
|
||||
assert "/docker" in result
|
||||
assert "Yes" in result
|
||||
assert "No" not in result
|
||||
assert "1 path(s) checked" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_exist_single_missing(config: AppConfig) -> None:
|
||||
"""check_exist returns No for a path that does not exist (name=None from DSM)."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(
|
||||
return_value={
|
||||
"files": [
|
||||
{"path": "/no-such-path", "name": None, "isdir": None, "additional": None},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["check_exist"](path="/no-such-path")
|
||||
|
||||
assert "/no-such-path" in result
|
||||
assert "No" in result
|
||||
assert "1 path(s) checked" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_exist_multi_path(config: AppConfig) -> None:
|
||||
"""check_exist handles comma-separated paths and reports each correctly."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(
|
||||
return_value={
|
||||
"files": [
|
||||
{"path": "/docker", "name": "docker", "isdir": True, "additional": {}},
|
||||
{"path": "/ghost", "name": None, "isdir": None, "additional": None},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["check_exist"](path="/docker,/ghost")
|
||||
|
||||
assert "/docker" in result
|
||||
assert "/ghost" in result
|
||||
assert "Yes" in result
|
||||
assert "No" in result
|
||||
assert "2 path(s) checked" in result
|
||||
|
||||
# Verify DSM was called with both paths as a JSON array
|
||||
call_params = client.request.call_args[1]["params"]
|
||||
requested_paths = json.loads(call_params["path"])
|
||||
assert "/docker" in requested_paths
|
||||
assert "/ghost" in requested_paths
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# compress
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_success(config: AppConfig) -> None:
|
||||
"""compress polls until finished and returns the archive path."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_compress1"}
|
||||
if method == "status":
|
||||
return {"finished": True}
|
||||
return {}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["compress"](
|
||||
paths=["/data/report.pdf", "/data/photos"],
|
||||
dest_file_path="/backup/archive.zip",
|
||||
)
|
||||
|
||||
assert result == "Compressed to: /backup/archive.zip"
|
||||
|
||||
# Verify DSM call parameters
|
||||
start_call = client.request.call_args_list[0]
|
||||
assert start_call[0][0] == "SYNO.FileStation.Compress"
|
||||
assert start_call[0][1] == "start"
|
||||
assert start_call[1]["version"] == 3
|
||||
p = start_call[1]["params"]
|
||||
assert json.loads(p["path"]) == ["/data/report.pdf", "/data/photos"]
|
||||
assert json.loads(p["dest_file_path"]) == "/backup/archive.zip"
|
||||
assert p["level"] == "moderate"
|
||||
assert p["mode"] == "add"
|
||||
assert p["format"] == "zip"
|
||||
assert p["compress_password"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_polling_multiple_rounds(config: AppConfig) -> None:
|
||||
"""compress returns success after multiple polling rounds."""
|
||||
client = MagicMock()
|
||||
poll_calls = 0
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
nonlocal poll_calls
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_compress2"}
|
||||
if method == "status":
|
||||
poll_calls += 1
|
||||
return {"finished": poll_calls >= 3}
|
||||
return {}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["compress"](
|
||||
paths=["/data/big-folder"],
|
||||
dest_file_path="/backup/big.7z",
|
||||
format="7z",
|
||||
level="maximum",
|
||||
)
|
||||
|
||||
assert result == "Compressed to: /backup/big.7z"
|
||||
assert poll_calls == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_dsm_error_on_start(config: AppConfig) -> None:
|
||||
"""compress returns Error: when the start call fails."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(side_effect=SynologyError("No write permission", code=1801))
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["compress"](
|
||||
paths=["/data/file.txt"],
|
||||
dest_file_path="/backup/out.zip",
|
||||
)
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "permission" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_invalid_level(config: AppConfig) -> None:
|
||||
"""compress rejects unknown level values before making any DSM call."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock()
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["compress"](
|
||||
paths=["/data/file.txt"],
|
||||
dest_file_path="/backup/out.zip",
|
||||
level="ultra",
|
||||
)
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "level" in result
|
||||
client.request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_invalid_format(config: AppConfig) -> None:
|
||||
"""compress rejects unknown format values before making any DSM call."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock()
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["compress"](
|
||||
paths=["/data/file.txt"],
|
||||
dest_file_path="/backup/out.zip",
|
||||
format="tar.gz",
|
||||
)
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "format" in result
|
||||
client.request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_empty_paths(config: AppConfig) -> None:
|
||||
"""compress rejects an empty paths list before making any DSM call."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock()
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["compress"](paths=[], dest_file_path="/backup/out.zip")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "paths" in result.lower() or "empty" in result.lower()
|
||||
client.request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_timeout(config: AppConfig) -> None:
|
||||
"""compress returns an error after polling times out."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_compress_timeout"}
|
||||
return {"finished": False}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["compress"](
|
||||
paths=["/data/huge"],
|
||||
dest_file_path="/backup/huge.zip",
|
||||
)
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "timed out" in result.lower() or "60 seconds" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# extract
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_success(config: AppConfig) -> None:
|
||||
"""extract polls until finished and returns the dest_folder_path from status."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_extract1"}
|
||||
if method == "status":
|
||||
return {
|
||||
"finished": True,
|
||||
"dest_folder_path": "/data/extracted",
|
||||
"path": "/backup/archive.zip",
|
||||
"progress": 1,
|
||||
}
|
||||
return {}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["extract"](
|
||||
file_path="/backup/archive.zip",
|
||||
dest_folder_path="/data/extracted",
|
||||
)
|
||||
|
||||
assert result == "Extracted to: /data/extracted"
|
||||
|
||||
# Verify DSM call parameters
|
||||
start_call = client.request.call_args_list[0]
|
||||
assert start_call[0][0] == "SYNO.FileStation.Extract"
|
||||
assert start_call[0][1] == "start"
|
||||
assert start_call[1]["version"] == 2
|
||||
p = start_call[1]["params"]
|
||||
assert json.loads(p["file_path"]) == "/backup/archive.zip"
|
||||
assert json.loads(p["dest_folder_path"]) == "/data/extracted"
|
||||
assert p["overwrite"] == "false"
|
||||
assert p["keep_dir"] == "true"
|
||||
assert p["create_subfolder"] == "false"
|
||||
assert p["codepage"] == "enu"
|
||||
assert p["password"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_overwrite_and_subfolder(config: AppConfig) -> None:
|
||||
"""extract passes overwrite=true and create_subfolder=true when requested."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_extract2"}
|
||||
return {"finished": True, "dest_folder_path": "/data/out"}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
await tools["extract"](
|
||||
file_path="/backup/archive.zip",
|
||||
dest_folder_path="/data/out",
|
||||
overwrite=True,
|
||||
create_subfolder=True,
|
||||
)
|
||||
|
||||
p = client.request.call_args_list[0][1]["params"]
|
||||
assert p["overwrite"] == "true"
|
||||
assert p["create_subfolder"] == "true"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_dest_folder_from_status(config: AppConfig) -> None:
|
||||
"""extract uses dest_folder_path from status response when available."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_extract3"}
|
||||
return {"finished": True, "dest_folder_path": "/data/real-dest"}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["extract"](
|
||||
file_path="/backup/archive.zip",
|
||||
dest_folder_path="/data/requested",
|
||||
)
|
||||
|
||||
# Should report what DSM confirmed, not what we requested
|
||||
assert result == "Extracted to: /data/real-dest"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_dsm_error_on_start(config: AppConfig) -> None:
|
||||
"""extract returns Error: when the start call fails (e.g. bad path)."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(side_effect=SynologyError("File or folder not found", code=1800))
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["extract"](
|
||||
file_path="/backup/missing.zip",
|
||||
dest_folder_path="/data/out",
|
||||
)
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "not found" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_timeout(config: AppConfig) -> None:
|
||||
"""extract returns an error after polling times out."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_extract_timeout"}
|
||||
return {"finished": False, "progress": 0.1}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["extract"](
|
||||
file_path="/backup/huge.zip",
|
||||
dest_folder_path="/data/out",
|
||||
)
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "timed out" in result.lower() or "60 seconds" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_exist_empty_path(config: AppConfig) -> None:
|
||||
"""check_exist returns Error: when no path is given."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock()
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["check_exist"](path=" ")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
client.request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_exist_dsm_error(config: AppConfig) -> None:
|
||||
"""check_exist propagates DSM errors as Error: messages."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(side_effect=SynologyError("Permission denied", code=105))
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
result = await tools["check_exist"](path="/docker")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "Permission denied" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_exist_uses_getinfo(config: AppConfig) -> None:
|
||||
"""check_exist uses SYNO.FileStation.List::getinfo as its DSM backend."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(
|
||||
return_value={
|
||||
"files": [
|
||||
{"path": "/docker", "name": "docker", "isdir": True, "additional": {}},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
await tools["check_exist"](path="/docker")
|
||||
|
||||
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"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# dir_size
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dir_size_success(config: AppConfig) -> None:
|
||||
"""dir_size polls until finished and returns a formatted table."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_dirsize1"}
|
||||
if method == "status":
|
||||
return {
|
||||
"finished": True,
|
||||
"num_dir": 4,
|
||||
"num_file": 23,
|
||||
"total_size": 5_242_880,
|
||||
}
|
||||
return {}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["dir_size"](path="/data")
|
||||
|
||||
assert "Folders" in result
|
||||
assert "Files" in result
|
||||
assert "Total Size" in result
|
||||
assert "4" in result
|
||||
assert "23" in result
|
||||
assert "5 MB" in result or "MB" in result
|
||||
|
||||
# Verify DSM call params
|
||||
start_call = client.request.call_args_list[0]
|
||||
assert start_call[0][0] == "SYNO.FileStation.DirSize"
|
||||
assert start_call[0][1] == "start"
|
||||
assert start_call[1]["version"] == 2
|
||||
assert json.loads(start_call[1]["params"]["path"]) == ["/data"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dir_size_multi_path(config: AppConfig) -> None:
|
||||
"""dir_size passes all comma-separated paths as a JSON array."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_dirsize2"}
|
||||
return {"finished": True, "num_dir": 1, "num_file": 2, "total_size": 1024}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["dir_size"](path="/data, /backup")
|
||||
|
||||
start_params = client.request.call_args_list[0][1]["params"]
|
||||
assert json.loads(start_params["path"]) == ["/data", "/backup"]
|
||||
assert "/data" in result
|
||||
assert "/backup" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dir_size_dsm_error_on_start(config: AppConfig) -> None:
|
||||
"""dir_size returns Error: when start fails."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(side_effect=SynologyError("File or folder not found", code=1800))
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["dir_size"](path="/missing")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "not found" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dir_size_timeout(config: AppConfig) -> None:
|
||||
"""dir_size returns Error: after polling times out."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_dirsize_timeout"}
|
||||
return {"finished": False, "num_dir": 0, "num_file": 0, "total_size": 0}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["dir_size"](path="/huge")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "timed out" in result.lower() or "60 seconds" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dir_size_empty_path(config: AppConfig) -> None:
|
||||
"""dir_size returns Error: for blank path without making a DSM call."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock()
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
result = await tools["dir_size"](path=" ")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
client.request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dir_size_retries_on_transient_599(config: AppConfig) -> None:
|
||||
"""dir_size retries up to 4 times on code-599 then succeeds on 5th status call."""
|
||||
client = MagicMock()
|
||||
call_count = {"status": 0}
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_dirsize_599"}
|
||||
call_count["status"] += 1
|
||||
if call_count["status"] < 4:
|
||||
raise SynologyError("DSM error code 599", code=599)
|
||||
return {"finished": True, "num_dir": 2, "num_file": 10, "total_size": 1024}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["dir_size"](path="/data")
|
||||
|
||||
assert "Total Size" in result
|
||||
assert call_count["status"] == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dir_size_fails_after_5_consecutive_599(config: AppConfig) -> None:
|
||||
"""dir_size gives up and returns Error: after exhausting all restart attempts."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_dirsize_dead"}
|
||||
raise SynologyError("DSM error code 599", code=599)
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["dir_size"](path="/dead")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dir_size_cold_start_restart(config: AppConfig) -> None:
|
||||
"""dir_size restarts the task after 5 consecutive 599s and succeeds on second attempt."""
|
||||
client = MagicMock()
|
||||
start_count = {"n": 0}
|
||||
status_count = {"n": 0}
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
start_count["n"] += 1
|
||||
return {"taskid": f"task_{start_count['n']}"}
|
||||
status_count["n"] += 1
|
||||
# First 5 status calls → 599 (simulates cold start)
|
||||
if status_count["n"] <= 5:
|
||||
raise SynologyError("DSM error code 599", code=599)
|
||||
# After restart: immediately done
|
||||
return {"finished": True, "num_dir": 1, "num_file": 5, "total_size": 1024}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["dir_size"](path="/coldstart")
|
||||
|
||||
assert "Total Size" in result
|
||||
assert start_count["n"] == 2 # task was restarted once after cold-start 599s
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# get_md5
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_md5_success(config: AppConfig) -> None:
|
||||
"""get_md5 polls until finished and returns the MD5 string."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_md5_1"}
|
||||
if method == "status":
|
||||
return {"finished": True, "md5": "d41d8cd98f00b204e9800998ecf8427e"}
|
||||
return {}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["get_md5"](path="/data/file.zip")
|
||||
|
||||
assert result == "MD5 of /data/file.zip: d41d8cd98f00b204e9800998ecf8427e"
|
||||
|
||||
# Verify DSM call params
|
||||
start_call = client.request.call_args_list[0]
|
||||
assert start_call[0][0] == "SYNO.FileStation.MD5"
|
||||
assert start_call[0][1] == "start"
|
||||
assert start_call[1]["version"] == 2
|
||||
assert json.loads(start_call[1]["params"]["file_path"]) == "/data/file.zip"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_md5_dsm_error_on_start(config: AppConfig) -> None:
|
||||
"""get_md5 returns Error: when start fails (e.g. file not found)."""
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(side_effect=SynologyError("File or folder not found", code=1800))
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["get_md5"](path="/data/missing.zip")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "not found" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_md5_timeout(config: AppConfig) -> None:
|
||||
"""get_md5 returns Error: after polling times out."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_md5_timeout"}
|
||||
return {"finished": False}
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["get_md5"](path="/data/huge.iso")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "timed out" in result.lower() or "60 seconds" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_md5_missing_hash_in_response(config: AppConfig) -> None:
|
||||
"""get_md5 returns Error: when finished status contains no md5 field."""
|
||||
client = MagicMock()
|
||||
|
||||
async def _request(api, method, version=None, params=None, **kwargs):
|
||||
if method == "start":
|
||||
return {"taskid": "FileStation_md5_nohash"}
|
||||
return {"finished": True} # md5 field absent
|
||||
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
tools = _make_mcp_and_tools(config, client)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
result = await tools["get_md5"](path="/data/file.zip")
|
||||
|
||||
assert result.startswith("Error:")
|
||||
assert "md5" in result.lower() or "hash" in result.lower()
|
||||
|
||||
Reference in New Issue
Block a user