diff --git a/CHANGELOG.md b/CHANGELOG.md index 99d8e71..b1fea9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,30 @@ All notable changes to this project will be documented in this file. -## [0.3.1] - 2026-05-18 +## [0.3.2] - 2026-05-18 + +### Added + +- `delete_project` — remove a Container Manager project's + registration via `SYNO.Docker.Project/delete` with the UUID + JSON-encoded as the `id` parameter (per DSM convention). + Mirrors the "Delete project" action in Container Manager: only the + registration is removed; the project folder and compose file remain + on the NAS. The success message explicitly states the folder was + preserved so the user is not surprised. Closes the project + lifecycle (create → start/stop/redeploy → delete). + Safety: + - Project-name validation runs before any I/O. + - A `_find_project` pre-flight returns "not found" with a clear + message rather than letting DSM reject an unknown UUID. + - The tool deliberately does NOT auto-stop a running project. If + DSM rejects the delete on a `RUNNING` project, the response tells + the user to `stop_project` first rather than silently halting + containers under the guise of a "delete" call. + - Requires `confirmed=True`; the preview shows name, UUID, status, + full path, and share path so the user can verify before deleting. + + ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 5b17cc3..35f880d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,11 +33,11 @@ Only a second consecutive failure is treated as a real auth problem. --- -## Implemented tools (24) +## Implemented tools (25) | Category | Tools | |---|---| -| Projects | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project`, `create_project` | +| Projects | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project`, `create_project`, `delete_project` | | Containers | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats`, `delete_container` | | Compose | `read_compose`, `update_compose`, `update_image_tag`, `update_env_var` | | Images | `check_image_updates`, `list_images`, `delete_image` | @@ -69,9 +69,9 @@ Only a second consecutive failure is treated as a real auth problem. ## Implementation rules - Confirmation required before destructive operations: `stop_project`, - `redeploy_project`, `create_project`, `exec_in_container`, - `update_image_tag`, `update_env_var`, `update_compose`, - `delete_container` + `redeploy_project`, `create_project`, `delete_project`, + `exec_in_container`, `update_image_tag`, `update_env_var`, + `update_compose`, `delete_container` - After compose changes: suggest `redeploy_project` - DSM errors → human-readable message, no stack traces - No secrets in stderr output diff --git a/pyproject.toml b/pyproject.toml index 30e91ae..2fb91a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-synology-container" -version = "0.3.1" +version = "0.3.2" description = "MCP server for Synology Container Manager" requires-python = ">=3.12" dependencies = [ diff --git a/src/mcp_synology_container/modules/projects.py b/src/mcp_synology_container/modules/projects.py index 51c4f9e..e1549bd 100644 --- a/src/mcp_synology_container/modules/projects.py +++ b/src/mcp_synology_container/modules/projects.py @@ -372,6 +372,61 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non return "\n".join(results) + @mcp.tool() + async def delete_project(project_name: str, confirmed: bool = False): + """Remove a project registration from Container Manager. Requires confirmed=True.""" + # Lazy import: see create_project for why this avoids a circular dep. + from mcp_synology_container.dsm_client import SynologyError + from mcp_synology_container.modules.compose import _validate_project_name + + if (err := _validate_project_name(project_name)) is not None: + return err + + project = await _find_project(client, project_name) + if project is None: + return f"Project '{project_name}' not found." + + project_id = project.get("id", "") + status = (project.get("status") or "?").upper() + path = project.get("path", "?") + share_path = project.get("share_path", "?") + + if not confirmed: + return ( + f"About to delete project registration '{project_name}':\n" + f" UUID: {project_id}\n" + f" Status: {status}\n" + f" Path: {path}\n" + f" Share path: {share_path}\n\n" + f"Note: only the Container Manager registration is removed — " + f"the folder and compose file will remain on the NAS.\n\n" + f"Call this tool again with confirmed=True to proceed." + ) + + try: + await client.request( + "SYNO.Docker.Project", + "delete", + version=1, + params={"id": json.dumps(project_id)}, + ) + except SynologyError as e: + # DSM refuses to delete a running project. We deliberately do NOT + # auto-stop — that would be too destructive for a delete tool — + # but we tell the user how to proceed. + if status == "RUNNING": + return ( + f"Cannot delete project '{project_name}' while it is running ({e}).\n" + f"Stop the project first with stop_project." + ) + return f"Error deleting project '{project_name}': {e}" + + return ( + f"Project '{project_name}' deleted (registration removed).\n" + f"Note: the project folder {share_path} was NOT deleted — " + f"its files remain on the NAS." + ) + async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None: """Find a project by name from the list. diff --git a/tests/test_modules/test_projects.py b/tests/test_modules/test_projects.py index 8b7e73b..664649e 100644 --- a/tests/test_modules/test_projects.py +++ b/tests/test_modules/test_projects.py @@ -774,3 +774,144 @@ async def test_create_project_build_stream_failure_keeps_registration(): assert "registered but was not started" in result assert "redeploy_project" in result assert "created and started successfully" not in result + + +# ────────────────────────────────────────────────────────────────────── +# delete_project +# ────────────────────────────────────────────────────────────────────── + + +def make_delete_project_client( + *, + project: dict | None = None, + delete_raises: Exception | None = None, +): + """Stateful mock client for delete_project tests. + + - `project`: the project dict returned by Project/list. None → no + project registered (simulates the "not found" case). + - `delete_raises`: optional exception raised when Project/delete is + called (used to simulate DSM refusing to delete a running project). + """ + client = AsyncMock() + calls: list[tuple[str, str, dict]] = [] + project_deleted = False + + async def mock_request(api, method, version=None, params=None, **kwargs): + nonlocal project_deleted + calls.append((api, method, dict(params or {}))) + if api == "SYNO.Docker.Project" and method == "list": + if project is None or project_deleted: + return {} + return {project["id"]: project} + if api == "SYNO.Docker.Project" and method == "delete": + if delete_raises: + raise delete_raises + project_deleted = True + return {} + return {} + + client.request.side_effect = mock_request + return client, calls + + +SAMPLE_PROJECT_RUNNING = { + "id": "uuid-abc", + "name": "myapp", + "status": "RUNNING", + "path": "/volume1/docker/myapp", + "share_path": "/docker/myapp", + "containerIds": ["c1"], + "services": [], +} + +SAMPLE_PROJECT_STOPPED = { + **SAMPLE_PROJECT_RUNNING, + "status": "STOPPED", + "containerIds": [], +} + + +@pytest.mark.asyncio +async def test_delete_project_preview_only(): + """confirmed=False: no Project/delete call; preview shows UUID and warns about folder.""" + client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED) + tools = make_projects_tools(client) + + result = await tools["delete_project"]("myapp") + + assert "confirmed=True" in result + assert "uuid-abc" in result + assert "myapp" in result + assert "/docker/myapp" in result + # No delete call + methods = [m for _, m, _ in calls] + assert "delete" not in methods + + +@pytest.mark.asyncio +async def test_delete_project_not_found(): + """If the project isn't registered, return a clear 'not found' message — no delete.""" + client, calls = make_delete_project_client(project=None) + tools = make_projects_tools(client) + + result = await tools["delete_project"]("ghost", confirmed=True) + + assert "not found" in result + assert "ghost" in result + methods = [m for _, m, _ in calls] + assert "delete" not in methods + + +@pytest.mark.asyncio +async def test_delete_project_rejects_invalid_name(): + client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED) + tools = make_projects_tools(client) + + result = await tools["delete_project"]("../escape", confirmed=True) + + assert "invalid project name" in result.lower() + # Not even a list call + assert calls == [] + + +@pytest.mark.asyncio +async def test_delete_project_happy_path(): + """confirmed=True with a stopped project: UUID is json.dumps'd; success message + mentions both 'deleted' and the surviving folder path.""" + client, calls = make_delete_project_client(project=SAMPLE_PROJECT_STOPPED) + tools = make_projects_tools(client) + + result = await tools["delete_project"]("myapp", confirmed=True) + + assert "deleted" in result + assert "registration removed" in result + assert "/docker/myapp" in result + assert "NOT deleted" in result + + delete_call = next( + (a, m, p) for a, m, p in calls if a == "SYNO.Docker.Project" and m == "delete" + ) + _api, _method, params = delete_call + # The UUID must arrive JSON-encoded per the reverse-engineered DSM convention. + assert params["id"] == '"uuid-abc"' + + +@pytest.mark.asyncio +async def test_delete_project_running_returns_stop_hint(): + """DSM refusing to delete a running project produces a clean 'stop_project' hint + rather than a raw error.""" + from mcp_synology_container.dsm_client import SynologyError + + client, calls = make_delete_project_client( + project=SAMPLE_PROJECT_RUNNING, + delete_raises=SynologyError("Project is running", code=2103), + ) + tools = make_projects_tools(client) + + result = await tools["delete_project"]("myapp", confirmed=True) + + assert "stop_project" in result + assert "running" in result.lower() + # No "deleted" success line + assert "deleted (registration removed)" not in result diff --git a/uv.lock b/uv.lock index 4fce740..cf7b5ee 100644 --- a/uv.lock +++ b/uv.lock @@ -362,7 +362,7 @@ wheels = [ [[package]] name = "mcp-synology-container" -version = "0.3.1" +version = "0.3.2" source = { editable = "." } dependencies = [ { name = "click" },