fix: v0.4.2 — inspect_image used wrong DSM parameter contract
Live DSM API capture revealed the actual SYNO.Docker.Image/get contract: a single JSON-encoded parameter named `identity` that accepts both `name:tag` and `sha256:<hash>` forms. The 0.4.0 code passed `name` + `tag` + `id` and was rejected by DSM with error 114. Response shape also corrected — the endpoint returns flat top-level fields (image, tag, id, digest, size, virtual_size, author, docker_version, cmd, entrypoint, env, ports, volumes), NOT the Docker-engine inspect shape with details.Config.* + RootFS.Layers that the previous implementation assumed. Layer rendering removed; digest / author / docker_version / volumes are now displayed. Pre-resolution against list_images is no longer needed — the user input goes straight into `identity` JSON-encoded. Closes #4. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -273,109 +273,51 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
|
||||
@mcp.tool()
|
||||
async def inspect_image(image_id: str):
|
||||
"""Inspect a local image by name:tag or hash — shows config, layers, env, ports."""
|
||||
# Parse name and tag using the last ":" as separator so registry-prefixed
|
||||
# images (e.g. "ghcr.io/foo/bar:v1") are handled correctly.
|
||||
name, sep, tag = image_id.rpartition(":")
|
||||
if not sep:
|
||||
name = image_id
|
||||
tag = "latest"
|
||||
|
||||
# Resolve the image against the local list so the user can pass either
|
||||
# name:tag or a hash (full or 12-char prefix).
|
||||
"""Inspect a local image by name:tag or sha256 hash — shows config, env, ports."""
|
||||
# SYNO.Docker.Image/get version=1 expects the parameter "identity"
|
||||
# (JSON-encoded). It accepts both `name:tag` and `sha256:<hash>` —
|
||||
# no pre-resolution needed. Confirmed via DSM API capture.
|
||||
try:
|
||||
img_data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
"list",
|
||||
params={"limit": "-1", "offset": "0", "show_dsm": "false"},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error inspecting image '{image_id}': {e}"
|
||||
|
||||
images: list[dict[str, Any]] = img_data.get("images", [])
|
||||
is_hash = image_id.startswith("sha256:") or (len(image_id) >= 12 and ":" not in image_id)
|
||||
target: dict[str, Any] | None = None
|
||||
|
||||
for img in images:
|
||||
if is_hash:
|
||||
img_hash = img.get("id", "")
|
||||
if img_hash == image_id or img_hash.startswith(image_id):
|
||||
target = img
|
||||
break
|
||||
else:
|
||||
repo = img.get("repository", "")
|
||||
img_tags = img.get("tags") or []
|
||||
if repo == name and tag in img_tags:
|
||||
target = img
|
||||
break
|
||||
|
||||
if target is None:
|
||||
return f"Image '{image_id}' not found locally."
|
||||
|
||||
repo = target.get("repository", name)
|
||||
img_tags = target.get("tags") or [tag]
|
||||
display_name = f"{repo}:{img_tags[0]}"
|
||||
img_hash = target.get("id", "")
|
||||
|
||||
# Call SYNO.Docker.Image/get — consistent with SYNO.Docker.Container/get
|
||||
# used elsewhere in this codebase. Pass both name+tag and id defensively
|
||||
# so DSM can pick whichever shape it accepts.
|
||||
try:
|
||||
inspect_params: dict[str, Any] = {
|
||||
"name": repo,
|
||||
"tag": img_tags[0] if img_tags else tag,
|
||||
}
|
||||
if img_hash:
|
||||
inspect_params["id"] = img_hash
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
"get",
|
||||
params=inspect_params,
|
||||
version=1,
|
||||
params={"identity": json.dumps(image_id)},
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error inspecting image '{image_id}': {e}"
|
||||
|
||||
# DSM may wrap the inspect blob under "details" (like SYNO.Docker.Container/get)
|
||||
# or return Docker-engine-style fields at top level. Try both.
|
||||
details: dict[str, Any] = data.get("details") if isinstance(data, dict) else None
|
||||
if not isinstance(details, dict):
|
||||
details = data if isinstance(data, dict) else {}
|
||||
if not isinstance(data, dict) or not data:
|
||||
return f"Image '{image_id}' not found."
|
||||
|
||||
# Identity — prefer fields from inspect, fall back to list_images entry
|
||||
inspect_id = details.get("Id") or img_hash or "unknown"
|
||||
size_val = details.get("Size", target.get("size", 0)) or 0
|
||||
size_str = _human_size(size_val)
|
||||
repo = data.get("image") or "?"
|
||||
tag = data.get("tag") or "?"
|
||||
display_name = f"{repo}:{tag}"
|
||||
img_hash = data.get("id") or ""
|
||||
digest = data.get("digest") or ""
|
||||
size_val = data.get("size") or 0
|
||||
virtual_size_val = data.get("virtual_size") or 0
|
||||
author = data.get("author") or ""
|
||||
docker_version = data.get("docker_version") or ""
|
||||
cmd = data.get("cmd") or []
|
||||
entrypoint = data.get("entrypoint") or []
|
||||
env_list: list[str] = data.get("env") or []
|
||||
ports: list[str] = data.get("ports") or []
|
||||
volumes: list[str] = data.get("volumes") or []
|
||||
|
||||
# Created — DSM list_images returns a Unix int; inspect typically returns ISO string
|
||||
created_field: Any = details.get("Created") or target.get("created", 0)
|
||||
if isinstance(created_field, int):
|
||||
created_str = _format_created(created_field)
|
||||
elif isinstance(created_field, str) and created_field:
|
||||
# Trim ISO timestamp to date portion if possible
|
||||
created_str = created_field.split("T")[0]
|
||||
else:
|
||||
created_str = "unknown"
|
||||
|
||||
config: dict[str, Any] = details.get("Config") or {}
|
||||
env_list: list[str] = config.get("Env") or []
|
||||
exposed_ports: dict[str, Any] = config.get("ExposedPorts") or {}
|
||||
entrypoint = config.get("Entrypoint")
|
||||
cmd = config.get("Cmd")
|
||||
working_dir = config.get("WorkingDir") or ""
|
||||
labels: dict[str, Any] = config.get("Labels") or {}
|
||||
|
||||
rootfs: dict[str, Any] = details.get("RootFS") or {}
|
||||
layers: list[Any] = rootfs.get("Layers") or []
|
||||
|
||||
lines = [
|
||||
f"Image: {display_name}",
|
||||
f" Hash: {inspect_id}",
|
||||
f" Size: {size_str}",
|
||||
f" Created: {created_str}",
|
||||
]
|
||||
|
||||
if working_dir:
|
||||
lines.append(f" Working dir: {working_dir}")
|
||||
lines = [f"Image: {display_name}"]
|
||||
if img_hash:
|
||||
lines.append(f" Hash: {img_hash}")
|
||||
if digest:
|
||||
lines.append(f" Digest: {digest}")
|
||||
if size_val:
|
||||
lines.append(f" Size: {_human_size(size_val)}")
|
||||
if virtual_size_val and virtual_size_val != size_val:
|
||||
lines.append(f" Virtual: {_human_size(virtual_size_val)}")
|
||||
if author:
|
||||
lines.append(f" Author: {author}")
|
||||
if docker_version:
|
||||
lines.append(f" Docker: {docker_version}")
|
||||
|
||||
if entrypoint:
|
||||
ep_str = (
|
||||
@@ -383,52 +325,32 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
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}")
|
||||
|
||||
if exposed_ports:
|
||||
if ports:
|
||||
lines.append("")
|
||||
lines.append(f"Exposed ports ({len(exposed_ports)}):")
|
||||
for port in exposed_ports:
|
||||
lines.append(f"Exposed ports ({len(ports)}):")
|
||||
for port in ports:
|
||||
lines.append(f" {port}")
|
||||
|
||||
if volumes:
|
||||
lines.append("")
|
||||
lines.append(f"Volumes ({len(volumes)}):")
|
||||
for vol in volumes:
|
||||
lines.append(f" {vol}")
|
||||
|
||||
if env_list:
|
||||
lines.append("")
|
||||
lines.append(f"Environment ({len(env_list)}):")
|
||||
for var in env_list:
|
||||
lines.append(f" {var}")
|
||||
|
||||
if layers:
|
||||
lines.append("")
|
||||
lines.append(f"Layers ({len(layers)}):")
|
||||
for layer in layers:
|
||||
# Layer may be a string hash or a dict with size info
|
||||
if isinstance(layer, dict):
|
||||
layer_hash = layer.get("digest") or layer.get("Id") or ""
|
||||
layer_size = layer.get("size") or layer.get("Size")
|
||||
short = layer_hash.split(":")[-1][:12] if layer_hash else "?"
|
||||
if isinstance(layer_size, int) and layer_size > 0:
|
||||
lines.append(f" {short} {_human_size(layer_size)}")
|
||||
else:
|
||||
lines.append(f" {short}")
|
||||
else:
|
||||
layer_str = str(layer)
|
||||
short = layer_str.split(":")[-1][:12] if layer_str else "?"
|
||||
lines.append(f" {short}")
|
||||
|
||||
if labels:
|
||||
label_items = list(labels.items())
|
||||
shown = label_items[:5]
|
||||
lines.append("")
|
||||
lines.append(f"Labels ({len(label_items)}):")
|
||||
for key, value in shown:
|
||||
lines.append(f" {key}={value}")
|
||||
if len(label_items) > len(shown):
|
||||
lines.append(f" ... and {len(label_items) - len(shown)} more")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
|
||||
Reference in New Issue
Block a user