feat: add compress and extract tools
Implements SYNO.FileStation.Compress (v3) and SYNO.FileStation.Extract (v2) with async polling identical to copy/move. Includes input validation for compress (level, mode, format, empty paths) and 11 new unit tests. Bumps version to 0.2.0. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcp-synology-filestation"
|
name = "mcp-synology-filestation"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "MCP server for Synology FileStation"
|
description = "MCP server for Synology FileStation"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -801,6 +801,137 @@ def register_filestation(
|
|||||||
|
|
||||||
return f"Deleted: {path}"
|
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 = "",
|
||||||
|
) -> str:
|
||||||
|
"""Compress files or folders into an archive on the NAS.
|
||||||
|
|
||||||
|
Creates a new archive asynchronously. Progress is polled until the operation
|
||||||
|
completes. Use share paths as returned by list_shares.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paths: List of share-relative paths to include in the archive
|
||||||
|
(e.g. ["/data/report.pdf", "/data/photos"]).
|
||||||
|
dest_file_path: Full destination path including filename
|
||||||
|
(e.g. "/backup/archive.zip").
|
||||||
|
level: Compression level — "store", "fastest", "fast", "normal",
|
||||||
|
"moderate" (default), or "maximum".
|
||||||
|
mode: Archive write mode — "add" (default), "update", or "refreshen".
|
||||||
|
format: Archive format — "zip" (default) or "7z".
|
||||||
|
password: Optional password to encrypt the archive (default: none).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path of the created archive on success, or an Error: message.
|
||||||
|
"""
|
||||||
|
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 = "",
|
||||||
|
) -> str:
|
||||||
|
"""Extract an archive file to a destination folder on the NAS.
|
||||||
|
|
||||||
|
Supports ZIP and 7z archives. Runs asynchronously; progress is polled
|
||||||
|
until the extraction completes. Use share paths as returned by list_shares.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Share-relative path to the archive file
|
||||||
|
(e.g. "/backup/archive.zip").
|
||||||
|
dest_folder_path: Destination folder for extracted contents
|
||||||
|
(e.g. "/data/extracted").
|
||||||
|
overwrite: Replace existing files at the destination (default False).
|
||||||
|
keep_dir: Preserve the directory structure inside the archive
|
||||||
|
(default True).
|
||||||
|
create_subfolder: Create a subfolder named after the archive to hold
|
||||||
|
extracted contents (default False).
|
||||||
|
password: Password for encrypted archives (default: none).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Destination folder path on success, or an Error: message.
|
||||||
|
"""
|
||||||
|
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()
|
@mcp.tool()
|
||||||
async def upload(
|
async def upload(
|
||||||
path: str,
|
path: str,
|
||||||
|
|||||||
@@ -1152,6 +1152,305 @@ async def test_check_exist_multi_path(config: AppConfig) -> None:
|
|||||||
assert "/ghost" 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
|
@pytest.mark.asyncio
|
||||||
async def test_check_exist_empty_path(config: AppConfig) -> None:
|
async def test_check_exist_empty_path(config: AppConfig) -> None:
|
||||||
"""check_exist returns Error: when no path is given."""
|
"""check_exist returns Error: when no path is given."""
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp-synology-filestation"
|
name = "mcp-synology-filestation"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user