feat: add create_folder, rename, copy, move, delete, upload tools

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 10:28:32 +02:00
parent 544cfb8b06
commit 80ac894165
3 changed files with 677 additions and 1 deletions
+6
View File
@@ -97,5 +97,11 @@ src/mcp_synology_filestation/
| `get_info` | Get detailed metadata for one or more paths | | `get_info` | Get detailed metadata for one or more paths |
| `search` | Search for files by glob pattern with async polling | | `search` | Search for files by glob pattern with async polling |
| `download` | Download a file as base64 (max 10 MB) | | `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. See [SPEC.md](SPEC.md) for the full planned tool set.
@@ -6,7 +6,7 @@ import asyncio
import contextlib import contextlib
import json import json
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
@@ -59,6 +59,51 @@ def register_filestation(
client: FileStationClient for DSM API calls. 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() @mcp.tool()
async def list_shares() -> str: async def list_shares() -> str:
"""List all shared folders visible to the authenticated user. """List all shared folders visible to the authenticated user.
@@ -492,3 +537,262 @@ def register_filestation(
lines.append(f"\n{len(rows)} item(s).") lines.append(f"\n{len(rows)} item(s).")
return "\n".join(lines) 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}"
+366
View File
@@ -709,3 +709,369 @@ async def test_download_exactly_10mb(config: AppConfig) -> None:
parsed = json.loads(result) parsed = json.loads(result)
assert parsed["size"] == len(content) 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()