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
@@ -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)