3f73ed0aef
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>
516 lines
22 KiB
Python
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)
|