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
+34 -1
View File
@@ -2,7 +2,40 @@
All notable changes to this project will be documented in this file.
## [0.4.3] - 2026-05-18
## [0.5.0] - 2026-05-18
### Added
**Welle B Teil 1 — Registry tools (#3, #5).** Three new `SYNO.Docker.Registry`
tools, reverse-engineered from a live DSM API capture (the n4s4 reference
disagrees on parameter names and methods; the live capture wins):
- `search_registry` (#5) — search the active Docker registry by query string.
Uses `SYNO.Docker.Registry/search` (version 1) with the query JSON-encoded
as `q`, plus `offset`, `limit`, and `page_size`. Renders stars, downloads,
the official-image flag, and a truncated description per hit, and shows
the total match count so the caller knows when to raise `limit`. No
confirmation gate (read-only).
- `list_image_tags` — list available tags for a repository (bonus tool).
Uses `SYNO.Docker.Registry/tags` (version 1) with the repository
JSON-encoded as `repo` — note the parameter name diverges from the n4s4
reference which uses `name`. The response shape is unusual: DSM returns
the tag list as the envelope's `data` field directly (not wrapped in a
sub-key), so the tool accepts both shapes defensively. Output is capped
by `limit` (default 50) because popular images like `alpine` ship 200+
tags. No confirmation gate (read-only).
- `pull_image` (#3) — pull an image into the local cache via
`SYNO.Docker.Registry/pull_start` (version 1) with both `repository` and
`tag` JSON-encoded. Requires `confirmed=True`. DSM exposes no confirmed
`pull_status` method, so completion is detected by polling
`SYNO.Docker.Image/list` for the new `repository:tag` pair with a 210 s
backoff schedule and a 240 s overall budget (kept under the Claude
Desktop ~4 min tool-call ceiling). A timeout returns a non-fatal "still
running" hint pointing at `list_images` instead of raising — DSM keeps
pulling server-side regardless. Short-circuits when the image is already
present locally so a repeated call is cheap. Closes #3 and #5.
Tool count rises from 31 to 34.
### Fixed
+14 -3
View File
@@ -33,7 +33,7 @@ Only a second consecutive failure is treated as a real auth problem.
---
## Implemented tools (31)
## Implemented tools (34)
| Category | Tools |
|---|---|
@@ -41,6 +41,7 @@ Only a second consecutive failure is treated as a real auth problem.
| Containers | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats`, `delete_container`, `start_container`, `stop_container`, `restart_container` |
| Compose | `read_compose`, `update_compose`, `update_image_tag`, `update_env_var` |
| Images | `check_image_updates`, `list_images`, `delete_image`, `inspect_image` |
| Registry | `search_registry`, `list_image_tags`, `pull_image` |
| Networks | `list_networks`, `create_network`, `delete_network` |
| System | `system_df`, `system_prune`, `system_overview` |
@@ -58,7 +59,17 @@ Only a second consecutive failure is treated as a real auth problem.
- **Image delete** — requires a form-encoded POST with a JSON `images` array
(confirmed via browser DevTools); uses `DsmClient.post_request()`.
- **`SYNO.Docker.Image/pull`** — API method exists but behaviour varies by
DSM version; not exposed as a standalone tool.
DSM version; not exposed as a standalone tool. `pull_image` uses
`SYNO.Docker.Registry/pull_start` instead (see below).
- **`SYNO.Docker.Registry/pull_start`** — asynchronous pull entry point;
no matching `pull_status` method confirmed. `pull_image` polls
`SYNO.Docker.Image/list` until `repository:tag` appears (210 s backoff,
240 s budget) and returns a "still running" hint on timeout instead of
raising — DSM keeps pulling server-side regardless of the HTTP response.
- **`SYNO.Docker.Registry/tags`** — uses `repo` (JSON-encoded) as the
parameter name; the n4s4 reference's `name` does not work on this DSM
version. Returns the tag list as the envelope's `data` field directly,
not wrapped in a sub-key.
- **`SYNO.Docker.Volume`** — endpoint does not exist; volume management is
not available via the DSM WebAPI.
- **`SYNO.Docker.Registry/get`** — does not behave as documented; registry
@@ -77,7 +88,7 @@ Only a second consecutive failure is treated as a real auth problem.
`redeploy_project`, `create_project`, `delete_project`,
`exec_in_container`, `update_image_tag`, `update_env_var`,
`update_compose`, `delete_container`, `stop_container`,
`restart_container`
`restart_container`, `pull_image`
- After compose changes: suggest `redeploy_project`
- DSM errors → human-readable message, no stack traces
- No secrets in stderr output
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "mcp-synology-container"
version = "0.4.3"
version = "0.5.0"
description = "MCP server for Synology Container Manager"
requires-python = ">=3.12"
dependencies = [
@@ -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
+385
View File
@@ -0,0 +1,385 @@
"""Tests for modules/registry.py."""
import json
from unittest.mock import AsyncMock
import pytest
def make_mock_mcp():
tools: dict = {}
class MockMCP:
def tool(self):
def decorator(fn):
tools[fn.__name__] = fn
return fn
return decorator
return MockMCP(), tools
def make_config():
from mcp_synology_container.config import AppConfig, ConnectionConfig
return AppConfig(
schema_version=1,
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
)
# ──────────────────────────────────────────────────────────────────────────────
# search_registry — Issue #5
# ──────────────────────────────────────────────────────────────────────────────
SEARCH_RESPONSE = {
"data": [
{
"name": "caddy",
"description": (
"Caddy 2 is a powerful, enterprise-ready, open source web "
"server with automatic HTTPS written in Go."
),
"downloads": 650989718,
"is_automated": False,
"is_official": True,
"star_count": 881,
"registry": "https://registry.hub.docker.com",
},
{
"name": "abiosoft/caddy",
"description": "Caddy is a lightweight, general-purpose web server.",
"downloads": 111974910,
"is_automated": True,
"is_official": False,
"star_count": 289,
"registry": "https://registry.hub.docker.com",
},
],
"limit": 20,
"offset": 0,
"page_size": 20,
"total": 6647,
}
@pytest.mark.asyncio
async def test_search_registry_renders_hits():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.return_value = SEARCH_RESPONSE
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["search_registry"](query="caddy")
assert "caddy" in result
assert "abiosoft/caddy" in result
assert "[official]" in result
assert "881" in result # stars
assert "650989718" in result # downloads
assert "2 of 6647" in result
@pytest.mark.asyncio
async def test_search_registry_uses_json_encoded_query():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.return_value = SEARCH_RESPONSE
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
await tools["search_registry"](query="nginx", limit=10)
call = client.request.call_args
assert call.args[0] == "SYNO.Docker.Registry"
assert call.args[1] == "search"
assert call.kwargs.get("version") == 1
params = call.kwargs.get("params", {})
assert json.loads(params["q"]) == "nginx"
assert params["offset"] == "0"
assert params["limit"] == "10"
assert params["page_size"] == "10"
@pytest.mark.asyncio
async def test_search_registry_empty():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.return_value = {"data": [], "total": 0}
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["search_registry"](query="does-not-exist")
assert "No results" in result
@pytest.mark.asyncio
async def test_search_registry_api_error():
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.side_effect = SynologyError("Auth failure", code=119)
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["search_registry"](query="caddy")
assert "Error" in result
# ──────────────────────────────────────────────────────────────────────────────
# list_image_tags — bonus
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_image_tags_array_response():
"""DSM returns the envelope's data field directly as a list."""
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.return_value = [
{"tag": "latest"},
{"tag": "3.20"},
{"tag": "3.19"},
]
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["list_image_tags"](repository="alpine")
assert "alpine" in result
assert "latest" in result
assert "3.20" in result
assert "3.19" in result
assert "3 total" in result
@pytest.mark.asyncio
async def test_list_image_tags_uses_json_repo_param():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.return_value = [{"tag": "latest"}]
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
await tools["list_image_tags"](repository="grafana/grafana")
call = client.request.call_args
assert call.args[0] == "SYNO.Docker.Registry"
assert call.args[1] == "tags"
assert call.kwargs.get("version") == 1
params = call.kwargs.get("params", {})
assert json.loads(params["repo"]) == "grafana/grafana"
@pytest.mark.asyncio
async def test_list_image_tags_limit_truncates():
from mcp_synology_container.modules.registry import register_registry
tags = [{"tag": f"v{i}"} for i in range(120)]
client = AsyncMock()
client.request.return_value = tags
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["list_image_tags"](repository="alpine", limit=10)
assert "120 total" in result
assert "Showing first 10 of 120" in result
# Tag #10 must not appear (only v0..v9)
assert " v9" in result
assert " v10" not in result
@pytest.mark.asyncio
async def test_list_image_tags_empty():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
# Empty list → DsmClient.request coerces to {} via `or {}`
client.request.return_value = {}
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["list_image_tags"](repository="nonexistent/image")
assert "No tags found" in result
@pytest.mark.asyncio
async def test_list_image_tags_api_error():
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.side_effect = SynologyError("Boom", code=100)
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["list_image_tags"](repository="alpine")
assert "Error" in result
# ──────────────────────────────────────────────────────────────────────────────
# pull_image — Issue #3
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_pull_image_preview():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24")
assert "Preview" in result
assert "nginx:1.24" in result
assert "confirmed=True" in result
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_pull_image_already_present():
"""If image is already in Image/list, no pull_start call is made."""
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
return {
"images": [
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
]
}
raise AssertionError(f"Unexpected call: {api}/{method}")
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "already present" in result
# Only the Image/list pre-check was called; pull_start must NOT fire.
calls = client.request.call_args_list
assert all(c.args[1] != "pull_start" for c in calls)
@pytest.mark.asyncio
async def test_pull_image_confirmed_success(monkeypatch):
"""pull_start succeeds, then Image/list shows the new tag on first poll."""
import mcp_synology_container.modules.registry as registry_mod
from mcp_synology_container.modules.registry import register_registry
# Make polling instant
async def fake_sleep(_):
return None
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
state = {"pulled": False}
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
if state["pulled"]:
return {
"images": [
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
]
}
return {"images": []}
if api == "SYNO.Docker.Registry" and method == "pull_start":
state["pulled"] = True
return {}
raise AssertionError(f"Unexpected call: {api}/{method}")
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "Pulled" in result
assert "nginx:1.24" in result
# Verify pull_start was invoked with JSON-encoded params
pull_calls = [c for c in client.request.call_args_list if c.args[1] == "pull_start"]
assert len(pull_calls) == 1
params = pull_calls[0].kwargs.get("params", {})
assert json.loads(params["repository"]) == "nginx"
assert json.loads(params["tag"]) == "1.24"
assert pull_calls[0].kwargs.get("version") == 1
@pytest.mark.asyncio
async def test_pull_image_timeout(monkeypatch):
"""If the image never appears, the tool returns a still-running hint."""
import mcp_synology_container.modules.registry as registry_mod
from mcp_synology_container.modules.registry import register_registry
# Make polling instant
async def fake_sleep(_):
return None
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
# Shrink the budget so the loop exits quickly
monkeypatch.setattr(registry_mod, "_PULL_POLL_TIMEOUT", 0.05)
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
return {"images": []}
if api == "SYNO.Docker.Registry" and method == "pull_start":
return {}
raise AssertionError(f"Unexpected call: {api}/{method}")
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "still running" in result
assert "nginx:1.24" in result
@pytest.mark.asyncio
async def test_pull_image_start_error():
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.registry import register_registry
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
return {"images": []}
if api == "SYNO.Docker.Registry" and method == "pull_start":
raise SynologyError("Permission denied", code=105)
raise AssertionError(f"Unexpected call: {api}/{method}")
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "Error starting pull" in result
Generated
+1 -1
View File
@@ -362,7 +362,7 @@ wheels = [
[[package]]
name = "mcp-synology-container"
version = "0.4.3"
version = "0.5.0"
source = { editable = "." }
dependencies = [
{ name = "click" },