Remove pull_image + list_registries; mark Gruppen 6+7 as entfällt

DSM methods for SYNO.Docker.Image/pull and SYNO.Docker.Registry/get
did not behave as expected in production testing against the NAS.
Tools deregistered, modules deleted, tests removed, CLAUDE.md updated.
Tool count: 19 → 17.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 20:03:17 +02:00
parent 5fe8f5bc73
commit 6fa35e1b48
6 changed files with 10 additions and 428 deletions
+8 -14
View File
@@ -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).
---
+1 -52
View File
@@ -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.
@@ -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()
-2
View File
@@ -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
+1 -138
View File
@@ -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
-173
View File
@@ -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