feat: v0.7.0 — inspect_container (full-path mount source)

New tool inspect_container surfaces the full configuration of a single
container as the foundation for a future GUI-container → Compose
migration workflow. Output covers image, status, restart policy,
network mode + per-network IPs, port bindings, volume mounts, env
vars, labels, entrypoint/command, links, and capabilities.

Mount paths come 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 not directly Compose-usable. Verified live against the NAS;
quirk documented in CLAUDE.md.

DSM API: SYNO.Docker.Container/get with name JSON-encoded (action
inspect does not exist and returns code 103). Hash-prefixed names
are resolved transparently, matching the convention of the other
container tools.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 15:59:51 +02:00
parent 036429e9bf
commit 7bb9b00dcc
6 changed files with 433 additions and 4 deletions
+34
View File
@@ -2,6 +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.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 ## [0.6.0] - 2026-05-18
### Changed ### Changed
+13 -2
View File
@@ -33,12 +33,12 @@ Only a second consecutive failure is treated as a real auth problem.
--- ---
## Implemented tools (34) ## Implemented tools (35)
| Category | Tools | | Category | Tools |
|---|---| |---|---|
| Projects | `list_projects`, `get_project_status`, `start_project`, `stop_project`, `redeploy_project`, `create_project`, `delete_project` | | 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` | | 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` | | 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 start/stop/force-stop/restart/reset; calls to `pause`/`unpause` return
"Method does not exist". `pause_container` and `unpause_container` "Method does not exist". `pause_container` and `unpause_container`
were briefly shipped in 0.4.0 and removed in 0.4.1. 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`.
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "mcp-synology-container" name = "mcp-synology-container"
version = "0.6.0" version = "0.7.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 = [
@@ -368,6 +368,25 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
except Exception as e: except Exception as e:
return f"Error stopping container '{container_name}': {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() @mcp.tool()
async def restart_container(container_name: str, confirmed: bool = False): async def restart_container(container_name: str, confirmed: bool = False):
"""Restart a container. Requires confirmed=True.""" """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}") lines.append(f" {src}{dst}{type_tag}{rw}")
return "\n".join(lines) 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)
+224
View File
@@ -623,6 +623,230 @@ async def test_get_container_status_shows_mounts():
assert "/var/jenkins_home" in result 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 @pytest.mark.asyncio
async def test_get_container_logs_resolves_hash_prefix(): async def test_get_container_logs_resolves_hash_prefix():
"""get_container_logs resolves 'jenkins' to 'f93cb8b504f7_jenkins' for DSM call.""" """get_container_logs resolves 'jenkins' to 'f93cb8b504f7_jenkins' for DSM call."""
Generated
+1 -1
View File
@@ -362,7 +362,7 @@ wheels = [
[[package]] [[package]]
name = "mcp-synology-container" name = "mcp-synology-container"
version = "0.6.0" version = "0.7.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },