Initial implementation

This commit is contained in:
2026-04-13 14:22:37 +02:00
commit a0c1b6ed93
26 changed files with 4125 additions and 0 deletions
@@ -0,0 +1,227 @@
"""MCP tools for SYNO.Docker.Project: list, status, start, stop, redeploy."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
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__)
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() -> str:
"""List all Container Manager projects with their current status.
Returns a formatted table of projects including 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) -> str:
"""Get detailed status of a specific project.
Args:
project_name: Name of the project to inspect.
"""
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) -> str:
"""Start a Container Manager project.
Args:
project_name: Name of the project to start.
"""
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) -> str:
"""Stop a running Container Manager project.
This operation stops all containers in the project.
Requires confirmation before executing.
Args:
project_name: Name of the project to stop.
confirmed: Must be True to proceed. Set to True to confirm the stop operation.
"""
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) -> str:
"""Redeploy a project: pull latest images, stop, and restart.
This operation will briefly take the project offline.
Requires confirmation before executing.
Args:
project_name: Name of the project to redeploy.
confirmed: Must be True to proceed. Set to True to confirm the redeploy.
"""
if not confirmed:
return (
f"Redeploying project '{project_name}' will:\n"
f" 1. Pull latest images\n"
f" 2. Stop all containers\n"
f" 3. Restart with new 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", "")
results = []
try:
# Step 1: Pull latest images via build (triggers compose pull)
results.append("Step 1/3: Pulling latest images...")
try:
await client.request(
"SYNO.Docker.Project",
"build",
params={"id": project_id, "force": "true"},
)
results.append(" Images pulled.")
except Exception as e:
results.append(f" Warning: pull step failed ({e}), continuing with restart.")
# Step 2: Stop the project
results.append("Step 2/3: Stopping project...")
await client.request(
"SYNO.Docker.Project",
"stop",
params={"id": project_id},
)
results.append(" Project stopped.")
# Step 3: Start the project
results.append("Step 3/3: Starting project...")
await client.request(
"SYNO.Docker.Project",
"start",
params={"id": project_id},
)
results.append(" Project started.")
results.append(f"\nProject '{project_name}' redeployed successfully.")
except Exception as e:
results.append(f"Error during redeploy: {e}")
return "\n".join(results)
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
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)