feat: v0.5.0 — welle B Teil 1 (registry tools: search, tags, pull)

Three new SYNO.Docker.Registry tools, reverse-engineered from a live
DSM API capture (n4s4 reference disagrees on param names and methods).

- search_registry (#5): SYNO.Docker.Registry/search v1 with JSON-encoded
  q, plus offset/limit/page_size. Renders stars, downloads, official
  flag, truncated description, and total match count. Read-only.
- list_image_tags: SYNO.Docker.Registry/tags v1 with JSON-encoded repo
  (not name — DSM live capture diverges from n4s4). Response shape is
  unusual: tag list comes back as the envelope's data field directly.
  Output capped by limit (default 50); accepts both list and dict
  response shapes defensively. Read-only.
- pull_image (#3): SYNO.Docker.Registry/pull_start v1 with both
  repository and tag JSON-encoded. Async pull — no pull_status method
  confirmed on this DSM, so completion is detected by polling
  SYNO.Docker.Image/list (2–10 s backoff, 240 s budget under the
  Claude Desktop ~4 min tool-call ceiling). Timeout returns a
  non-fatal "still running" hint. Short-circuits when the image is
  already present locally. Confirmation gate required.

Tool count: 31 → 34. CLAUDE.md confirmation list updated. New DSM
quirks documented for pull_start (no pull_status) and tags (repo
param name, top-level data array).

Closes #3
Closes #5

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 13:25:59 +02:00
parent 82e8167f67
commit f27a5456f6
7 changed files with 639 additions and 6 deletions
@@ -0,0 +1,202 @@
"""MCP tools for SYNO.Docker.Registry: search, tags, pull."""
from __future__ import annotations
import asyncio
import json
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__)
# Pull polling: backoff schedule capped at 10 s between checks. Total budget
# stays under the Claude Desktop ~4 min tool-call ceiling.
_PULL_POLL_TIMEOUT = 240.0
_PULL_POLL_INTERVALS: tuple[float, ...] = (2.0, 3.0, 5.0, 8.0, 10.0)
def _truncate(text: str, limit: int = 80) -> str:
"""Truncate text to limit with an ellipsis."""
text = (text or "").replace("\n", " ").replace("\r", " ").strip()
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + ""
async def _image_present(client: DsmClient, repository: str, tag: str) -> bool:
"""Return True if repository:tag is in the local image list."""
try:
data = await client.request(
"SYNO.Docker.Image",
"list",
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
)
except Exception as e:
logger.debug("Pull polling: image list failed: %s", e)
return False
images: list[dict[str, Any]] = data.get("images", []) if isinstance(data, dict) else []
for img in images:
repo = img.get("repository", "")
tags = img.get("tags") or []
if repo == repository and tag in tags:
return True
return False
def register_registry(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
"""Register all registry management tools with the MCP server."""
@mcp.tool()
async def search_registry(query: str, limit: int = 20):
"""Search the active Docker registry for images matching query."""
if limit < 1:
limit = 1
elif limit > 100:
limit = 100
try:
data = await client.request(
"SYNO.Docker.Registry",
"search",
version=1,
params={
"q": json.dumps(query),
"offset": "0",
"limit": str(limit),
"page_size": str(limit),
},
)
except Exception as e:
return f"Error searching registry for '{query}': {e}"
results: list[dict[str, Any]] = []
total = 0
if isinstance(data, dict):
results = data.get("data", []) or []
total = int(data.get("total", 0) or 0)
if not results:
return f"No results found for '{query}'."
lines = [f"Search results for '{query}' ({len(results)} of {total} total):", ""]
lines.append(" Stars Downloads Name (official) Description")
for hit in results:
name = hit.get("name", "?")
description = _truncate(hit.get("description", ""))
stars = int(hit.get("star_count", 0) or 0)
downloads = int(hit.get("downloads", 0) or 0)
official = " [official]" if hit.get("is_official") else ""
lines.append(f" {stars:>5} {downloads:>9} {name}{official}")
if description:
lines.append(f" {description}")
if total > len(results):
lines.append("")
lines.append(f"Showing {len(results)} of {total}; raise limit to see more.")
return "\n".join(lines)
@mcp.tool()
async def list_image_tags(repository: str, limit: int = 50):
"""List available tags for a registry image (e.g. 'nginx', 'grafana/grafana')."""
if limit < 1:
limit = 1
try:
data = await client.request(
"SYNO.Docker.Registry",
"tags",
version=1,
params={"repo": json.dumps(repository)},
)
except Exception as e:
return f"Error fetching tags for '{repository}': {e}"
# DSM returns the tag list as the envelope's `data` field directly.
# When empty, DsmClient.request() coerces to {} via `or {}`, so we
# accept both shapes here.
if isinstance(data, list):
entries: list[Any] = data
elif isinstance(data, dict):
# Defensive: some DSM versions wrap as {"tags": [...]}.
entries = data.get("tags") or data.get("data") or []
else:
entries = []
tags: list[str] = []
for entry in entries:
if isinstance(entry, dict):
tag = entry.get("tag")
if isinstance(tag, str) and tag:
tags.append(tag)
elif isinstance(entry, str):
tags.append(entry)
if not tags:
return f"No tags found for '{repository}'."
total = len(tags)
shown = tags[:limit]
lines = [f"Tags for '{repository}' ({total} total):", ""]
lines.extend(f" {t}" for t in shown)
if total > limit:
lines.append("")
lines.append(f"Showing first {limit} of {total}; raise limit to see more.")
return "\n".join(lines)
@mcp.tool()
async def pull_image(repository: str, tag: str = "latest", confirmed: bool = False):
"""Pull image from the active registry. Requires confirmed=True; polls for completion."""
target = f"{repository}:{tag}"
if not confirmed:
return (
f"Preview: would pull {target} from the active registry.\n"
f"Call pull_image(repository={repository!r}, tag={tag!r}, "
"confirmed=True) to proceed."
)
# Short-circuit: if the image already exists locally, no pull needed.
if await _image_present(client, repository, tag):
return f"{target} is already present locally — nothing to pull."
try:
await client.request(
"SYNO.Docker.Registry",
"pull_start",
version=1,
params={
"repository": json.dumps(repository),
"tag": json.dumps(tag),
},
)
except Exception as e:
return f"Error starting pull for '{target}': {e}"
# Async pull — DSM exposes no confirmed pull_status method, so we poll
# Image/list for the new tag. Backoff schedule capped at 10 s; total
# budget under the Claude Desktop ~4 min tool-call ceiling.
loop = asyncio.get_event_loop()
deadline = loop.time() + _PULL_POLL_TIMEOUT
attempt = 0
while loop.time() < deadline:
interval = _PULL_POLL_INTERVALS[min(attempt, len(_PULL_POLL_INTERVALS) - 1)]
remaining = deadline - loop.time()
await asyncio.sleep(min(interval, max(remaining, 0.0)))
if await _image_present(client, repository, tag):
return f"Pulled {target} successfully."
attempt += 1
return (
f"Pull of {target} started, still running — "
f"verify later with list_images or check_image_updates."
)
+2
View File
@@ -31,6 +31,7 @@ def create_server(config: AppConfig, client: DsmClient) -> FastMCP:
from mcp_synology_container.modules.images import register_images
from mcp_synology_container.modules.networks import register_networks
from mcp_synology_container.modules.projects import register_projects
from mcp_synology_container.modules.registry import register_registry
from mcp_synology_container.modules.system import register_system
register_projects(mcp, config, client)
@@ -39,6 +40,7 @@ def create_server(config: AppConfig, client: DsmClient) -> FastMCP:
register_images(mcp, config, client)
register_system(mcp, config, client)
register_networks(mcp, config, client)
register_registry(mcp, config, client)
logger.info("MCP server configured with all tool modules")
return mcp