Add pull_image + list_registries; remove Gruppe 5 (no Volume API)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 19:28:45 +02:00
parent 59f7fc1d6c
commit 5fe8f5bc73
6 changed files with 421 additions and 46 deletions
+7 -44
View File
@@ -32,14 +32,17 @@ via Container Manager. Der MCP-Server ist in Claude Desktop aktiv verbunden.
## Aktueller Stand ## Aktueller Stand
### Implementierte Tools (17) ### Implementierte Tools (19)
| Kategorie | Tools | | Kategorie | Tools |
|---|---| |---|---|
| Projekte | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project` | | 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` | | 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` | | 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 ### Bekannte Bugs
@@ -52,31 +55,9 @@ via Container Manager. Der MCP-Server ist in Claude Desktop aktiv verbunden.
## Roadmap (geplante Erweiterungen) ## Roadmap (geplante Erweiterungen)
### Images ~~Alle geplanten Erweiterungen implementiert.~~
- `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
### Container Volumes entfällt — SYNO.Docker.Volume existiert nicht (kein DSM-Endpunkt).
- ~~`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
--- ---
@@ -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 #### Gruppe 6 Images Ergänzung `modules/images.py` ✦ Prio: niedrig
**`pull_image`** **`pull_image`**
+52 -1
View File
@@ -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 from __future__ import annotations
import asyncio
import json import json
import logging import logging
import sys import sys
@@ -269,6 +270,56 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
return f"Deleted {display_name}{size_str} freed." 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() @mcp.tool()
async def check_image_updates(project_name: str | None = None) -> str: async def check_image_updates(project_name: str | None = None) -> str:
"""Check for available image updates for a project or all images. """Check for available image updates for a project or all images.
@@ -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()
+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.images import register_images
from mcp_synology_container.modules.networks import register_networks from mcp_synology_container.modules.networks import register_networks
from mcp_synology_container.modules.projects import register_projects 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 from mcp_synology_container.modules.system import register_system
register_projects(mcp, config, client) register_projects(mcp, config, client)
@@ -39,6 +40,7 @@ def create_server(config: AppConfig, client: DsmClient) -> FastMCP:
register_images(mcp, config, client) register_images(mcp, config, client)
register_system(mcp, config, client) register_system(mcp, config, client)
register_networks(mcp, config, client) register_networks(mcp, config, client)
register_registries(mcp, config, client)
logger.info("MCP server configured with all tool modules") logger.info("MCP server configured with all tool modules")
return mcp return mcp
+138 -1
View File
@@ -1,6 +1,6 @@
"""Tests for modules/images.py.""" """Tests for modules/images.py."""
from unittest.mock import AsyncMock from unittest.mock import AsyncMock, patch
import pytest import pytest
@@ -491,3 +491,140 @@ async def test_check_image_updates_for_project():
result = await tools["check_image_updates"](project_name="myapp") result = await tools["check_image_updates"](project_name="myapp")
assert "myapp" in result assert "myapp" in result
assert "nginx:1.24" 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
@@ -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