fix: v0.4.3 — inspect_image rendering polish (follow-up to #4)
Three live-NAS observations that the 0.4.2 implementation got wrong:
1. Header showed "?:?" — DSM Image/get returns image="" and tag=""
for name:tag lookups; the response fields are unreliable. The
header now echoes the user-supplied image_id, falling back to the
sha256 id only when image_id itself is empty.
2. Ports printed as raw Python dicts ({'port':'22','protocol':'tcp'}).
The ports array is a list of {port, protocol} objects — each entry
now renders as "22/tcp".
3. Environment printed as raw Python dicts ({'key':'PATH','value':...}).
The env array is a list of {key, value} objects — each entry now
renders as "PATH=/usr/local/...".
cmd, entrypoint, and volumes (plain string arrays) were already
correct and are untouched. Tests updated to match the live shape;
two new tests guard the header fallback. References #4 (already
closed by 0.4.2 — no need to reopen for a rendering polish).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,29 @@
|
|||||||
|
|
||||||
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.4.3] - 2026-05-18
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `inspect_image` rendering polish (follow-up to #4 after the 0.4.2
|
||||||
|
parameter-contract fix). Three live-NAS observations that the
|
||||||
|
initial implementation got wrong:
|
||||||
|
- **Header showed `?:?`** — DSM `SYNO.Docker.Image/get` returns
|
||||||
|
`image=""` and `tag=""` when the lookup is by name:tag, so the
|
||||||
|
response fields are unreliable. The header now echoes the user-
|
||||||
|
supplied `image_id` (e.g. `gitea/gitea:1.26.1`), falling back to
|
||||||
|
the sha256 `id` only if `image_id` itself were empty.
|
||||||
|
- **Ports rendered as raw Python dicts** like
|
||||||
|
`{'port': '22', 'protocol': 'tcp'}`. The `ports` array is actually
|
||||||
|
a list of `{"port", "protocol"}` objects; each entry is now
|
||||||
|
formatted as `22/tcp`.
|
||||||
|
- **Environment rendered as raw Python dicts** like
|
||||||
|
`{'key': 'PATH', 'value': '...'}`. The `env` array is actually a
|
||||||
|
list of `{"key", "value"}` objects; each entry is now formatted
|
||||||
|
as `PATH=/usr/local/...`.
|
||||||
|
`cmd`, `entrypoint`, and `volumes` (plain string arrays) were
|
||||||
|
already correct and are unchanged. Referencing #4 (already closed).
|
||||||
|
|
||||||
## [0.4.2] - 2026-05-18
|
## [0.4.2] - 2026-05-18
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
description = "MCP server for Synology Container Manager"
|
description = "MCP server for Synology Container Manager"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -290,10 +290,12 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
|||||||
if not isinstance(data, dict) or not data:
|
if not isinstance(data, dict) or not data:
|
||||||
return f"Image '{image_id}' not found."
|
return f"Image '{image_id}' not found."
|
||||||
|
|
||||||
repo = data.get("image") or "?"
|
|
||||||
tag = data.get("tag") or "?"
|
|
||||||
display_name = f"{repo}:{tag}"
|
|
||||||
img_hash = data.get("id") or ""
|
img_hash = data.get("id") or ""
|
||||||
|
# The DSM Image/get response leaves `image` and `tag` empty when the
|
||||||
|
# lookup is by name:tag (live-confirmed against the NAS), so the
|
||||||
|
# response fields are unreliable for the header. Prefer the user-
|
||||||
|
# supplied image_id, then fall back to the id hash.
|
||||||
|
display_name = image_id or img_hash or "?"
|
||||||
digest = data.get("digest") or ""
|
digest = data.get("digest") or ""
|
||||||
size_val = data.get("size") or 0
|
size_val = data.get("size") or 0
|
||||||
virtual_size_val = data.get("virtual_size") or 0
|
virtual_size_val = data.get("virtual_size") or 0
|
||||||
@@ -301,9 +303,9 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
|||||||
docker_version = data.get("docker_version") or ""
|
docker_version = data.get("docker_version") or ""
|
||||||
cmd = data.get("cmd") or []
|
cmd = data.get("cmd") or []
|
||||||
entrypoint = data.get("entrypoint") or []
|
entrypoint = data.get("entrypoint") or []
|
||||||
env_list: list[str] = data.get("env") or []
|
env_list: list[Any] = data.get("env") or []
|
||||||
ports: list[str] = data.get("ports") or []
|
ports: list[Any] = data.get("ports") or []
|
||||||
volumes: list[str] = data.get("volumes") or []
|
volumes: list[Any] = data.get("volumes") or []
|
||||||
|
|
||||||
lines = [f"Image: {display_name}"]
|
lines = [f"Image: {display_name}"]
|
||||||
if img_hash:
|
if img_hash:
|
||||||
@@ -337,6 +339,12 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"Exposed ports ({len(ports)}):")
|
lines.append(f"Exposed ports ({len(ports)}):")
|
||||||
for port in ports:
|
for port in ports:
|
||||||
|
# Live shape: {"port": "22", "protocol": "tcp"}.
|
||||||
|
if isinstance(port, dict):
|
||||||
|
port_num = port.get("port", "?")
|
||||||
|
proto = port.get("protocol", "tcp")
|
||||||
|
lines.append(f" {port_num}/{proto}")
|
||||||
|
else:
|
||||||
lines.append(f" {port}")
|
lines.append(f" {port}")
|
||||||
|
|
||||||
if volumes:
|
if volumes:
|
||||||
@@ -349,6 +357,12 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"Environment ({len(env_list)}):")
|
lines.append(f"Environment ({len(env_list)}):")
|
||||||
for var in env_list:
|
for var in env_list:
|
||||||
|
# Live shape: {"key": "PATH", "value": "/usr/local/sbin"}.
|
||||||
|
if isinstance(var, dict):
|
||||||
|
key = var.get("key", "?")
|
||||||
|
value = var.get("value", "")
|
||||||
|
lines.append(f" {key}={value}")
|
||||||
|
else:
|
||||||
lines.append(f" {var}")
|
lines.append(f" {var}")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -416,12 +416,17 @@ async def test_delete_image_api_error():
|
|||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
# Authoritative DSM SYNO.Docker.Image/get response shape (v1). Top-level
|
# Authoritative DSM SYNO.Docker.Image/get response shape (v1).
|
||||||
# fields only — there is NO "layers" field on this endpoint, confirmed
|
# Live-captured peculiarities:
|
||||||
# via API capture against the live NAS.
|
# - `image` and `tag` come back as empty strings when the lookup is
|
||||||
|
# by name:tag — the header therefore has to fall back to image_id.
|
||||||
|
# - `ports` is an array of {"port", "protocol"} dicts, NOT strings.
|
||||||
|
# - `env` is an array of {"key", "value"} dicts, NOT strings.
|
||||||
|
# - `volumes` and `cmd`/`entrypoint` are plain string arrays.
|
||||||
|
# - There is no `layers` field on this endpoint.
|
||||||
SAMPLE_INSPECT = {
|
SAMPLE_INSPECT = {
|
||||||
"image": "nginx",
|
"image": "",
|
||||||
"tag": "1.24",
|
"tag": "",
|
||||||
"id": "sha256:aaaa1234567890abcdef",
|
"id": "sha256:aaaa1234567890abcdef",
|
||||||
"digest": "sha256:digestabcdef",
|
"digest": "sha256:digestabcdef",
|
||||||
"size": 50 * 1024 * 1024,
|
"size": 50 * 1024 * 1024,
|
||||||
@@ -430,8 +435,14 @@ SAMPLE_INSPECT = {
|
|||||||
"docker_version": "20.10.0",
|
"docker_version": "20.10.0",
|
||||||
"cmd": ["nginx", "-g", "daemon off;"],
|
"cmd": ["nginx", "-g", "daemon off;"],
|
||||||
"entrypoint": ["/docker-entrypoint.sh"],
|
"entrypoint": ["/docker-entrypoint.sh"],
|
||||||
"env": ["NGINX_VERSION=1.24", "PATH=/usr/local/sbin"],
|
"env": [
|
||||||
"ports": ["80/tcp", "443/tcp"],
|
{"key": "NGINX_VERSION", "value": "1.24"},
|
||||||
|
{"key": "PATH", "value": "/usr/local/sbin"},
|
||||||
|
],
|
||||||
|
"ports": [
|
||||||
|
{"port": "80", "protocol": "tcp"},
|
||||||
|
{"port": "443", "protocol": "tcp"},
|
||||||
|
],
|
||||||
"volumes": ["/var/cache/nginx"],
|
"volumes": ["/var/cache/nginx"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,10 +478,12 @@ async def test_inspect_image_by_name_tag():
|
|||||||
async def test_inspect_image_by_hash():
|
async def test_inspect_image_by_hash():
|
||||||
from mcp_synology_container.modules.images import register_images
|
from mcp_synology_container.modules.images import register_images
|
||||||
|
|
||||||
# Inspect data shaped for redis returned by hash lookup
|
# Inspect data shaped for redis returned by hash lookup.
|
||||||
|
# image/tag come back empty (matches live DSM behavior) — display
|
||||||
|
# falls back to image_id, then to the id hash.
|
||||||
redis_inspect = {
|
redis_inspect = {
|
||||||
"image": "redis",
|
"image": "",
|
||||||
"tag": "7",
|
"tag": "",
|
||||||
"id": "sha256:ccccredis",
|
"id": "sha256:ccccredis",
|
||||||
"digest": "sha256:rdigest",
|
"digest": "sha256:rdigest",
|
||||||
"size": 30 * 1024 * 1024,
|
"size": 30 * 1024 * 1024,
|
||||||
@@ -478,7 +491,7 @@ async def test_inspect_image_by_hash():
|
|||||||
"cmd": ["redis-server"],
|
"cmd": ["redis-server"],
|
||||||
"entrypoint": [],
|
"entrypoint": [],
|
||||||
"env": [],
|
"env": [],
|
||||||
"ports": ["6379/tcp"],
|
"ports": [{"port": "6379", "protocol": "tcp"}],
|
||||||
"volumes": [],
|
"volumes": [],
|
||||||
}
|
}
|
||||||
client = _make_inspect_client(inspect_payload=redis_inspect)
|
client = _make_inspect_client(inspect_payload=redis_inspect)
|
||||||
@@ -486,7 +499,39 @@ async def test_inspect_image_by_hash():
|
|||||||
register_images(mcp, make_config(), client)
|
register_images(mcp, make_config(), client)
|
||||||
|
|
||||||
result = await tools["inspect_image"](image_id="sha256:cccc")
|
result = await tools["inspect_image"](image_id="sha256:cccc")
|
||||||
|
# The header echoes the user-supplied image_id; "redis" surfaces via the cmd line.
|
||||||
|
assert "sha256:cccc" in result
|
||||||
assert "redis" in result
|
assert "redis" in result
|
||||||
|
assert "6379/tcp" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inspect_image_header_uses_image_id_when_fields_empty():
|
||||||
|
"""DSM Image/get returns image='' and tag='' for name:tag lookups — the header
|
||||||
|
must fall back to the user-supplied image_id, NOT render '?:?'."""
|
||||||
|
from mcp_synology_container.modules.images import register_images
|
||||||
|
|
||||||
|
client = _make_inspect_client() # SAMPLE_INSPECT has image='' / tag=''
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_images(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["inspect_image"](image_id="gitea/gitea:1.26.1")
|
||||||
|
assert "Image: gitea/gitea:1.26.1" in result
|
||||||
|
assert "?:?" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inspect_image_header_falls_back_to_id_when_image_id_empty():
|
||||||
|
"""If image_id were empty (defensive — should not happen in practice), the
|
||||||
|
header falls back to the sha256 id field."""
|
||||||
|
from mcp_synology_container.modules.images import register_images
|
||||||
|
|
||||||
|
client = _make_inspect_client() # SAMPLE_INSPECT.id == sha256:aaaa1234567890abcdef
|
||||||
|
mcp, tools = make_mock_mcp()
|
||||||
|
register_images(mcp, make_config(), client)
|
||||||
|
|
||||||
|
result = await tools["inspect_image"](image_id="")
|
||||||
|
assert "Image: sha256:aaaa1234567890abcdef" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -605,8 +650,9 @@ async def test_inspect_image_registry_prefixed():
|
|||||||
from mcp_synology_container.modules.images import register_images
|
from mcp_synology_container.modules.images import register_images
|
||||||
|
|
||||||
registry_inspect = {
|
registry_inspect = {
|
||||||
"image": "ghcr.io/foo/bar",
|
# image / tag come back empty for name:tag lookups (live DSM behavior).
|
||||||
"tag": "v1",
|
"image": "",
|
||||||
|
"tag": "",
|
||||||
"id": "sha256:dddd",
|
"id": "sha256:dddd",
|
||||||
"digest": "sha256:gdigest",
|
"digest": "sha256:gdigest",
|
||||||
"size": 100 * 1024 * 1024,
|
"size": 100 * 1024 * 1024,
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user