Files
mcp-synology-container/src/mcp_synology_container/modules/projects.py
T
marcus 3f73ed0aef 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>
2026-05-18 11:29:09 +02:00

516 lines
22 KiB
Python

"""MCP tools for SYNO.Docker.Project: list, status, start, stop, redeploy, create."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from typing import TYPE_CHECKING, Any
import yaml
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP
from mcp_synology_container.config import AppConfig
from mcp_synology_container.dsm_client import DsmClient
logger = logging.getLogger(__name__)
_POLL_INTERVAL = 2 # seconds between status checks
_POLL_TIMEOUT = 30 # seconds for ordinary start polling
_BUILD_POLL_TIMEOUT = 300 # seconds for build_stream polling (image pull can be slow)
# Statuses that mean "stop polling now — this redeploy is not coming back."
# DSM signals these typically within seconds of build_stream when the image
# pull or container start fails; without an early exit the caller would wait
# the full _BUILD_POLL_TIMEOUT for nothing.
_TERMINAL_FAILURE_STATUSES = frozenset({"BUILD_FAILED", "ERROR"})
def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
"""Register all project management tools with the MCP server."""
@mcp.tool()
async def list_projects():
"""List all Container Manager projects with name, status, path, and container count."""
try:
data = await client.request("SYNO.Docker.Project", "list")
except Exception as e:
return f"Error listing projects: {e}"
projects: dict[str, Any] = data if isinstance(data, dict) else {}
if not projects:
return "No projects found."
lines = ["Projects:", ""]
for _project_id, proj in sorted(projects.items(), key=lambda x: x[1].get("name", "")):
name = proj.get("name", "?")
status = proj.get("status", "?")
path = proj.get("path", "?")
container_count = len(proj.get("containerIds", []))
lines.append(f" {name}")
lines.append(f" Status: {status}")
lines.append(f" Path: {path}")
lines.append(f" Containers: {container_count}")
lines.append("")
return "\n".join(lines).rstrip()
@mcp.tool()
async def get_project_status(project_name: str):
"""Get detailed status and container list for a specific project."""
project = await _find_project(client, project_name)
if project is None:
return f"Project '{project_name}' not found."
return _format_project_detail(project)
@mcp.tool()
async def start_project(project_name: str):
"""Start a Container Manager project."""
project = await _find_project(client, project_name)
if project is None:
return f"Project '{project_name}' not found."
project_id = project.get("id", "")
try:
await client.request(
"SYNO.Docker.Project",
"start",
params={"id": project_id},
)
return f"Project '{project_name}' started successfully."
except Exception as e:
return f"Error starting project '{project_name}': {e}"
@mcp.tool()
async def stop_project(project_name: str, confirmed: bool = False):
"""Stop all containers in a project. Requires confirmed=True."""
if not confirmed:
return (
f"Stopping project '{project_name}' will halt all its containers.\n"
f"Call this tool again with confirmed=True to proceed."
)
project = await _find_project(client, project_name)
if project is None:
return f"Project '{project_name}' not found."
project_id = project.get("id", "")
try:
await client.request(
"SYNO.Docker.Project",
"stop",
params={"id": project_id},
)
return f"Project '{project_name}' stopped successfully."
except Exception as e:
return f"Error stopping project '{project_name}': {e}"
@mcp.tool()
async def redeploy_project(project_name: str, confirmed: bool = False):
"""Pull latest images and restart a project via build_stream. Requires confirmed=True."""
if not confirmed:
return (
f"Redeploying project '{project_name}' will stop and restart all its "
f"containers, pulling the latest images.\n\n"
f"Call this tool again with confirmed=True to proceed."
)
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()
if status not in ("RUNNING", "STOPPED", "BUILD_FAILED", ""):
return (
f"Cannot redeploy '{project_name}': unexpected status '{status}'.\n"
f"Workaround: use stop_project + start_project separately."
)
results: list[str] = []
# Track whether we issued a stop that DSM accepted. Used to give the
# caller an accurate recovery hint if a later step (build_stream)
# fails — the project would be left in STOPPED state.
stop_was_issued = False
try:
# ── Step 1: Stop ──────────────────────────────────────────────────
if status == "STOPPED":
results.append("Step 1/3: Project is STOPPED — skipping stop.")
elif status in ("BUILD_FAILED", ""):
results.append("Step 1/3: Stopping failed build...")
with contextlib.suppress(Exception):
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
stop_was_issued = True
results.append(" Stopped.")
else: # RUNNING
results.append("Step 1/3: Stopping project...")
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
stop_was_issued = True
results.append(" Stopped.")
# ── Step 2: build_stream (pull images + start) ────────────────────
results.append("Step 2/3: Triggering image pull and project start (build_stream)...")
await client.trigger_build_stream(project_id)
results.append(" Build request accepted by DSM.")
# ── Step 3: Poll ──────────────────────────────────────────────────
results.append(
f"Step 3/3: Waiting for project to reach RUNNING state "
f"(up to {_BUILD_POLL_TIMEOUT}s)..."
)
final_status = await _wait_for_project_running(
client, project_name, timeout=_BUILD_POLL_TIMEOUT
)
if final_status == "RUNNING":
results.append(" Project is RUNNING.")
results.append(f"\nProject '{project_name}' redeployed successfully.")
elif final_status in _TERMINAL_FAILURE_STATUSES:
# M-5: DSM signalled a hard failure during polling (e.g.
# image pull failed). Surface it immediately rather than
# waiting for the full timeout.
results.append(f" Redeploy failed — project status is '{final_status}'.")
if final_status == "BUILD_FAILED":
results.append(
" Check the image tag in the compose file "
"(update_image_tag) and retry redeploy_project."
)
results.append(
f"\nProject '{project_name}' redeploy aborted (status: {final_status})."
)
else:
results.append(
f" Warning: project status is '{final_status}' after "
f"{_BUILD_POLL_TIMEOUT}s. "
f"Containers may still be starting — check with get_project_status."
)
results.append(f"\nProject '{project_name}' build issued (status: {final_status}).")
except Exception as e:
results.append(f"Error during redeploy: {e}")
if stop_was_issued:
# M-4: build_stream (or polling) failed AFTER we stopped the
# project. The project is now in STOPPED state and the caller
# needs to know that — the previous "use stop + start"
# workaround was misleading because stop already happened.
results.append(
f"Note: project '{project_name}' was stopped before this error and is "
f"now in STOPPED state. Run start_project('{project_name}') or retry "
f"redeploy_project to recover."
)
else:
results.append("Workaround: use stop_project + start_project separately.")
return "\n".join(results)
@mcp.tool()
async def create_project(
project_name: str,
compose_content: str,
share_path: str | None = None,
confirmed: bool = False,
):
"""Create a new Container Manager project from compose YAML. Requires confirmed=True."""
# Lazy import avoids a circular dependency between projects.py and compose.py.
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.compose import (
_to_filestation_path,
_validate_project_name,
)
if (err := _validate_project_name(project_name)) is not None:
return err
# Parse compose YAML up-front so a malformed input is rejected before
# any side effects (folder creation, project registration).
try:
parsed = yaml.safe_load(compose_content)
except yaml.YAMLError as e:
return f"Invalid YAML content: {e}"
if not isinstance(parsed, dict) or "services" not in parsed:
return "Invalid compose file: must be a YAML document with a 'services' key."
services = parsed.get("services") or {}
service_count = len(services) if isinstance(services, dict) else 0
# Resolve share_path. When the caller omits it, derive it from
# compose_base_path (e.g. "/volume1/docker" + "myapp" → "/docker/myapp").
if share_path is None:
parent_share = _to_filestation_path(config.compose_base_path).rstrip("/")
resolved_share_path = f"{parent_share}/{project_name}"
folder_name = project_name
else:
resolved_share_path = share_path.rstrip("/")
parent_share, _, folder_name = resolved_share_path.rpartition("/")
if not parent_share:
parent_share = "/"
if not folder_name:
return f"Invalid share_path '{share_path}': missing folder name."
# Check for an existing project with the same name BEFORE creating
# the folder — avoids leaving an orphaned directory on the NAS.
existing = await _find_project(client, project_name)
if existing is not None:
return (
f"Project '{project_name}' already exists "
f"(status: {existing.get('status', '?')}, path: {existing.get('path', '?')})."
)
if not confirmed:
return (
f"About to create new project '{project_name}':\n"
f" Share path: {resolved_share_path}\n"
f" Services: {service_count}\n\n"
f"Call this tool again with confirmed=True to apply."
)
results: list[str] = []
# ── Step 1: Create the target folder via FileStation ──────────────────
# force_parent=true makes the call idempotent: it does not fail if the
# folder already exists, and it creates any missing intermediate
# directories. Without this step Docker.Project/create fails with
# error code 2100 ("target folder issue").
results.append("Step 1/3: Creating target folder...")
try:
await client.request(
"SYNO.FileStation.CreateFolder",
"create",
version=2,
params={
"folder_path": json.dumps(parent_share),
"name": json.dumps(folder_name),
"force_parent": "true",
},
)
results.append(f" Folder ready: {resolved_share_path}")
except SynologyError as e:
return (
f"Error creating folder for project '{project_name}': {e}\n"
f" Attempted path: {parent_share}/{folder_name}"
)
# ── Step 2: Register the project with Container Manager ───────────────
results.append("Step 2/3: Registering project with Container Manager...")
try:
data = await client.post_request(
"SYNO.Docker.Project",
"create",
version=1,
params={
"name": json.dumps(project_name),
"share_path": json.dumps(resolved_share_path),
"content": json.dumps(compose_content),
"enable_service_portal": json.dumps(False),
"service_portal_name": json.dumps(""),
"service_portal_port": 0,
"service_portal_protocol": json.dumps("http"),
},
)
except SynologyError as e:
if e.code == 2100:
return (
f"Project creation failed — target folder issue (DSM error 2100).\n"
f" Share path: {resolved_share_path}\n"
f" Folder was created in step 1 but DSM rejected it. "
f"Verify the share exists and the user has write access."
)
return f"Error registering project '{project_name}': {e}"
project_id = (data.get("id") if isinstance(data, dict) else "") or ""
if not project_id:
return (
f"Project registered but DSM returned no project ID. "
f"Check list_projects to confirm — response was: {data!r}"
)
results.append(f" Registered (id={project_id}).")
# ── Step 3: Trigger the build (pull images + start containers) ────────
results.append("Step 3/3: Triggering build_stream (image pull and start)...")
try:
await client.trigger_build_stream(project_id)
results.append(" Build request accepted by DSM.")
except Exception as e:
results.append(f" Error triggering build: {e}")
results.append(
f"\nProject '{project_name}' is registered but was not started. "
f"Run redeploy_project('{project_name}', confirmed=True) to retry."
)
return "\n".join(results)
results.append(
f"Waiting for project to reach RUNNING state (up to {_BUILD_POLL_TIMEOUT}s)..."
)
final_status = await _wait_for_project_running(
client, project_name, timeout=_BUILD_POLL_TIMEOUT
)
if final_status == "RUNNING":
results.append(" Project is RUNNING.")
results.append(f"\nProject '{project_name}' created and started successfully.")
elif final_status in _TERMINAL_FAILURE_STATUSES:
results.append(f" Build failed — project status is '{final_status}'.")
if final_status == "BUILD_FAILED":
results.append(
" Check the image tag(s) in the compose content "
"(update_image_tag) and retry redeploy_project."
)
results.append(
f"\nProject '{project_name}' is registered but failed to start "
f"(status: {final_status})."
)
else:
results.append(
f" Warning: project status is '{final_status}' after "
f"{_BUILD_POLL_TIMEOUT}s. "
f"Containers may still be starting — check with get_project_status."
)
results.append(f"\nProject '{project_name}' created (final status: {final_status}).")
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.
Args:
client: DsmClient instance.
name: Project name to search for.
Returns:
Project dict if found, None otherwise.
"""
try:
data = await client.request("SYNO.Docker.Project", "list")
except Exception:
return None
projects: dict[str, Any] = data if isinstance(data, dict) else {}
for project in projects.values():
if project.get("name") == name:
return dict(project)
return None
async def _wait_for_project_running(
client: DsmClient,
name: str,
timeout: int = _POLL_TIMEOUT,
interval: int = _POLL_INTERVAL,
) -> str:
"""Poll until the project reaches RUNNING status or timeout expires.
Args:
client: DsmClient instance.
name: Project name to watch.
timeout: Maximum seconds to wait (default 30).
interval: Seconds between polls (default 2).
Returns:
Final project status string (may not be "RUNNING" on timeout).
"""
elapsed = 0
while elapsed < timeout:
await asyncio.sleep(interval)
elapsed += interval
project = await _find_project(client, name)
if project is None:
continue
current = (project.get("status") or "").upper()
logger.debug("Polling '%s': status=%s elapsed=%ds", name, current, elapsed)
if current == "RUNNING":
return current
if current in _TERMINAL_FAILURE_STATUSES:
# DSM has reported a hard failure (e.g. image pull failed,
# container exited immediately). Returning early lets the
# caller surface the real cause instead of waiting out the
# full timeout.
return current
# Return whatever status we last saw (or UNKNOWN on repeated failures)
project = await _find_project(client, name)
return (project.get("status") or "UNKNOWN").upper() if project else "UNKNOWN"
def _format_project_detail(project: dict[str, Any]) -> str:
"""Format project details as human-readable text."""
lines = [
f"Project: {project.get('name', '?')}",
f" ID: {project.get('id', '?')}",
f" Status: {project.get('status', '?')}",
f" Path: {project.get('path', '?')}",
f" Share path: {project.get('share_path', '?')}",
f" Created: {project.get('created_at', '?')}",
f" Updated: {project.get('updated_at', '?')}",
]
container_ids = project.get("containerIds", [])
lines.append(f" Containers: {len(container_ids)}")
for cid in container_ids:
lines.append(f" - {cid[:12]}")
services = project.get("services") or []
if services:
lines.append(f" Services: {len(services)}")
for svc in services:
lines.append(f" - {svc.get('display_name', '?')}")
return "\n".join(lines)