diff --git a/CLAUDE.md b/CLAUDE.md index 6b0df93..58279dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,17 +32,16 @@ via Container Manager. Der MCP-Server ist in Claude Desktop aktiv verbunden. ## Aktueller Stand -### Implementierte Tools (19) +### Implementierte Tools (17) | 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`, `pull_image` | +| Images | `check_image_updates`, `list_images`, `delete_image` | | Netzwerke | `list_networks`, `create_network`, `delete_network` | | System | `system_df`, `system_prune` | -| Registries | `list_registries` | ### Bekannte Bugs @@ -178,22 +177,17 @@ Implementiere **eine Gruppe nach der anderen**. Commit + Push nach jeder Gruppe, --- -#### Gruppe 6 – Images Ergänzung `modules/images.py` ✦ Prio: niedrig +#### Gruppe 6 – Images Ergänzung – entfällt -**`pull_image`** -- Signatur: `pull_image(image: str) -> str` (z.B. `"postgres:17.8"`) -- DSM API: `SYNO.Docker.Image`, method `create` oder pull-Endpunkt -- Fortschritt streamen wenn möglich, sonst polling -- Confirmation: nein +`pull_image` entfällt — SYNO.Docker.Image/pull liefert keinen nutzbaren Endpunkt +(DSM-Methode unbekannt / nicht über WebAPI erreichbar). --- -#### Gruppe 7 – Registries `modules/registries.py` (neu) ✦ Prio: niedrig +#### Gruppe 7 – Registries – entfällt -**`list_registries`** -- DSM API: `SYNO.Docker.Registry`, method `list` -- Ausgabe: Name, URL, ob authentifiziert -- Confirmation: nein +`list_registries` entfällt — SYNO.Docker.Registry/get funktioniert nicht wie erwartet +(DSM-Methode unbekannt / Produkttest fehlgeschlagen). --- diff --git a/src/mcp_synology_container/modules/images.py b/src/mcp_synology_container/modules/images.py index c625b6d..a74f828 100644 --- a/src/mcp_synology_container/modules/images.py +++ b/src/mcp_synology_container/modules/images.py @@ -1,8 +1,7 @@ -"""MCP tools for SYNO.Docker.Image: list, check updates, delete, pull.""" +"""MCP tools for SYNO.Docker.Image: list, check updates, delete.""" from __future__ import annotations -import asyncio import json import logging import sys @@ -270,56 +269,6 @@ 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 deleted file mode 100644 index 4bc2645..0000000 --- a/src/mcp_synology_container/modules/registries.py +++ /dev/null @@ -1,49 +0,0 @@ -"""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 6d19a79..4caccd6 100644 --- a/src/mcp_synology_container/server.py +++ b/src/mcp_synology_container/server.py @@ -31,7 +31,6 @@ 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) @@ -40,7 +39,6 @@ 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 b9547e3..a3c9b23 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, patch +from unittest.mock import AsyncMock import pytest @@ -491,140 +491,3 @@ 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 deleted file mode 100644 index 0229e27..0000000 --- a/tests/test_modules/test_registries.py +++ /dev/null @@ -1,173 +0,0 @@ -"""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