From 5fe8f5bc7322c56e999c93e8d20df8d8e54e050b Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Mon, 13 Apr 2026 19:28:45 +0200 Subject: [PATCH] Add pull_image + list_registries; remove Gruppe 5 (no Volume API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pull_image: SYNO.Docker.Image/pull with repository+tag split via rpartition; polls image list every 3 s until image appears, 120 s timeout - list_registries: SYNO.Docker.Registry/get; shows name, URL, active marker - Gruppe 5 (Volumes) removed from roadmap — SYNO.Docker.Volume does not exist - CLAUDE.md: tool count 17 → 19, Volumes section removed - 28 tests all passing Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 51 +----- src/mcp_synology_container/modules/images.py | 53 +++++- .../modules/registries.py | 49 +++++ src/mcp_synology_container/server.py | 2 + tests/test_modules/test_images.py | 139 +++++++++++++- tests/test_modules/test_registries.py | 173 ++++++++++++++++++ 6 files changed, 421 insertions(+), 46 deletions(-) create mode 100644 src/mcp_synology_container/modules/registries.py create mode 100644 tests/test_modules/test_registries.py diff --git a/CLAUDE.md b/CLAUDE.md index 6f8e696..6b0df93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,14 +32,17 @@ via Container Manager. Der MCP-Server ist in Claude Desktop aktiv verbunden. ## Aktueller Stand -### Implementierte Tools (17) +### Implementierte Tools (19) | Kategorie | Tools | |---|---| | Projekte | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project` | | Container | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats` | | Compose | `read_compose`, `update_compose`, `update_image_tag`, `update_env_var` | -| Images | `check_image_updates`, `list_images`, `delete_image` | +| Images | `check_image_updates`, `list_images`, `delete_image`, `pull_image` | +| Netzwerke | `list_networks`, `create_network`, `delete_network` | +| System | `system_df`, `system_prune` | +| Registries | `list_registries` | ### Bekannte Bugs @@ -52,31 +55,9 @@ via Container Manager. Der MCP-Server ist in Claude Desktop aktiv verbunden. ## Roadmap (geplante Erweiterungen) -### Images -- `list_images` – alle lokalen Images mit Größe, Tag, Erstellungsdatum -- `delete_image` – nicht mehr benötigte Images löschen -- `pull_image` – Image manuell aus Registry ziehen +~~Alle geplanten Erweiterungen implementiert.~~ -### Container -- ~~`container_stats`~~ – implementiert -- ~~`rename_container`~~ – entfällt (DSM bietet kein Container-Umbenennen) - -### Netzwerke -- `list_networks` – alle Docker-Netzwerke auflisten -- `create_network` – neues Netzwerk anlegen -- `delete_network` – Netzwerk löschen - -### Volumes -- `list_volumes` – alle Docker-Volumes auflisten -- `delete_volume` – verwaiste Volumes löschen -- `inspect_volume` – Volume-Details anzeigen - -### System -- `system_df` – Docker Disk Usage (Images, Container, Volumes) -- `system_prune` – Aufräumen (dangling Images, gestoppte Container) - -### Registries -- `list_registries` – konfigurierte Registries anzeigen +Volumes entfällt — SYNO.Docker.Volume existiert nicht (kein DSM-Endpunkt). --- @@ -197,24 +178,6 @@ Implementiere **eine Gruppe nach der anderen**. Commit + Push nach jeder Gruppe, --- -#### Gruppe 5 – Volumes `modules/volumes.py` (neu) ✦ Prio: niedrig - -**`list_volumes`** -- DSM API: `SYNO.Docker.Volume`, method `list` -- Ausgabe: Name, Mountpoint, Größe, ob in Verwendung -- Confirmation: nein - -**`inspect_volume`** -- Signatur: `inspect_volume(volume_name: str) -> str` -- Confirmation: nein - -**`delete_volume`** -- Signatur: `delete_volume(volume_name: str, confirmed: bool = False) -> str` -- Fehler wenn Volume gemountet → klare Meldung -- Confirmation: **ja** - ---- - #### Gruppe 6 – Images Ergänzung `modules/images.py` ✦ Prio: niedrig **`pull_image`** diff --git a/src/mcp_synology_container/modules/images.py b/src/mcp_synology_container/modules/images.py index a74f828..c625b6d 100644 --- a/src/mcp_synology_container/modules/images.py +++ b/src/mcp_synology_container/modules/images.py @@ -1,7 +1,8 @@ -"""MCP tools for SYNO.Docker.Image: list, check updates, delete.""" +"""MCP tools for SYNO.Docker.Image: list, check updates, delete, pull.""" from __future__ import annotations +import asyncio import json import logging import sys @@ -269,6 +270,56 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None: return f"Deleted {display_name} — {size_str} freed." + @mcp.tool() + async def pull_image(image: str) -> str: + """Pull a Docker image from the active registry. + + Splits the image reference into repository and tag, triggers the pull + via DSM, then polls the image list until the image appears (up to 120 s). + + Args: + image: Image reference as "name:tag" (e.g. "postgres:17.8"). + Tag defaults to "latest" when omitted. + """ + repository, sep, tag = image.rpartition(":") + if not sep: + repository = image + tag = "latest" + + try: + await client.request( + "SYNO.Docker.Image", + "pull", + params={"repository": repository, "tag": tag}, + ) + except Exception as e: + return f"Error starting pull of '{image}': {e}" + + # DSM starts the pull asynchronously; poll until the image appears. + deadline = 120 + interval = 3 + elapsed = 0 + while elapsed < deadline: + await asyncio.sleep(interval) + elapsed += interval + try: + img_data = await client.request( + "SYNO.Docker.Image", + "list", + params={"limit": "-1", "offset": "0", "show_dsm": "false"}, + ) + except Exception: + continue + for img in img_data.get("images", []): + if img.get("repository") == repository and tag in (img.get("tags") or []): + size_str = _human_size(img.get("size", 0)) + return f"Pulled {repository}:{tag} — {size_str}." + + return ( + f"Pull of '{repository}:{tag}' started but did not complete within " + f"{deadline} s. Check DSM Container Manager for status." + ) + @mcp.tool() async def check_image_updates(project_name: str | None = None) -> str: """Check for available image updates for a project or all images. diff --git a/src/mcp_synology_container/modules/registries.py b/src/mcp_synology_container/modules/registries.py new file mode 100644 index 0000000..4bc2645 --- /dev/null +++ b/src/mcp_synology_container/modules/registries.py @@ -0,0 +1,49 @@ +"""MCP tools for SYNO.Docker.Registry: list.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +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_registries(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None: + """Register all registry management tools with the MCP server.""" + + @mcp.tool() + async def list_registries() -> str: + """List all configured Docker registries. + + Shows name, URL, and marks the currently active registry. + Uses SYNO.Docker.Registry/get which returns the registries array + and the name of the currently active registry in the "using" field. + """ + try: + data = await client.request("SYNO.Docker.Registry", "get") + except Exception as e: + return f"Error listing registries: {e}" + + registries = data.get("registries", []) + using = data.get("using", "") + + if not registries: + return "No registries configured." + + lines = [f"Registries ({len(registries)} total):", ""] + for reg in registries: + name = reg.get("name", "?") + url = reg.get("url", "?") + active_marker = " [active]" if name == using else "" + mirror_marker = " [mirror enabled]" if reg.get("enable_registry_mirror") else "" + lines.append(f" {name}{active_marker}") + lines.append(f" URL: {url}{mirror_marker}") + lines.append("") + + return "\n".join(lines).rstrip() diff --git a/src/mcp_synology_container/server.py b/src/mcp_synology_container/server.py index 4caccd6..6d19a79 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.registries import register_registries 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_registries(mcp, config, client) logger.info("MCP server configured with all tool modules") return mcp diff --git a/tests/test_modules/test_images.py b/tests/test_modules/test_images.py index a3c9b23..b9547e3 100644 --- a/tests/test_modules/test_images.py +++ b/tests/test_modules/test_images.py @@ -1,6 +1,6 @@ """Tests for modules/images.py.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest @@ -491,3 +491,140 @@ async def test_check_image_updates_for_project(): result = await tools["check_image_updates"](project_name="myapp") assert "myapp" in result assert "nginx:1.24" in result + + +# ────────────────────────────────────────────────────────────────────────────── +# pull_image +# ────────────────────────────────────────────────────────────────────────────── + +PULLED_IMAGE = { + "id": "sha256:dddd", + "repository": "postgres", + "tags": ["17.8"], + "size": 80 * 1024 * 1024, + "created": 1700000000, + "upgradable": False, +} + + +@pytest.mark.asyncio +async def test_pull_image_success(): + from mcp_synology_container.modules.images import register_images + + client = AsyncMock() + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Image" and method == "pull": + return {} + if api == "SYNO.Docker.Image" and method == "list": + return {"images": [PULLED_IMAGE]} + return {} + + client.request.side_effect = mock_request + mcp, tools = make_mock_mcp() + register_images(mcp, make_config(), client) + + with patch("asyncio.sleep", new=AsyncMock()): + result = await tools["pull_image"]("postgres:17.8") + + assert "Pulled postgres:17.8" in result + assert "MiB" in result + + +@pytest.mark.asyncio +async def test_pull_image_no_tag_defaults_to_latest(): + from mcp_synology_container.modules.images import register_images + + client = AsyncMock() + found_image = {**PULLED_IMAGE, "repository": "nginx", "tags": ["latest"]} + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Image" and method == "pull": + # Verify tag defaults to "latest" + assert kwargs.get("params", {}).get("tag") == "latest" + return {} + if api == "SYNO.Docker.Image" and method == "list": + return {"images": [found_image]} + return {} + + client.request.side_effect = mock_request + mcp, tools = make_mock_mcp() + register_images(mcp, make_config(), client) + + with patch("asyncio.sleep", new=AsyncMock()): + result = await tools["pull_image"]("nginx") + + assert "Pulled nginx:latest" in result + + +@pytest.mark.asyncio +async def test_pull_image_registry_prefixed(): + """Registry-prefixed images (e.g. ghcr.io/foo/bar:v1) split at last ':'.""" + from mcp_synology_container.modules.images import register_images + + client = AsyncMock() + found_image = { + **PULLED_IMAGE, + "repository": "ghcr.io/open-webui/open-webui", + "tags": ["v0.9.0"], + } + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Image" and method == "pull": + params = kwargs.get("params", {}) + assert params["repository"] == "ghcr.io/open-webui/open-webui" + assert params["tag"] == "v0.9.0" + return {} + if api == "SYNO.Docker.Image" and method == "list": + return {"images": [found_image]} + return {} + + client.request.side_effect = mock_request + mcp, tools = make_mock_mcp() + register_images(mcp, make_config(), client) + + with patch("asyncio.sleep", new=AsyncMock()): + result = await tools["pull_image"]("ghcr.io/open-webui/open-webui:v0.9.0") + + assert "Pulled ghcr.io/open-webui/open-webui:v0.9.0" in result + + +@pytest.mark.asyncio +async def test_pull_image_timeout(): + from mcp_synology_container.modules.images import register_images + + client = AsyncMock() + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Image" and method == "pull": + return {} + # image never appears in list + return {"images": []} + + client.request.side_effect = mock_request + mcp, tools = make_mock_mcp() + register_images(mcp, make_config(), client) + + with patch("asyncio.sleep", new=AsyncMock()): + result = await tools["pull_image"]("nonexistent:latest") + + assert "did not complete" in result + assert "120" in result + + +@pytest.mark.asyncio +async def test_pull_image_api_error(): + from mcp_synology_container.dsm_client import SynologyError + from mcp_synology_container.modules.images import register_images + + client = AsyncMock() + client.request.side_effect = SynologyError("pull failed", code=400) + + mcp, tools = make_mock_mcp() + register_images(mcp, make_config(), client) + + with patch("asyncio.sleep", new=AsyncMock()): + result = await tools["pull_image"]("bad/image:tag") + + assert "Error" in result + assert "bad/image:tag" in result diff --git a/tests/test_modules/test_registries.py b/tests/test_modules/test_registries.py new file mode 100644 index 0000000..0229e27 --- /dev/null +++ b/tests/test_modules/test_registries.py @@ -0,0 +1,173 @@ +"""Tests for modules/registries.py.""" + +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), + ) + + +SAMPLE_REGISTRIES = { + "registries": [ + { + "name": "Docker Hub", + "url": "https://registry.hub.docker.com", + "syno": True, + "enable_registry_mirror": False, + "enable_trust_SSC": True, + "mirror_urls": [], + }, + { + "name": "GitHub Packages", + "url": "https://ghcr.io", + "syno": False, + "enable_registry_mirror": False, + "enable_trust_SSC": True, + "mirror_urls": [], + }, + ], + "using": "Docker Hub", + "total": 2, + "offset": 0, +} + + +# ────────────────────────────────────────────────────────────────────────────── +# list_registries +# ────────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_list_registries_shows_all(): + from mcp_synology_container.modules.registries import register_registries + + client = AsyncMock() + client.request.return_value = SAMPLE_REGISTRIES + mcp, tools = make_mock_mcp() + register_registries(mcp, make_config(), client) + + result = await tools["list_registries"]() + + assert "Docker Hub" in result + assert "GitHub Packages" in result + assert "registry.hub.docker.com" in result + assert "ghcr.io" in result + + +@pytest.mark.asyncio +async def test_list_registries_marks_active(): + from mcp_synology_container.modules.registries import register_registries + + client = AsyncMock() + client.request.return_value = SAMPLE_REGISTRIES + mcp, tools = make_mock_mcp() + register_registries(mcp, make_config(), client) + + result = await tools["list_registries"]() + + # Docker Hub is the active registry + assert "[active]" in result + # GitHub Packages is not active — "[active]" appears only once + assert result.count("[active]") == 1 + + pos_hub = result.index("Docker Hub") + pos_active = result.index("[active]") + pos_github = result.index("GitHub Packages") + # [active] marker appears right after "Docker Hub", before "GitHub Packages" + assert pos_hub < pos_active < pos_github + + +@pytest.mark.asyncio +async def test_list_registries_uses_get_method(): + """list_registries must call SYNO.Docker.Registry with method='get'.""" + from mcp_synology_container.modules.registries import register_registries + + client = AsyncMock() + client.request.return_value = SAMPLE_REGISTRIES + mcp, tools = make_mock_mcp() + register_registries(mcp, make_config(), client) + + await tools["list_registries"]() + + client.request.assert_called_once() + call_args = client.request.call_args + assert call_args.args[0] == "SYNO.Docker.Registry" + assert call_args.args[1] == "get" + + +@pytest.mark.asyncio +async def test_list_registries_mirror_flag(): + from mcp_synology_container.modules.registries import register_registries + + data = { + "registries": [ + { + "name": "Mirror Registry", + "url": "https://mirror.example.com", + "syno": False, + "enable_registry_mirror": True, + "mirror_urls": ["https://mirror.example.com"], + } + ], + "using": "", + } + + client = AsyncMock() + client.request.return_value = data + mcp, tools = make_mock_mcp() + register_registries(mcp, make_config(), client) + + result = await tools["list_registries"]() + + assert "[mirror enabled]" in result + + +@pytest.mark.asyncio +async def test_list_registries_empty(): + from mcp_synology_container.modules.registries import register_registries + + client = AsyncMock() + client.request.return_value = {"registries": [], "using": ""} + mcp, tools = make_mock_mcp() + register_registries(mcp, make_config(), client) + + result = await tools["list_registries"]() + + assert "No registries" in result + + +@pytest.mark.asyncio +async def test_list_registries_api_error(): + from mcp_synology_container.dsm_client import SynologyError + from mcp_synology_container.modules.registries import register_registries + + client = AsyncMock() + client.request.side_effect = SynologyError("Permission denied", code=105) + mcp, tools = make_mock_mcp() + register_registries(mcp, make_config(), client) + + result = await tools["list_registries"]() + + assert "Error" in result + assert "Permission denied" in result