diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b744c1..1c2baaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to this project will be documented in this file. +## [0.7.0] - 2026-05-18 + +### Added + +**`inspect_container`** — new tool that returns the full configuration +of a single container, intended as the foundation for a future +GUI-container → Compose migration workflow. + +- API: `SYNO.Docker.Container/get` with `name` JSON-encoded (the same + endpoint already used by `get_container_status` and `delete_container`, + but called directly with a focus on the migration-relevant fields). + The DSM action `inspect` (no `get`) does not exist and returns DSM + code 103 — use `get`. +- Output covers: image, status / running, restart policy, network + mode and per-network IP addresses, port bindings, volume mounts + (with FULL `/volume1/...` host path — see DSM quirk below), + environment variables, labels, entrypoint / command, links, and + capabilities / privileged. +- Hash-prefixed container names (`abcdef012345_myservice`) are resolved + to the actual DSM name for the API call and stripped from the + display header — same convention as the other container tools. + +### Documented (DSM quirk) + +**`profile.volume_bindings[].host_volume_file` is share-relative**, not +the full host path. A live capture against a container with a +`/volume1/docker/homeassistant` bind mount returned +`host_volume_file = "/docker/homeassistant"` in `profile`, while +`details.Mounts[].Source` carried the full +`/volume1/docker/homeassistant`. For a Compose-rebuild use case the +full path is required — `inspect_container` therefore reads mount +sources from `details.Mounts[].Source`, not from +`profile.volume_bindings[].host_volume_file`. Recorded in CLAUDE.md. + ## [0.6.0] - 2026-05-18 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index e303757..e54f8a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,12 +33,12 @@ Only a second consecutive failure is treated as a real auth problem. --- -## Implemented tools (34) +## Implemented tools (35) | Category | Tools | |---|---| | Projects | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project`, `create_project`, `delete_project` | -| 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`, `inspect_container`, `delete_container`, `start_container`, `stop_container`, `restart_container` | | Compose | `read_compose`, `update_compose`, `update_image_tag`, `update_env_var` | | Images | `check_image_updates`, `list_images`, `delete_image`, `inspect_image` | | Registry | `search_registry`, `list_image_tags`, `pull_image` | @@ -98,6 +98,17 @@ Only a second consecutive failure is treated as a real auth problem. start/stop/force-stop/restart/reset; calls to `pause`/`unpause` return "Method does not exist". `pause_container` and `unpause_container` were briefly shipped in 0.4.0 and removed in 0.4.1. +- **`SYNO.Docker.Container/get` response — `profile.volume_bindings[].host_volume_file` + is share-relative, not the full host path.** Live capture against a + container with bind mount `/volume1/docker/homeassistant:/config` + returned `host_volume_file = "/docker/homeassistant"` (21 chars, + share-relative) in `profile`, while `details.Mounts[].Source` carried + the full `/volume1/docker/homeassistant` and `details.HostConfig.Binds` + the full `/volume1/docker/homeassistant:/config:rw`. For + Compose-rebuild use cases the full path is required — `inspect_container` + reads mount sources from `details.Mounts[].Source`, not from + `profile.volume_bindings[].host_volume_file`. The DSM action `inspect` + (no `get`) does not exist (code 103 "Method does not exist"); use `get`. --- diff --git a/pyproject.toml b/pyproject.toml index a1b8c19..2ca7661 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-synology-container" -version = "0.6.0" +version = "0.7.0" description = "MCP server for Synology Container Manager" requires-python = ">=3.12" dependencies = [ diff --git a/src/mcp_synology_container/modules/containers.py b/src/mcp_synology_container/modules/containers.py index 66e3862..6e68353 100644 --- a/src/mcp_synology_container/modules/containers.py +++ b/src/mcp_synology_container/modules/containers.py @@ -368,6 +368,25 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N except Exception as e: return f"Error stopping container '{container_name}': {e}" + @mcp.tool() + async def inspect_container(container_name: str): + """Inspect a container — image, ports, mounts (full host paths), env, labels, network.""" + resolved_name = await _resolve_container_name(client, container_name) + display_name = _strip_hash_prefix(resolved_name) + try: + data = await client.request( + "SYNO.Docker.Container", + "get", + params={"name": json.dumps(resolved_name)}, + ) + except Exception as e: + return f"Error inspecting container '{container_name}': {e}" + + if not data: + return f"Container '{container_name}' not found." + + return _format_container_inspect(display_name, data) + @mcp.tool() async def restart_container(container_name: str, confirmed: bool = False): """Restart a container. Requires confirmed=True.""" @@ -457,3 +476,144 @@ def _format_container_detail(name: str, data: dict[str, Any]) -> str: lines.append(f" {src} → {dst}{type_tag}{rw}") return "\n".join(lines) + + +def _format_container_inspect(name: str, data: dict[str, Any]) -> str: + """Format SYNO.Docker.Container/get for the migration-oriented inspect view. + + Reads the volume host path from ``details.Mounts[].Source`` (full + ``/volume1/...`` path), NOT from ``profile.volume_bindings[].host_volume_file`` + — the latter is share-relative (e.g. ``/docker/foo`` for ``/volume1/docker/foo``) + and is not directly Compose-usable. See CLAUDE.md DSM quirks. + """ + details: dict[str, Any] = data.get("details", {}) or {} + profile: dict[str, Any] = data.get("profile", {}) or {} + + state: dict[str, Any] = details.get("State", {}) or {} + host_config: dict[str, Any] = details.get("HostConfig", {}) or {} + + image_str = profile.get("image") or details.get("Config", {}).get("Image", "?") + status_str = state.get("Status", "?") + running = state.get("Running", False) + restart_count = details.get("RestartCount", 0) + + lines = [ + f"Container: {name}", + f" Image: {image_str}", + f" Status: {status_str} (running={running})", + ] + if restart_count: + lines.append(f" Restarts: {restart_count}") + + # ── Restart policy ────────────────────────────────────────────────────── + restart_policy = host_config.get("RestartPolicy", {}) or {} + policy_name = restart_policy.get("Name", "") + if policy_name: + max_retry = restart_policy.get("MaximumRetryCount", 0) + suffix = f" (max {max_retry})" if policy_name == "on-failure" and max_retry else "" + lines.append(f" Restart: {policy_name}{suffix}") + elif profile.get("enable_restart_policy"): + lines.append(" Restart: enabled") + + # ── Network ───────────────────────────────────────────────────────────── + net_mode = profile.get("network_mode") or host_config.get("NetworkMode", "") + use_host = profile.get("use_host_network", False) + if use_host: + lines.append(" Network: host") + elif net_mode: + lines.append(f" Network: {net_mode}") + + networks: dict[str, Any] = (details.get("NetworkSettings", {}) or {}).get("Networks", {}) or {} + for net_name, net in networks.items(): + ip = (net or {}).get("IPAddress", "") + if ip: + lines.append(f" {net_name}: {ip}") + + # ── Ports ─────────────────────────────────────────────────────────────── + port_bindings: list[dict[str, Any]] = profile.get("port_bindings", []) or [] + if port_bindings: + lines.append("") + lines.append(f"Ports ({len(port_bindings)}):") + for pb in port_bindings: + host = pb.get("host_port", "?") + ctr = pb.get("container_port", "?") + proto = pb.get("type", "tcp") + lines.append(f" {host} → {ctr}/{proto}") + + # ── Mounts ────────────────────────────────────────────────────────────── + # Use details.Mounts[].Source — it's the full /volume1/... path. + # profile.volume_bindings[].host_volume_file is share-relative and not + # Compose-usable (DSM quirk; see CLAUDE.md). + mounts: list[dict[str, Any]] = details.get("Mounts", []) or [] + if mounts: + lines.append("") + lines.append(f"Mounts ({len(mounts)}):") + for m in mounts: + src = m.get("Source", "?") + dst = m.get("Destination", "?") + mtype = m.get("Type", "") + rw = "" if m.get("RW", True) else " [ro]" + type_tag = f" ({mtype})" if mtype else "" + lines.append(f" {src} → {dst}{type_tag}{rw}") + + # ── Environment ───────────────────────────────────────────────────────── + env_vars: list[Any] = profile.get("env_variables") or [] + if env_vars: + lines.append("") + lines.append(f"Environment ({len(env_vars)}):") + for var in env_vars: + if isinstance(var, dict): + lines.append(f" {var.get('key', '?')}={var.get('value', '')}") + else: + lines.append(f" {var}") + + # ── Labels ────────────────────────────────────────────────────────────── + labels: dict[str, Any] = profile.get("labels") or {} + if labels: + lines.append("") + lines.append(f"Labels ({len(labels)}):") + for key in sorted(labels): + lines.append(f" {key}={labels[key]}") + + # ── Command / Entrypoint ──────────────────────────────────────────────── + cfg = details.get("Config", {}) or {} + entrypoint = cfg.get("Entrypoint") or [] + cmd = cfg.get("Cmd") or profile.get("cmd_v2") or profile.get("cmd") or "" + + if entrypoint: + ep_str = ( + " ".join(str(x) for x in entrypoint) + if isinstance(entrypoint, list) + else str(entrypoint) + ) + lines.append("") + lines.append(f"Entrypoint: {ep_str}") + if cmd: + cmd_str = " ".join(str(x) for x in cmd) if isinstance(cmd, list) else str(cmd) + if not entrypoint: + lines.append("") + lines.append(f"Cmd: {cmd_str}") + + # ── Links ─────────────────────────────────────────────────────────────── + links: list[Any] = profile.get("links") or [] + if links: + lines.append("") + lines.append(f"Links ({len(links)}):") + for link in links: + lines.append(f" {link}") + + # ── Capabilities / Privileged ─────────────────────────────────────────── + cap_add = profile.get("CapAdd") or host_config.get("CapAdd") or [] + cap_drop = profile.get("CapDrop") or host_config.get("CapDrop") or [] + privileged = profile.get("privileged") or host_config.get("Privileged", False) + if cap_add or cap_drop or privileged: + lines.append("") + lines.append("Security:") + if privileged: + lines.append(" privileged=true") + if cap_add: + lines.append(f" CapAdd: {', '.join(str(c) for c in cap_add)}") + if cap_drop: + lines.append(f" CapDrop: {', '.join(str(c) for c in cap_drop)}") + + return "\n".join(lines) diff --git a/tests/test_modules/test_containers.py b/tests/test_modules/test_containers.py index 85e8e7f..63bafe4 100644 --- a/tests/test_modules/test_containers.py +++ b/tests/test_modules/test_containers.py @@ -623,6 +623,230 @@ async def test_get_container_status_shows_mounts(): assert "/var/jenkins_home" in result +# ────────────────────────────────────────────────────────────────────────────── +# inspect_container +# ────────────────────────────────────────────────────────────────────────────── + +# Live-captured-style response (homeassistant) — proves that +# details.Mounts[].Source carries the full /volume1/... path while +# profile.volume_bindings[].host_volume_file is share-relative. +INSPECT_RESPONSE = { + "details": { + "Config": { + "Image": "homeassistant/home-assistant:stable", + "Entrypoint": ["/init"], + "Cmd": None, + "Env": ["TZ=Europe/Berlin"], + }, + "HostConfig": { + "RestartPolicy": {"Name": "always", "MaximumRetryCount": 0}, + "NetworkMode": "frostiq_net", + "Privileged": False, + "CapAdd": None, + "CapDrop": None, + "Binds": ["/volume1/docker/homeassistant:/config:rw"], + }, + "Mounts": [ + { + "Type": "bind", + "Source": "/volume1/docker/homeassistant", + "Destination": "/config", + "RW": True, + } + ], + "NetworkSettings": { + "Networks": { + "frostiq_net": {"IPAddress": "172.18.0.5"}, + } + }, + "RestartCount": 0, + "State": {"Status": "running", "Running": True}, + }, + "profile": { + "image": "homeassistant/home-assistant:stable", + "name": "homeassistant", + "enable_restart_policy": True, + "network_mode": "frostiq_net", + "use_host_network": False, + "port_bindings": [{"container_port": 8123, "host_port": 8123, "type": "tcp"}], + # Share-relative — must NOT be the path the tool reports. + "volume_bindings": [ + { + "host_volume_file": "/docker/homeassistant", + "mount_point": "/config", + "type": "rw", + "is_directory": True, + } + ], + "env_variables": [ + {"key": "TZ", "value": "Europe/Berlin"}, + {"key": "LANG", "value": "C.UTF-8"}, + ], + "labels": {"io.hass.type": "core", "io.hass.version": "2026.2.3"}, + "links": [], + "cmd": "", + "cmd_v2": "", + "privileged": False, + "CapAdd": None, + "CapDrop": None, + }, +} + + +@pytest.mark.asyncio +async def test_inspect_container_uses_full_host_path(): + """inspect_container must report /volume1/docker/... (full) — not + /docker/... (share-relative) — for volume mounts. The compose-rebuild + workflow depends on the full host path.""" + from mcp_synology_container.modules.containers import register_containers + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Container" and method == "list": + return {"containers": [{"name": "homeassistant"}]} + if api == "SYNO.Docker.Container" and method == "get": + return INSPECT_RESPONSE + return {} + + client = AsyncMock() + client.request.side_effect = mock_request + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["inspect_container"]("homeassistant") + assert "/volume1/docker/homeassistant" in result + # The share-relative shortcut must not appear as a mount source. + assert " /docker/homeassistant " not in result # not as standalone path + assert "→ /config" in result + + +@pytest.mark.asyncio +async def test_inspect_container_shows_core_fields(): + from mcp_synology_container.modules.containers import register_containers + + client = AsyncMock() + client.request.return_value = INSPECT_RESPONSE + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["inspect_container"]("homeassistant") + # Header + assert "Container: homeassistant" in result + assert "homeassistant/home-assistant:stable" in result + assert "running" in result + # Restart policy + assert "always" in result + # Network + assert "frostiq_net" in result + assert "172.18.0.5" in result + # Ports + assert "8123" in result + # Env + assert "TZ=Europe/Berlin" in result + # Labels + assert "io.hass.version=2026.2.3" in result + # Entrypoint + assert "/init" in result + + +@pytest.mark.asyncio +async def test_inspect_container_calls_get_with_json_name(): + """inspect_container must send name= as a JSON-encoded string (DSM + Container/get is documented to accept both but json.dumps keeps the + convention shared with start/stop/restart/delete).""" + from mcp_synology_container.modules.containers import register_containers + + seen: dict[str, object] = {} + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Container" and method == "list": + return {"containers": [{"name": "homeassistant"}]} + if api == "SYNO.Docker.Container" and method == "get": + seen["params"] = kwargs.get("params") + return INSPECT_RESPONSE + return {} + + client = AsyncMock() + client.request.side_effect = mock_request + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + await tools["inspect_container"]("homeassistant") + assert seen["params"] == {"name": '"homeassistant"'} + + +@pytest.mark.asyncio +async def test_inspect_container_resolves_hash_prefix(): + """If DSM stores the container as 'abcdef012345_homeassistant', a user + request for 'homeassistant' must resolve to the prefixed name and the + displayed header must show the clean name.""" + from mcp_synology_container.modules.containers import register_containers + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Container" and method == "list": + return {"containers": [{"name": "abcdef012345_homeassistant"}]} + if api == "SYNO.Docker.Container" and method == "get": + assert kwargs["params"]["name"] == '"abcdef012345_homeassistant"' + return INSPECT_RESPONSE + return {} + + client = AsyncMock() + client.request.side_effect = mock_request + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["inspect_container"]("homeassistant") + assert "Container: homeassistant" in result + assert "abcdef012345" not in result + + +@pytest.mark.asyncio +async def test_inspect_container_not_found(): + from mcp_synology_container.modules.containers import register_containers + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Container" and method == "list": + return {"containers": []} + if api == "SYNO.Docker.Container" and method == "get": + return None + return {} + + client = AsyncMock() + client.request.side_effect = mock_request + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["inspect_container"]("ghost") + assert "not found" in result + + +@pytest.mark.asyncio +async def test_inspect_container_api_error(): + from mcp_synology_container.dsm_client import SynologyError + from mcp_synology_container.modules.containers import register_containers + + async def mock_request(api, method, **kwargs): + if api == "SYNO.Docker.Container" and method == "list": + return {"containers": [{"name": "homeassistant"}]} + if api == "SYNO.Docker.Container" and method == "get": + raise SynologyError("API error", code=102) + return {} + + client = AsyncMock() + client.request.side_effect = mock_request + + mcp, tools = make_mock_mcp() + register_containers(mcp, make_config(), client) + + result = await tools["inspect_container"]("homeassistant") + assert "Error" in result + assert "homeassistant" in result + + @pytest.mark.asyncio async def test_get_container_logs_resolves_hash_prefix(): """get_container_logs resolves 'jenkins' to 'f93cb8b504f7_jenkins' for DSM call.""" diff --git a/uv.lock b/uv.lock index e651239..e57d10b 100644 --- a/uv.lock +++ b/uv.lock @@ -362,7 +362,7 @@ wheels = [ [[package]] name = "mcp-synology-container" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "click" },