"""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)