Fix get_container_status: clean name + dual-format response handling

DSM response has two top-level keys:
  details → Docker Engine inspect (State, NetworkSettings, Mounts)
  profile → DSM format (image, port_bindings)

_format_container_detail now reads:
  Status/Running/StartedAt from details.State
  Image               from profile.image
  IP addresses        from details.NetworkSettings.Networks
  Port bindings       from profile.port_bindings
  Mounts              from details.Mounts

Also: debug_container_response tool removed, json/sys imports cleaned up.
27 container tests all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 06:59:50 +02:00
parent 2dbcc0ba5f
commit d9f0e75d0a
2 changed files with 115 additions and 84 deletions
@@ -2,10 +2,8 @@
from __future__ import annotations
import json
import logging
import re
import sys
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
@@ -108,29 +106,6 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
return "\n".join(lines).rstrip()
@mcp.tool()
async def debug_container_response(container_name: str) -> str:
"""TEMPORARY DEBUG TOOL — returns raw SYNO.Docker.Container/get response as JSON.
Used to inspect the actual DSM response structure so that
get_container_status can be fixed to read the correct fields.
Remove once the real fix is in place.
Args:
container_name: Name of the container to inspect.
"""
clean_name = _strip_hash_prefix(container_name)
try:
data = await client.request(
"SYNO.Docker.Container",
"get",
params={"name": clean_name},
)
except Exception as e:
return f"Error calling SYNO.Docker.Container/get for '{clean_name}': {e}"
return json.dumps(data, indent=2, default=str)
@mcp.tool()
async def get_container_status(container_name: str) -> str:
"""Get detailed status, uptime, and resource usage of a container.
@@ -138,9 +113,7 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
Args:
container_name: Name of the container to inspect.
"""
# Strip hash prefix from user input; SYNO.Docker.Container/get accepts
# the clean name directly — do NOT resolve to hash-prefixed name, as
# the endpoint may not accept that form.
# SYNO.Docker.Container/get accepts the clean name (no hash prefix).
clean_name = _strip_hash_prefix(container_name)
try:
data = await client.request(
@@ -151,12 +124,6 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
except Exception as e:
return f"Error getting container '{container_name}': {e}"
sys.stderr.write(
f"[get_container_status] name={clean_name!r} "
f"raw response: {json.dumps(data, indent=2, default=str)[:2000]}\n"
)
sys.stderr.flush()
if not data:
return f"Container '{container_name}' not found."
@@ -357,23 +324,17 @@ def _container_in_project(container: dict[str, Any], project_name: str) -> bool:
def _format_container_detail(name: str, data: dict[str, Any]) -> str:
"""Format container inspect data as human-readable text.
DSM may return the container object under a "container" key, or directly
at the top level. Supports both the Docker Engine inspect format
(nested State/Config/HostConfig) and the DSM flat format (lowercase
status/image fields as returned by the list endpoint).
SYNO.Docker.Container/get returns two top-level keys:
- "details": Docker Engine inspect format (State, NetworkSettings, Mounts, …)
- "profile": DSM format (image, port_bindings, …)
"""
# Unwrap "container" wrapper if present (common DSM API pattern)
raw = data["container"] if "container" in data and isinstance(data["container"], dict) else data
details: dict[str, Any] = data.get("details", {}) or {}
profile: dict[str, Any] = data.get("profile", {}) or {}
# Docker Engine inspect format: State / Config / HostConfig
state = raw.get("State", {}) or {}
config = raw.get("Config", {}) or {}
host_config = raw.get("HostConfig", {}) or {}
# DSM flat format fallback (lowercase fields, same as list response)
status_str = state.get("Status") or raw.get("status") or "?"
running = state.get("Running") if "Running" in state else (status_str == "running")
image_str = config.get("Image") or raw.get("image") or "?"
state: dict[str, Any] = details.get("State", {}) or {}
status_str = state.get("Status", "?")
running = state.get("Running", False)
image_str = profile.get("image", "?")
lines = [
f"Container: {name}",
@@ -388,13 +349,35 @@ def _format_container_detail(name: str, data: dict[str, Any]) -> str:
lines.append(f" Finished: {state['FinishedAt']}")
lines.append(f" Exit code: {state.get('ExitCode', '?')}")
memory = host_config.get("Memory", 0)
if memory:
mb = memory // (1024 * 1024)
lines.append(f" Memory limit: {mb} MiB")
# IP addresses from all attached networks
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" IP ({net_name}): {ip}")
env = config.get("Env", []) or []
if env:
lines.append(f" Env vars: {len(env)}")
# Port bindings from DSM profile
port_bindings: list[dict[str, Any]] = profile.get("port_bindings", []) or []
if port_bindings:
lines.append(" Ports:")
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 from Docker Engine inspect
mounts: list[dict[str, Any]] = details.get("Mounts", []) or []
if mounts:
lines.append(" 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}")
return "\n".join(lines)