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:
@@ -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`**
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user