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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user