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:
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user