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:
@@ -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}"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user