feat: v0.5.0 — welle B Teil 1 (registry tools: search, tags, pull)
Three new SYNO.Docker.Registry tools, reverse-engineered from a live DSM API capture (n4s4 reference disagrees on param names and methods). - search_registry (#5): SYNO.Docker.Registry/search v1 with JSON-encoded q, plus offset/limit/page_size. Renders stars, downloads, official flag, truncated description, and total match count. Read-only. - list_image_tags: SYNO.Docker.Registry/tags v1 with JSON-encoded repo (not name — DSM live capture diverges from n4s4). Response shape is unusual: tag list comes back as the envelope's data field directly. Output capped by limit (default 50); accepts both list and dict response shapes defensively. Read-only. - pull_image (#3): SYNO.Docker.Registry/pull_start v1 with both repository and tag JSON-encoded. Async pull — no pull_status method confirmed on this DSM, so completion is detected by polling SYNO.Docker.Image/list (2–10 s backoff, 240 s budget under the Claude Desktop ~4 min tool-call ceiling). Timeout returns a non-fatal "still running" hint. Short-circuits when the image is already present locally. Confirmation gate required. Tool count: 31 → 34. CLAUDE.md confirmation list updated. New DSM quirks documented for pull_start (no pull_status) and tags (repo param name, top-level data array). Closes #3 Closes #5 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+34
-1
@@ -2,7 +2,40 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [0.4.3] - 2026-05-18
|
## [0.5.0] - 2026-05-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
**Welle B Teil 1 — Registry tools (#3, #5).** Three new `SYNO.Docker.Registry`
|
||||||
|
tools, reverse-engineered from a live DSM API capture (the n4s4 reference
|
||||||
|
disagrees on parameter names and methods; the live capture wins):
|
||||||
|
|
||||||
|
- `search_registry` (#5) — search the active Docker registry by query string.
|
||||||
|
Uses `SYNO.Docker.Registry/search` (version 1) with the query JSON-encoded
|
||||||
|
as `q`, plus `offset`, `limit`, and `page_size`. Renders stars, downloads,
|
||||||
|
the official-image flag, and a truncated description per hit, and shows
|
||||||
|
the total match count so the caller knows when to raise `limit`. No
|
||||||
|
confirmation gate (read-only).
|
||||||
|
- `list_image_tags` — list available tags for a repository (bonus tool).
|
||||||
|
Uses `SYNO.Docker.Registry/tags` (version 1) with the repository
|
||||||
|
JSON-encoded as `repo` — note the parameter name diverges from the n4s4
|
||||||
|
reference which uses `name`. The response shape is unusual: DSM returns
|
||||||
|
the tag list as the envelope's `data` field directly (not wrapped in a
|
||||||
|
sub-key), so the tool accepts both shapes defensively. Output is capped
|
||||||
|
by `limit` (default 50) because popular images like `alpine` ship 200+
|
||||||
|
tags. No confirmation gate (read-only).
|
||||||
|
- `pull_image` (#3) — pull an image into the local cache via
|
||||||
|
`SYNO.Docker.Registry/pull_start` (version 1) with both `repository` and
|
||||||
|
`tag` JSON-encoded. Requires `confirmed=True`. DSM exposes no confirmed
|
||||||
|
`pull_status` method, so completion is detected by polling
|
||||||
|
`SYNO.Docker.Image/list` for the new `repository:tag` pair with a 2–10 s
|
||||||
|
backoff schedule and a 240 s overall budget (kept under the Claude
|
||||||
|
Desktop ~4 min tool-call ceiling). A timeout returns a non-fatal "still
|
||||||
|
running" hint pointing at `list_images` instead of raising — DSM keeps
|
||||||
|
pulling server-side regardless. Short-circuits when the image is already
|
||||||
|
present locally so a repeated call is cheap. Closes #3 and #5.
|
||||||
|
|
||||||
|
Tool count rises from 31 to 34.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Only a second consecutive failure is treated as a real auth problem.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implemented tools (31)
|
## Implemented tools (34)
|
||||||
|
|
||||||
| Category | Tools |
|
| Category | Tools |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -41,6 +41,7 @@ Only a second consecutive failure is treated as a real auth problem.
|
|||||||
| Containers | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats`, `delete_container`, `start_container`, `stop_container`, `restart_container` |
|
| Containers | `list_containers`, `get_container_status`, `get_container_logs`, `exec_in_container`, `container_stats`, `delete_container`, `start_container`, `stop_container`, `restart_container` |
|
||||||
| 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`, `inspect_image` |
|
| Images | `check_image_updates`, `list_images`, `delete_image`, `inspect_image` |
|
||||||
|
| Registry | `search_registry`, `list_image_tags`, `pull_image` |
|
||||||
| Networks | `list_networks`, `create_network`, `delete_network` |
|
| Networks | `list_networks`, `create_network`, `delete_network` |
|
||||||
| System | `system_df`, `system_prune`, `system_overview` |
|
| System | `system_df`, `system_prune`, `system_overview` |
|
||||||
|
|
||||||
@@ -58,7 +59,17 @@ Only a second consecutive failure is treated as a real auth problem.
|
|||||||
- **Image delete** — requires a form-encoded POST with a JSON `images` array
|
- **Image delete** — requires a form-encoded POST with a JSON `images` array
|
||||||
(confirmed via browser DevTools); uses `DsmClient.post_request()`.
|
(confirmed via browser DevTools); uses `DsmClient.post_request()`.
|
||||||
- **`SYNO.Docker.Image/pull`** — API method exists but behaviour varies by
|
- **`SYNO.Docker.Image/pull`** — API method exists but behaviour varies by
|
||||||
DSM version; not exposed as a standalone tool.
|
DSM version; not exposed as a standalone tool. `pull_image` uses
|
||||||
|
`SYNO.Docker.Registry/pull_start` instead (see below).
|
||||||
|
- **`SYNO.Docker.Registry/pull_start`** — asynchronous pull entry point;
|
||||||
|
no matching `pull_status` method confirmed. `pull_image` polls
|
||||||
|
`SYNO.Docker.Image/list` until `repository:tag` appears (2–10 s backoff,
|
||||||
|
240 s budget) and returns a "still running" hint on timeout instead of
|
||||||
|
raising — DSM keeps pulling server-side regardless of the HTTP response.
|
||||||
|
- **`SYNO.Docker.Registry/tags`** — uses `repo` (JSON-encoded) as the
|
||||||
|
parameter name; the n4s4 reference's `name` does not work on this DSM
|
||||||
|
version. Returns the tag list as the envelope's `data` field directly,
|
||||||
|
not wrapped in a sub-key.
|
||||||
- **`SYNO.Docker.Volume`** — endpoint does not exist; volume management is
|
- **`SYNO.Docker.Volume`** — endpoint does not exist; volume management is
|
||||||
not available via the DSM WebAPI.
|
not available via the DSM WebAPI.
|
||||||
- **`SYNO.Docker.Registry/get`** — does not behave as documented; registry
|
- **`SYNO.Docker.Registry/get`** — does not behave as documented; registry
|
||||||
@@ -77,7 +88,7 @@ Only a second consecutive failure is treated as a real auth problem.
|
|||||||
`redeploy_project`, `create_project`, `delete_project`,
|
`redeploy_project`, `create_project`, `delete_project`,
|
||||||
`exec_in_container`, `update_image_tag`, `update_env_var`,
|
`exec_in_container`, `update_image_tag`, `update_env_var`,
|
||||||
`update_compose`, `delete_container`, `stop_container`,
|
`update_compose`, `delete_container`, `stop_container`,
|
||||||
`restart_container`
|
`restart_container`, `pull_image`
|
||||||
- After compose changes: suggest `redeploy_project`
|
- After compose changes: suggest `redeploy_project`
|
||||||
- DSM errors → human-readable message, no stack traces
|
- DSM errors → human-readable message, no stack traces
|
||||||
- No secrets in stderr output
|
- No secrets in stderr output
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.4.3"
|
version = "0.5.0"
|
||||||
description = "MCP server for Synology Container Manager"
|
description = "MCP server for Synology Container Manager"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""MCP tools for SYNO.Docker.Registry: search, tags, pull."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
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__)
|
||||||
|
|
||||||
|
# Pull polling: backoff schedule capped at 10 s between checks. Total budget
|
||||||
|
# stays under the Claude Desktop ~4 min tool-call ceiling.
|
||||||
|
_PULL_POLL_TIMEOUT = 240.0
|
||||||
|
_PULL_POLL_INTERVALS: tuple[float, ...] = (2.0, 3.0, 5.0, 8.0, 10.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str, limit: int = 80) -> str:
|
||||||
|
"""Truncate text to limit with an ellipsis."""
|
||||||
|
text = (text or "").replace("\n", " ").replace("\r", " ").strip()
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return text[: limit - 1].rstrip() + "…"
|
||||||
|
|
||||||
|
|
||||||
|
async def _image_present(client: DsmClient, repository: str, tag: str) -> bool:
|
||||||
|
"""Return True if repository:tag is in the local image list."""
|
||||||
|
try:
|
||||||
|
data = await client.request(
|
||||||
|
"SYNO.Docker.Image",
|
||||||
|
"list",
|
||||||
|
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Pull polling: image list failed: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
images: list[dict[str, Any]] = data.get("images", []) if isinstance(data, dict) else []
|
||||||
|
for img in images:
|
||||||
|
repo = img.get("repository", "")
|
||||||
|
tags = img.get("tags") or []
|
||||||
|
if repo == repository and tag in tags:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def register_registry(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||||
|
"""Register all registry management tools with the MCP server."""
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_registry(query: str, limit: int = 20):
|
||||||
|
"""Search the active Docker registry for images matching query."""
|
||||||
|
if limit < 1:
|
||||||
|
limit = 1
|
||||||
|
elif limit > 100:
|
||||||
|
limit = 100
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await client.request(
|
||||||
|
"SYNO.Docker.Registry",
|
||||||
|
"search",
|
||||||
|
version=1,
|
||||||
|
params={
|
||||||
|
"q": json.dumps(query),
|
||||||
|
"offset": "0",
|
||||||
|
"limit": str(limit),
|
||||||
|
"page_size": str(limit),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error searching registry for '{query}': {e}"
|
||||||
|
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
total = 0
|
||||||
|
if isinstance(data, dict):
|
||||||
|
results = data.get("data", []) or []
|
||||||
|
total = int(data.get("total", 0) or 0)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return f"No results found for '{query}'."
|
||||||
|
|
||||||
|
lines = [f"Search results for '{query}' ({len(results)} of {total} total):", ""]
|
||||||
|
lines.append(" Stars Downloads Name (official) Description")
|
||||||
|
for hit in results:
|
||||||
|
name = hit.get("name", "?")
|
||||||
|
description = _truncate(hit.get("description", ""))
|
||||||
|
stars = int(hit.get("star_count", 0) or 0)
|
||||||
|
downloads = int(hit.get("downloads", 0) or 0)
|
||||||
|
official = " [official]" if hit.get("is_official") else ""
|
||||||
|
lines.append(f" {stars:>5} {downloads:>9} {name}{official}")
|
||||||
|
if description:
|
||||||
|
lines.append(f" {description}")
|
||||||
|
|
||||||
|
if total > len(results):
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Showing {len(results)} of {total}; raise limit to see more.")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def list_image_tags(repository: str, limit: int = 50):
|
||||||
|
"""List available tags for a registry image (e.g. 'nginx', 'grafana/grafana')."""
|
||||||
|
if limit < 1:
|
||||||
|
limit = 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await client.request(
|
||||||
|
"SYNO.Docker.Registry",
|
||||||
|
"tags",
|
||||||
|
version=1,
|
||||||
|
params={"repo": json.dumps(repository)},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error fetching tags for '{repository}': {e}"
|
||||||
|
|
||||||
|
# DSM returns the tag list as the envelope's `data` field directly.
|
||||||
|
# When empty, DsmClient.request() coerces to {} via `or {}`, so we
|
||||||
|
# accept both shapes here.
|
||||||
|
if isinstance(data, list):
|
||||||
|
entries: list[Any] = data
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
# Defensive: some DSM versions wrap as {"tags": [...]}.
|
||||||
|
entries = data.get("tags") or data.get("data") or []
|
||||||
|
else:
|
||||||
|
entries = []
|
||||||
|
|
||||||
|
tags: list[str] = []
|
||||||
|
for entry in entries:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
tag = entry.get("tag")
|
||||||
|
if isinstance(tag, str) and tag:
|
||||||
|
tags.append(tag)
|
||||||
|
elif isinstance(entry, str):
|
||||||
|
tags.append(entry)
|
||||||
|
|
||||||
|
if not tags:
|
||||||
|
return f"No tags found for '{repository}'."
|
||||||
|
|
||||||
|
total = len(tags)
|
||||||
|
shown = tags[:limit]
|
||||||
|
lines = [f"Tags for '{repository}' ({total} total):", ""]
|
||||||
|
lines.extend(f" {t}" for t in shown)
|
||||||
|
|
||||||
|
if total > limit:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Showing first {limit} of {total}; raise limit to see more.")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def pull_image(repository: str, tag: str = "latest", confirmed: bool = False):
|
||||||
|
"""Pull image from the active registry. Requires confirmed=True; polls for completion."""
|
||||||
|
target = f"{repository}:{tag}"
|
||||||
|
|
||||||
|
if not confirmed:
|
||||||
|
return (
|
||||||
|
f"Preview: would pull {target} from the active registry.\n"
|
||||||
|
f"Call pull_image(repository={repository!r}, tag={tag!r}, "
|
||||||
|
"confirmed=True) to proceed."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Short-circuit: if the image already exists locally, no pull needed.
|
||||||
|
if await _image_present(client, repository, tag):
|
||||||
|
return f"{target} is already present locally — nothing to pull."
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.request(
|
||||||
|
"SYNO.Docker.Registry",
|
||||||
|
"pull_start",
|
||||||
|
version=1,
|
||||||
|
params={
|
||||||
|
"repository": json.dumps(repository),
|
||||||
|
"tag": json.dumps(tag),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error starting pull for '{target}': {e}"
|
||||||
|
|
||||||
|
# Async pull — DSM exposes no confirmed pull_status method, so we poll
|
||||||
|
# Image/list for the new tag. Backoff schedule capped at 10 s; total
|
||||||
|
# budget under the Claude Desktop ~4 min tool-call ceiling.
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
deadline = loop.time() + _PULL_POLL_TIMEOUT
|
||||||
|
attempt = 0
|
||||||
|
while loop.time() < deadline:
|
||||||
|
interval = _PULL_POLL_INTERVALS[min(attempt, len(_PULL_POLL_INTERVALS) - 1)]
|
||||||
|
remaining = deadline - loop.time()
|
||||||
|
await asyncio.sleep(min(interval, max(remaining, 0.0)))
|
||||||
|
if await _image_present(client, repository, tag):
|
||||||
|
return f"Pulled {target} successfully."
|
||||||
|
attempt += 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"Pull of {target} started, still running — "
|
||||||
|
f"verify later with list_images or check_image_updates."
|
||||||
|
)
|
||||||
@@ -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.registry import register_registry
|
||||||
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_registry(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
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""Tests for modules/registry.py."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# search_registry — Issue #5
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SEARCH_RESPONSE = {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"name": "caddy",
|
||||||
|
"description": (
|
||||||
|
"Caddy 2 is a powerful, enterprise-ready, open source web "
|
||||||
|
"server with automatic HTTPS written in Go."
|
||||||
|
),
|
||||||
|
"downloads": 650989718,
|
||||||
|
"is_automated": False,
|
||||||
|
"is_official": True,
|
||||||
|
"star_count": 881,
|
||||||
|
"registry": "https://registry.hub.docker.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "abiosoft/caddy",
|
||||||
|
"description": "Caddy is a lightweight, general-purpose web server.",
|
||||||
|
"downloads": 111974910,
|
||||||
|
"is_automated": True,
|
||||||
|
"is_official": False,
|
||||||
|
"star_count": 289,
|
||||||
|
"registry": "https://registry.hub.docker.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"limit": 20,
|
||||||
|
"offset": 0,
|
||||||
|
"page_size": 20,
|
||||||
|
"total": 6647,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_registry_renders_hits():
|
||||||
|
from mcp_synology_container.modules.registry import register_registry
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.return_value = SEARCH_RESPONSE
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_registry(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["search_registry"](query="caddy")
|
||||||
|
|
||||||
|
assert "caddy" in result
|
||||||
|
assert "abiosoft/caddy" in result
|
||||||
|
assert "[official]" in result
|
||||||
|
assert "881" in result # stars
|
||||||
|
assert "650989718" in result # downloads
|
||||||
|
assert "2 of 6647" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_registry_uses_json_encoded_query():
|
||||||
|
from mcp_synology_container.modules.registry import register_registry
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.return_value = SEARCH_RESPONSE
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_registry(mcp, make_config(), client)
|
||||||
|
|
||||||
|
await tools["search_registry"](query="nginx", limit=10)
|
||||||
|
|
||||||
|
call = client.request.call_args
|
||||||
|
assert call.args[0] == "SYNO.Docker.Registry"
|
||||||
|
assert call.args[1] == "search"
|
||||||
|
assert call.kwargs.get("version") == 1
|
||||||
|
params = call.kwargs.get("params", {})
|
||||||
|
assert json.loads(params["q"]) == "nginx"
|
||||||
|
assert params["offset"] == "0"
|
||||||
|
assert params["limit"] == "10"
|
||||||
|
assert params["page_size"] == "10"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_registry_empty():
|
||||||
|
from mcp_synology_container.modules.registry import register_registry
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.return_value = {"data": [], "total": 0}
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_registry(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["search_registry"](query="does-not-exist")
|
||||||
|
assert "No results" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_registry_api_error():
|
||||||
|
from mcp_synology_container.dsm_client import SynologyError
|
||||||
|
from mcp_synology_container.modules.registry import register_registry
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.side_effect = SynologyError("Auth failure", code=119)
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_registry(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["search_registry"](query="caddy")
|
||||||
|
assert "Error" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# list_image_tags — bonus
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_image_tags_array_response():
|
||||||
|
"""DSM returns the envelope's data field directly as a list."""
|
||||||
|
from mcp_synology_container.modules.registry import register_registry
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.return_value = [
|
||||||
|
{"tag": "latest"},
|
||||||
|
{"tag": "3.20"},
|
||||||
|
{"tag": "3.19"},
|
||||||
|
]
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_registry(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["list_image_tags"](repository="alpine")
|
||||||
|
|
||||||
|
assert "alpine" in result
|
||||||
|
assert "latest" in result
|
||||||
|
assert "3.20" in result
|
||||||
|
assert "3.19" in result
|
||||||
|
assert "3 total" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_image_tags_uses_json_repo_param():
|
||||||
|
from mcp_synology_container.modules.registry import register_registry
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.return_value = [{"tag": "latest"}]
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_registry(mcp, make_config(), client)
|
||||||
|
|
||||||
|
await tools["list_image_tags"](repository="grafana/grafana")
|
||||||
|
|
||||||
|
call = client.request.call_args
|
||||||
|
assert call.args[0] == "SYNO.Docker.Registry"
|
||||||
|
assert call.args[1] == "tags"
|
||||||
|
assert call.kwargs.get("version") == 1
|
||||||
|
params = call.kwargs.get("params", {})
|
||||||
|
assert json.loads(params["repo"]) == "grafana/grafana"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_image_tags_limit_truncates():
|
||||||
|
from mcp_synology_container.modules.registry import register_registry
|
||||||
|
|
||||||
|
tags = [{"tag": f"v{i}"} for i in range(120)]
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.return_value = tags
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_registry(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["list_image_tags"](repository="alpine", limit=10)
|
||||||
|
assert "120 total" in result
|
||||||
|
assert "Showing first 10 of 120" in result
|
||||||
|
# Tag #10 must not appear (only v0..v9)
|
||||||
|
assert " v9" in result
|
||||||
|
assert " v10" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_image_tags_empty():
|
||||||
|
from mcp_synology_container.modules.registry import register_registry
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
# Empty list → DsmClient.request coerces to {} via `or {}`
|
||||||
|
client.request.return_value = {}
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_registry(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["list_image_tags"](repository="nonexistent/image")
|
||||||
|
assert "No tags found" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_image_tags_api_error():
|
||||||
|
from mcp_synology_container.dsm_client import SynologyError
|
||||||
|
from mcp_synology_container.modules.registry import register_registry
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.side_effect = SynologyError("Boom", code=100)
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_registry(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["list_image_tags"](repository="alpine")
|
||||||
|
assert "Error" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# pull_image — Issue #3
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pull_image_preview():
|
||||||
|
from mcp_synology_container.modules.registry import register_registry
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_registry(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["pull_image"](repository="nginx", tag="1.24")
|
||||||
|
assert "Preview" in result
|
||||||
|
assert "nginx:1.24" in result
|
||||||
|
assert "confirmed=True" in result
|
||||||
|
client.request.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pull_image_already_present():
|
||||||
|
"""If image is already in Image/list, no pull_start call is made."""
|
||||||
|
from mcp_synology_container.modules.registry import register_registry
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
|
||||||
|
async def mock_request(api, method, **kwargs):
|
||||||
|
if api == "SYNO.Docker.Image" and method == "list":
|
||||||
|
return {
|
||||||
|
"images": [
|
||||||
|
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||||
|
|
||||||
|
client.request.side_effect = mock_request
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_registry(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||||
|
assert "already present" in result
|
||||||
|
|
||||||
|
# Only the Image/list pre-check was called; pull_start must NOT fire.
|
||||||
|
calls = client.request.call_args_list
|
||||||
|
assert all(c.args[1] != "pull_start" for c in calls)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pull_image_confirmed_success(monkeypatch):
|
||||||
|
"""pull_start succeeds, then Image/list shows the new tag on first poll."""
|
||||||
|
import mcp_synology_container.modules.registry as registry_mod
|
||||||
|
from mcp_synology_container.modules.registry import register_registry
|
||||||
|
|
||||||
|
# Make polling instant
|
||||||
|
async def fake_sleep(_):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
|
||||||
|
|
||||||
|
state = {"pulled": False}
|
||||||
|
|
||||||
|
async def mock_request(api, method, **kwargs):
|
||||||
|
if api == "SYNO.Docker.Image" and method == "list":
|
||||||
|
if state["pulled"]:
|
||||||
|
return {
|
||||||
|
"images": [
|
||||||
|
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return {"images": []}
|
||||||
|
if api == "SYNO.Docker.Registry" and method == "pull_start":
|
||||||
|
state["pulled"] = True
|
||||||
|
return {}
|
||||||
|
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.side_effect = mock_request
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_registry(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||||
|
assert "Pulled" in result
|
||||||
|
assert "nginx:1.24" in result
|
||||||
|
|
||||||
|
# Verify pull_start was invoked with JSON-encoded params
|
||||||
|
pull_calls = [c for c in client.request.call_args_list if c.args[1] == "pull_start"]
|
||||||
|
assert len(pull_calls) == 1
|
||||||
|
params = pull_calls[0].kwargs.get("params", {})
|
||||||
|
assert json.loads(params["repository"]) == "nginx"
|
||||||
|
assert json.loads(params["tag"]) == "1.24"
|
||||||
|
assert pull_calls[0].kwargs.get("version") == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pull_image_timeout(monkeypatch):
|
||||||
|
"""If the image never appears, the tool returns a still-running hint."""
|
||||||
|
import mcp_synology_container.modules.registry as registry_mod
|
||||||
|
from mcp_synology_container.modules.registry import register_registry
|
||||||
|
|
||||||
|
# Make polling instant
|
||||||
|
async def fake_sleep(_):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
|
||||||
|
# Shrink the budget so the loop exits quickly
|
||||||
|
monkeypatch.setattr(registry_mod, "_PULL_POLL_TIMEOUT", 0.05)
|
||||||
|
|
||||||
|
async def mock_request(api, method, **kwargs):
|
||||||
|
if api == "SYNO.Docker.Image" and method == "list":
|
||||||
|
return {"images": []}
|
||||||
|
if api == "SYNO.Docker.Registry" and method == "pull_start":
|
||||||
|
return {}
|
||||||
|
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.side_effect = mock_request
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_registry(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||||
|
assert "still running" in result
|
||||||
|
assert "nginx:1.24" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pull_image_start_error():
|
||||||
|
from mcp_synology_container.dsm_client import SynologyError
|
||||||
|
from mcp_synology_container.modules.registry import register_registry
|
||||||
|
|
||||||
|
async def mock_request(api, method, **kwargs):
|
||||||
|
if api == "SYNO.Docker.Image" and method == "list":
|
||||||
|
return {"images": []}
|
||||||
|
if api == "SYNO.Docker.Registry" and method == "pull_start":
|
||||||
|
raise SynologyError("Permission denied", code=105)
|
||||||
|
raise AssertionError(f"Unexpected call: {api}/{method}")
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.side_effect = mock_request
|
||||||
|
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_registry(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
|
||||||
|
assert "Error starting pull" in result
|
||||||
@@ -362,7 +362,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.4.3"
|
version = "0.5.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user