feat: v0.3.2 — delete_project tool
Closes the project lifecycle (create → start/stop/redeploy → delete). The tool calls SYNO.Docker.Project/delete with the UUID JSON-encoded as the `id` parameter (per DSM convention) and removes only the Container Manager registration — the project folder and compose file remain on the NAS. This mirrors DSM's own "Delete project" behaviour, not a bug; the success message states the folder was preserved so the user is not surprised. 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. - No automatic stop. If the project is RUNNING and DSM rejects the delete, the response tells the user to `stop_project` first rather than silently halting containers under the guise of a "delete" call. - Requires confirmed=True; preview shows name, UUID, status, full path, and share path so the user can verify before deleting. Tests cover preview-only, not-found, invalid-name, happy path (verifies the UUID is JSON-encoded in the delete call), and the running-project rejection path that surfaces the stop_project hint. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+24
-1
@@ -2,7 +2,30 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
### Added
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ Only a second consecutive failure is treated as a real auth problem.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implemented tools (24)
|
## Implemented tools (25)
|
||||||
|
|
||||||
| Category | Tools |
|
| 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` |
|
| 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` |
|
| Compose | `read_compose`, `update_compose`, `update_image_tag`, `update_env_var` |
|
||||||
| Images | `check_image_updates`, `list_images`, `delete_image` |
|
| 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
|
## Implementation rules
|
||||||
|
|
||||||
- Confirmation required before destructive operations: `stop_project`,
|
- Confirmation required before destructive operations: `stop_project`,
|
||||||
`redeploy_project`, `create_project`, `exec_in_container`,
|
`redeploy_project`, `create_project`, `delete_project`,
|
||||||
`update_image_tag`, `update_env_var`, `update_compose`,
|
`exec_in_container`, `update_image_tag`, `update_env_var`,
|
||||||
`delete_container`
|
`update_compose`, `delete_container`
|
||||||
- After compose changes: suggest `redeploy_project`
|
- After compose changes: suggest `redeploy_project`
|
||||||
- DSM errors → human-readable message, no stack traces
|
- DSM errors → human-readable message, no stack traces
|
||||||
- No secrets in stderr output
|
- No secrets in stderr output
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.3.1"
|
version = "0.3.2"
|
||||||
description = "MCP server for Synology Container Manager"
|
description = "MCP server for Synology Container Manager"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -372,6 +372,61 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
|
|
||||||
return "\n".join(results)
|
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:
|
async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None:
|
||||||
"""Find a project by name from the list.
|
"""Find a project by name from the list.
|
||||||
|
|||||||
@@ -774,3 +774,144 @@ async def test_create_project_build_stream_failure_keeps_registration():
|
|||||||
assert "registered but was not started" in result
|
assert "registered but was not started" in result
|
||||||
assert "redeploy_project" in result
|
assert "redeploy_project" in result
|
||||||
assert "created and started successfully" not 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
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.3.1"
|
version = "0.3.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user