From 80ac89416557f6a42d2058f2399b6980bcfba75d Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Tue, 14 Apr 2026 10:28:32 +0200 Subject: [PATCH] feat: add create_folder, rename, copy, move, delete, upload tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All path/name params are json.dumps-wrapped per confirmed DSM behaviour. copy and move use async polling via a shared _poll_task helper (exponential backoff 200ms→2s, 60s timeout). delete requires confirmed=True; without it only a preview is returned and no DSM call is made. upload decodes base64 and enforces a 50 MB cap. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 6 + .../tools/filestation.py | 306 ++++++++++++++- tests/test_tools_filestation.py | 366 ++++++++++++++++++ 3 files changed, 677 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 303b5de..c1fd43b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,5 +97,11 @@ src/mcp_synology_filestation/ | `get_info` | Get detailed metadata for one or more paths | | `search` | Search for files by glob pattern with async polling | | `download` | Download a file as base64 (max 10 MB) | +| `create_folder` | Create a new folder (optionally with parent dirs) | +| `rename` | Rename a file or folder | +| `copy` | Copy a file or folder (async polling, overwrite=False default) | +| `move` | Move a file or folder (async polling, overwrite=False default) | +| `delete` | Delete a file or folder — requires confirmed=True | +| `upload` | Upload base64-encoded content to a path (max 50 MB) | See [SPEC.md](SPEC.md) for the full planned tool set. diff --git a/src/mcp_synology_filestation/tools/filestation.py b/src/mcp_synology_filestation/tools/filestation.py index 8ee9639..a8a5f56 100644 --- a/src/mcp_synology_filestation/tools/filestation.py +++ b/src/mcp_synology_filestation/tools/filestation.py @@ -6,7 +6,7 @@ import asyncio import contextlib import json import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from mcp.server.fastmcp import FastMCP @@ -59,6 +59,51 @@ def register_filestation( client: FileStationClient for DSM API calls. """ + # ── internal polling helper ─────────────────────────────────────────── + + 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. + + 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. + + Returns: + ``(True, status_dict)`` on success, or ``(False, "Error: …")`` on + DSM error or timeout. + """ + from mcp_synology_filestation.client import SynologyError as _SynologyError + + delay = 0.2 + elapsed = 0.0 + timeout = 60.0 + + while True: + await asyncio.sleep(delay) + elapsed += delay + + try: + status_data = await client.request( + api, + "status", + version=version, + params={"taskid": taskid}, + ) + except _SynologyError as e: + return False, f"Error: {e}" + + if status_data.get("finished"): + return True, status_data + + if elapsed >= timeout: + return ( + False, + "Error: Operation timed out after 60 seconds — check NAS manually.", + ) + + delay = min(delay * 2, 2.0) + @mcp.tool() async def list_shares() -> str: """List all shared folders visible to the authenticated user. @@ -492,3 +537,262 @@ def register_filestation( lines.append(f"\n{len(rows)} item(s).") return "\n".join(lines) + + # ── write tools ─────────────────────────────────────────────────────── + + @mcp.tool() + async def create_folder( + 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. + """ + from mcp_synology_filestation.client import SynologyError + + try: + data = await client.request( + "SYNO.FileStation.CreateFolder", + "create", + params={ + "folder_path": json.dumps(path), + "name": json.dumps(name), + "force_parent": "true" if create_parents else "false", + }, + ) + except SynologyError as e: + return f"Error: {e}" + + folders = data.get("folders", []) + created_path = folders[0].get("path", f"{path}/{name}") if folders else f"{path}/{name}" + 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. + """ + from mcp_synology_filestation.client import SynologyError + + try: + data = await client.request( + "SYNO.FileStation.Rename", + "rename", + params={ + "path": json.dumps(path), + "name": json.dumps(new_name), + }, + ) + except SynologyError as e: + return f"Error: {e}" + + files = data.get("files", []) + parent = path.rsplit("/", 1)[0] or "/" + new_path = files[0].get("path", f"{parent}/{new_name}") if files else f"{parent}/{new_name}" + 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. + """ + from mcp_synology_filestation.client import SynologyError + + try: + start_data = await client.request( + "SYNO.FileStation.CopyMove", + "start", + version=3, + params={ + "path": json.dumps(src), + "dest_folder_path": json.dumps(dst), + "overwrite": "true" if overwrite else "false", + "remove_src": "false", + }, + ) + 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.CopyMove", 3, taskid) + if not ok: + return result # type: ignore[return-value] + + status: dict[str, Any] = result # type: ignore[assignment] + dest_folder = status.get("dest_folder_path", dst) + filename = src.rsplit("/", 1)[-1] + 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. + """ + from mcp_synology_filestation.client import SynologyError + + try: + start_data = await client.request( + "SYNO.FileStation.CopyMove", + "start", + version=3, + params={ + "path": json.dumps(src), + "dest_folder_path": json.dumps(dst), + "overwrite": "true" if overwrite else "false", + "remove_src": "true", + }, + ) + except SynologyError as e: + return f"Error: {e}" + + taskid = start_data.get("taskid", "") + if not taskid: + return "Error: DSM did not return a task ID." + + ok, result = await _poll_task("SYNO.FileStation.CopyMove", 3, taskid) + if not ok: + return result # type: ignore[return-value] + + status: dict[str, Any] = result # type: ignore[assignment] + dest_folder = status.get("dest_folder_path", dst) + filename = src.rsplit("/", 1)[-1] + 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. + """ + from mcp_synology_filestation.client import SynologyError + + if not confirmed: + return ( + f"Would permanently delete: {path}\n" + "This cannot be undone. Pass confirmed=True to proceed." + ) + + try: + start_data = await client.request( + "SYNO.FileStation.Delete", + "start", + params={ + "path": json.dumps(path), + "recursive": "true", + "accurate_progress": "false", + }, + ) + except SynologyError as e: + return f"Error: {e}" + + taskid = start_data.get("taskid", "") + if not taskid: + return "Error: DSM did not return a task ID." + + ok, result = await _poll_task("SYNO.FileStation.Delete", 2, taskid) + if not ok: + return result # type: ignore[return-value] + + return f"Deleted: {path}" + + @mcp.tool() + async def upload( + path: str, + filename: str, + 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. + """ + import base64 + + from mcp_synology_filestation.client import SynologyError + + max_upload_bytes = 50 * 1024 * 1024 # 50 MB + + try: + content = base64.b64decode(content_base64) + except Exception: + return "Error: content_base64 is not valid base64." + + if len(content) > max_upload_bytes: + return ( + f"Error: File is {_fmt_size(len(content))}, which exceeds the 50 MB limit " + "for MCP uploads. Use SFTP or another file-transfer method instead." + ) + + try: + await client.upload_bytes( + dest_folder=path, + filename=filename, + content=content, + overwrite=overwrite, + create_parents=create_parents, + ) + except SynologyError as e: + return f"Error: {e}" + + return f"Uploaded: {path}/{filename}" diff --git a/tests/test_tools_filestation.py b/tests/test_tools_filestation.py index 4304b6a..5985841 100644 --- a/tests/test_tools_filestation.py +++ b/tests/test_tools_filestation.py @@ -709,3 +709,369 @@ async def test_download_exactly_10mb(config: AppConfig) -> None: parsed = json.loads(result) assert parsed["size"] == len(content) + + +# ────────────────────────────────────────────────────────────────────────── +# create_folder +# ────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_create_folder_success(config: AppConfig) -> None: + """create_folder returns the path of the created folder.""" + client = MagicMock() + client.request = AsyncMock( + return_value={"folders": [{"isdir": True, "name": "new-app", "path": "/docker/new-app"}]} + ) + + tools = _make_mcp_and_tools(config, client) + result = await tools["create_folder"](path="/docker", name="new-app") + + assert result == "Created: /docker/new-app" + call_params = client.request.call_args[1]["params"] + assert call_params["folder_path"] == json.dumps("/docker") + assert call_params["name"] == json.dumps("new-app") + assert call_params["force_parent"] == "false" + + +@pytest.mark.asyncio +async def test_create_folder_dsm_error(config: AppConfig) -> None: + """create_folder returns Error: on SynologyError.""" + client = MagicMock() + client.request = AsyncMock(side_effect=SynologyError("No write permission", code=1801)) + + tools = _make_mcp_and_tools(config, client) + result = await tools["create_folder"](path="/docker", name="new-app") + + assert result.startswith("Error:") + assert "permission" in result.lower() + + +@pytest.mark.asyncio +async def test_create_folder_create_parents(config: AppConfig) -> None: + """create_folder passes force_parent=true when create_parents=True.""" + client = MagicMock() + client.request = AsyncMock(return_value={"folders": []}) + + tools = _make_mcp_and_tools(config, client) + await tools["create_folder"](path="/docker/deep/path", name="new-app", create_parents=True) + + call_params = client.request.call_args[1]["params"] + assert call_params["force_parent"] == "true" + + +# ────────────────────────────────────────────────────────────────────────── +# rename +# ────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_rename_success(config: AppConfig) -> None: + """rename returns the new path of the renamed item.""" + client = MagicMock() + client.request = AsyncMock( + return_value={"files": [{"isdir": False, "name": "new.yaml", "path": "/docker/new.yaml"}]} + ) + + tools = _make_mcp_and_tools(config, client) + result = await tools["rename"](path="/docker/old.yaml", new_name="new.yaml") + + assert result == "Renamed to: /docker/new.yaml" + call_params = client.request.call_args[1]["params"] + assert call_params["path"] == json.dumps("/docker/old.yaml") + assert call_params["name"] == json.dumps("new.yaml") + + +@pytest.mark.asyncio +async def test_rename_dsm_error(config: AppConfig) -> None: + """rename returns Error: on SynologyError.""" + client = MagicMock() + client.request = AsyncMock(side_effect=SynologyError("File or folder not found", code=1800)) + + tools = _make_mcp_and_tools(config, client) + result = await tools["rename"](path="/docker/missing.yaml", new_name="new.yaml") + + assert result.startswith("Error:") + assert "not found" in result.lower() + + +# ────────────────────────────────────────────────────────────────────────── +# copy +# ────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_copy_success_with_polling(config: AppConfig) -> None: + """copy polls two rounds then returns the destination path.""" + client = MagicMock() + poll_calls = 0 + + async def _request(api, method, version=None, params=None, **kwargs): + nonlocal poll_calls + if method == "start": + return {"taskid": "FileStation_copy1"} + if method == "status": + poll_calls += 1 + finished = poll_calls >= 2 + return { + "finished": finished, + "progress": 1.0 if finished else 0.5, + "dest_folder_path": "/backup/docker", + } + return {} + + client.request = AsyncMock(side_effect=_request) + + tools = _make_mcp_and_tools(config, client) + with patch("asyncio.sleep", new_callable=AsyncMock): + result = await tools["copy"](src="/docker/app/compose.yaml", dst="/backup/docker") + + assert result == "Copied to: /backup/docker/compose.yaml" + assert poll_calls == 2 + # Verify start used correct params + start_call = client.request.call_args_list[0] + assert start_call[0][0] == "SYNO.FileStation.CopyMove" + assert start_call[0][1] == "start" + start_params = start_call[1]["params"] + assert start_params["path"] == json.dumps("/docker/app/compose.yaml") + assert start_params["dest_folder_path"] == json.dumps("/backup/docker") + assert start_params["remove_src"] == "false" + assert start_params["overwrite"] == "false" + + +@pytest.mark.asyncio +async def test_copy_timeout(config: AppConfig) -> None: + """copy returns an error message after polling times out.""" + client = MagicMock() + + async def _request(api, method, version=None, params=None, **kwargs): + if method == "start": + return {"taskid": "FileStation_copy_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["copy"](src="/docker/big.tar", dst="/backup") + + assert result.startswith("Error:") + assert "timed out" in result.lower() or "60 seconds" in result + + +@pytest.mark.asyncio +async def test_copy_dsm_error_on_start(config: AppConfig) -> None: + """copy 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["copy"](src="/docker/app.yaml", dst="/backup") + + assert result.startswith("Error:") + + +# ────────────────────────────────────────────────────────────────────────── +# move +# ────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_move_success(config: AppConfig) -> None: + """move returns destination path and passes remove_src=true.""" + client = MagicMock() + + async def _request(api, method, version=None, params=None, **kwargs): + if method == "start": + return {"taskid": "FileStation_move1"} + return {"finished": True, "dest_folder_path": "/archive/docker"} + + client.request = AsyncMock(side_effect=_request) + + tools = _make_mcp_and_tools(config, client) + with patch("asyncio.sleep", new_callable=AsyncMock): + result = await tools["move"](src="/docker/old.yaml", dst="/archive/docker") + + assert result == "Moved to: /archive/docker/old.yaml" + start_params = client.request.call_args_list[0][1]["params"] + assert start_params["remove_src"] == "true" + assert start_params["path"] == json.dumps("/docker/old.yaml") + assert start_params["dest_folder_path"] == json.dumps("/archive/docker") + + +@pytest.mark.asyncio +async def test_move_dsm_error(config: AppConfig) -> None: + """move returns Error: when the start call 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["move"](src="/docker/missing.yaml", dst="/backup") + + assert result.startswith("Error:") + assert "not found" in result.lower() + + +# ────────────────────────────────────────────────────────────────────────── +# delete +# ────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_delete_preview_no_dsm_call(config: AppConfig) -> None: + """delete with confirmed=False returns a preview and makes no DSM requests.""" + client = MagicMock() + client.request = AsyncMock() + + tools = _make_mcp_and_tools(config, client) + result = await tools["delete"](path="/docker/app", confirmed=False) + + client.request.assert_not_called() + assert "/docker/app" in result + assert "confirmed=True" in result + + +@pytest.mark.asyncio +async def test_delete_confirmed_with_polling(config: AppConfig) -> None: + """delete with confirmed=True polls until finished and returns success.""" + client = MagicMock() + poll_calls = 0 + + async def _request(api, method, version=None, params=None, **kwargs): + nonlocal poll_calls + if method == "start": + return {"taskid": "FileStation_del1"} + if method == "status": + poll_calls += 1 + return {"finished": poll_calls >= 2, "processed_num": poll_calls} + return {} + + client.request = AsyncMock(side_effect=_request) + + tools = _make_mcp_and_tools(config, client) + with patch("asyncio.sleep", new_callable=AsyncMock): + result = await tools["delete"](path="/docker/old-app", confirmed=True) + + assert result == "Deleted: /docker/old-app" + start_params = client.request.call_args_list[0][1]["params"] + assert start_params["path"] == json.dumps("/docker/old-app") + assert start_params["recursive"] == "true" + + +@pytest.mark.asyncio +async def test_delete_dsm_error_on_start(config: AppConfig) -> None: + """delete returns Error: when the start call fails.""" + client = MagicMock() + client.request = AsyncMock( + side_effect=SynologyError("Permission denied — check DSM user permissions", code=105) + ) + + tools = _make_mcp_and_tools(config, client) + with patch("asyncio.sleep", new_callable=AsyncMock): + result = await tools["delete"](path="/docker/app", confirmed=True) + + assert result.startswith("Error:") + assert "Permission denied" in result + + +# ────────────────────────────────────────────────────────────────────────── +# upload +# ────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_upload_success(config: AppConfig) -> None: + """upload decodes base64, calls upload_bytes, and returns the full path.""" + import base64 as _b64 + + raw = b"version: '3'\nservices:\n app:\n image: nginx\n" + encoded = _b64.b64encode(raw).decode() + + client = MagicMock() + client.upload_bytes = AsyncMock(return_value={}) + + tools = _make_mcp_and_tools(config, client) + result = await tools["upload"]( + path="/docker/app", filename="compose.yaml", content_base64=encoded + ) + + assert result == "Uploaded: /docker/app/compose.yaml" + client.upload_bytes.assert_called_once() + call_kwargs = client.upload_bytes.call_args[1] + assert call_kwargs["dest_folder"] == "/docker/app" + assert call_kwargs["filename"] == "compose.yaml" + assert call_kwargs["content"] == raw + assert call_kwargs["overwrite"] is False + + +@pytest.mark.asyncio +async def test_upload_too_large(config: AppConfig) -> None: + """upload returns Error: when decoded content exceeds 50 MB.""" + import base64 as _b64 + + large = _b64.b64encode(b"x" * (50 * 1024 * 1024 + 1)).decode() + + client = MagicMock() + client.upload_bytes = AsyncMock(return_value={}) + + tools = _make_mcp_and_tools(config, client) + result = await tools["upload"](path="/data", filename="big.bin", content_base64=large) + + assert result.startswith("Error:") + assert "50 MB" in result or "exceeds" in result + client.upload_bytes.assert_not_called() + + +@pytest.mark.asyncio +async def test_upload_create_parents(config: AppConfig) -> None: + """upload passes create_parents=True to upload_bytes.""" + import base64 as _b64 + + client = MagicMock() + client.upload_bytes = AsyncMock(return_value={}) + + tools = _make_mcp_and_tools(config, client) + await tools["upload"]( + path="/docker/deep/path", + filename="file.txt", + content_base64=_b64.b64encode(b"hello").decode(), + create_parents=True, + ) + + call_kwargs = client.upload_bytes.call_args[1] + assert call_kwargs["create_parents"] is True + + +@pytest.mark.asyncio +async def test_upload_invalid_base64(config: AppConfig) -> None: + """upload returns Error: when content_base64 is not valid base64.""" + client = MagicMock() + client.upload_bytes = AsyncMock(return_value={}) + + tools = _make_mcp_and_tools(config, client) + result = await tools["upload"](path="/docker", filename="f.txt", content_base64="not-base64!!!") + + assert result.startswith("Error:") + assert "base64" in result.lower() + client.upload_bytes.assert_not_called() + + +@pytest.mark.asyncio +async def test_upload_dsm_error(config: AppConfig) -> None: + """upload returns Error: on SynologyError from upload_bytes.""" + import base64 as _b64 + + client = MagicMock() + client.upload_bytes = AsyncMock(side_effect=SynologyError("No write permission", code=1801)) + + tools = _make_mcp_and_tools(config, client) + result = await tools["upload"]( + path="/docker", + filename="compose.yaml", + content_base64=_b64.b64encode(b"data").decode(), + ) + + assert result.startswith("Error:") + assert "permission" in result.lower()