diff --git a/pyproject.toml b/pyproject.toml index 7a3fc40..13b74db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-synology-filestation" -version = "0.1.0" +version = "0.2.0" description = "MCP server for Synology FileStation" requires-python = ">=3.12" dependencies = [ diff --git a/src/mcp_synology_filestation/tools/filestation.py b/src/mcp_synology_filestation/tools/filestation.py index ae478e1..33670b3 100644 --- a/src/mcp_synology_filestation/tools/filestation.py +++ b/src/mcp_synology_filestation/tools/filestation.py @@ -801,6 +801,137 @@ 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 = "", + ) -> 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() async def upload( path: str, diff --git a/tests/test_tools_filestation.py b/tests/test_tools_filestation.py index 908faa9..03cb379 100644 --- a/tests/test_tools_filestation.py +++ b/tests/test_tools_filestation.py @@ -1152,6 +1152,305 @@ async def test_check_exist_multi_path(config: AppConfig) -> None: 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.""" diff --git a/uv.lock b/uv.lock index 499b734..7df7243 100644 --- a/uv.lock +++ b/uv.lock @@ -362,7 +362,7 @@ wheels = [ [[package]] name = "mcp-synology-filestation" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "click" },