diff --git a/CHANGELOG.md b/CHANGELOG.md index a7adec1..b075ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 2–10 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 diff --git a/CLAUDE.md b/CLAUDE.md index 7b0c0d3..dfce8fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 (2–10 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 diff --git a/pyproject.toml b/pyproject.toml index 76c6299..1dea638 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/mcp_synology_container/modules/registry.py b/src/mcp_synology_container/modules/registry.py new file mode 100644 index 0000000..4086a2f --- /dev/null +++ b/src/mcp_synology_container/modules/registry.py @@ -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." + ) diff --git a/src/mcp_synology_container/server.py b/src/mcp_synology_container/server.py index 4caccd6..59de21e 100644 --- a/src/mcp_synology_container/server.py +++ b/src/mcp_synology_container/server.py @@ -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 diff --git a/tests/test_modules/test_registry.py b/tests/test_modules/test_registry.py new file mode 100644 index 0000000..fd0ee54 --- /dev/null +++ b/tests/test_modules/test_registry.py @@ -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 diff --git a/uv.lock b/uv.lock index 0489d4b..35a079a 100644 --- a/uv.lock +++ b/uv.lock @@ -362,7 +362,7 @@ wheels = [ [[package]] name = "mcp-synology-container" -version = "0.4.3" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "click" },