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,145 @@
"""MCP tools for SYNO.Docker.Image: list and check for updates."""
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_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
"""Register all image management tools with the MCP server."""
@mcp.tool()
async def check_image_updates(project_name: str | None = None) -> str:
"""Check for available image updates for a project or all images.
Queries the local image list and reports which images have the
'upgradable' flag set by the NAS registry check.
Args:
project_name: Optional project name to filter images.
If omitted, checks all locally available images.
"""
try:
data = await client.request(
"SYNO.Docker.Image",
"list",
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
)
except Exception as e:
return f"Error listing images: {e}"
images: list[dict[str, Any]] = data.get("images", [])
if not images:
return "No images found."
# If project_name given, cross-reference with project containers
if project_name:
images = await _filter_images_for_project(client, project_name, images)
if not images:
return f"No images found for project '{project_name}'."
header = f"Image update status for project '{project_name}':\n"
else:
header = f"Image update status (all {len(images)} images):\n"
upgradable = [img for img in images if img.get("upgradable")]
up_to_date = [img for img in images if not img.get("upgradable")]
lines = [header]
if upgradable:
lines.append(f"Updates available ({len(upgradable)}):")
for img in sorted(upgradable, key=lambda x: x.get("repository", "")):
repo = img.get("repository", "?")
tags = ", ".join(img.get("tags", []))
size_mb = img.get("size", 0) // (1024 * 1024)
lines.append(f" {repo}:{tags} ({size_mb} MiB) ← UPDATE AVAILABLE")
lines.append("")
if up_to_date:
lines.append(f"Up to date ({len(up_to_date)}):")
for img in sorted(up_to_date, key=lambda x: x.get("repository", "")):
repo = img.get("repository", "?")
tags = ", ".join(img.get("tags", []))
size_mb = img.get("size", 0) // (1024 * 1024)
lines.append(f" {repo}:{tags} ({size_mb} MiB)")
if not upgradable:
lines.append("All images are up to date.")
return "\n".join(lines)
async def _filter_images_for_project(
client: DsmClient,
project_name: str,
all_images: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Filter images to those used by a specific project.
Fetches project details and cross-references container image IDs.
Falls back to name-based matching if project details unavailable.
Args:
client: DsmClient instance.
project_name: Project name to filter for.
all_images: Full list of images from SYNO.Docker.Image.
Returns:
Subset of images used by the project.
"""
# Get project details to find used images
try:
# Find project by name
list_data = await client.request("SYNO.Docker.Project", "list")
projects: dict[str, Any] = list_data if isinstance(list_data, dict) else {}
project_entry = next(
(p for p in projects.values() if p.get("name") == project_name), None
)
if not project_entry:
return []
project_id = project_entry.get("id", "")
# Get project detail which includes container image info
detail_data = await client.request(
"SYNO.Docker.Project",
"get",
params={"id": project_id},
)
containers = detail_data.get("containers", []) or []
image_ids: set[str] = set()
image_names: set[str] = set()
for container in containers:
img_id = container.get("Image", "")
if img_id:
image_ids.add(img_id)
cfg_image = container.get("Config", {}).get("Image", "")
if cfg_image:
# Strip tag for name matching
name = cfg_image.split(":")[0] if ":" in cfg_image else cfg_image
image_names.add(name)
# Match images
result = []
for img in all_images:
img_id = img.get("id", "")
repo = img.get("repository", "")
if img_id in image_ids or repo in image_names:
result.append(img)
return result
except Exception as e:
logger.debug("Could not filter images for project '%s': %s", project_name, e)
# Fallback: return all images
return all_images