8adcf93b6a
Bug 1 — delete_container (DSM error 114):
SYNO.Docker.Container/delete requires three parameters: name
(JSON-encoded), force=false, and preserve_profile=false. Previously
only a bare `name` string was sent, causing DSM to reject the call
with error 114. Added the two missing fields and JSON-encode name to
match the DSM convention. The connector-side running-container guard
is unchanged; force stays hard-coded to false.
Bug 2 — delete_project orphan containers:
Production test revealed that DSM does NOT reject Project/delete on a
running project — it silently removes the registration and leaves the
containers running without any project context. The previous
implementation tried to handle this via a caught SynologyError that
never actually fires. Fix: check the project status from _find_project
connector-side before issuing any DSM call; if RUNNING, return an
error pointing at stop_project. The delete request is never sent for
a running project.
The corresponding unit test (test_delete_project_running_returns_stop_hint)
was a false positive — it mocked a DSM rejection that real DSM never
produces. Replaced with test_delete_project_running_blocked_connector_side
which asserts that client.request("delete") is never called when the
project is RUNNING.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
400 lines
15 KiB
Python
400 lines
15 KiB
Python
"""MCP tools for SYNO.Docker.Container: list, status, logs, exec."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
from mcp_synology_container.config import AppConfig
|
|
from mcp_synology_container.dsm_client import DsmClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Matches DSM hash-prefixed container names like "f93cb8b504f7_jenkins"
|
|
_HASH_PREFIX_RE = re.compile(r"^[a-f0-9]{12}_(.+)$")
|
|
|
|
|
|
def _strip_hash_prefix(name: str) -> str:
|
|
"""Strip 12-char hex hash prefix from container names.
|
|
|
|
DSM sometimes returns names like 'f93cb8b504f7_jenkins' when the
|
|
service name in compose.yaml differs from container_name. Returns the
|
|
clean name (also strips a leading slash if present).
|
|
"""
|
|
clean = name.lstrip("/")
|
|
match = _HASH_PREFIX_RE.match(clean)
|
|
return match.group(1) if match else clean
|
|
|
|
|
|
async def _resolve_container_name(client: DsmClient, user_name: str) -> str:
|
|
"""Resolve a user-supplied name to the actual DSM container name.
|
|
|
|
Needed because DSM may store the container as 'f93cb8b504f7_jenkins'
|
|
while the user passes 'jenkins'. Falls back to user_name unchanged if
|
|
the list cannot be fetched or no match is found.
|
|
|
|
Args:
|
|
client: DsmClient instance.
|
|
user_name: Name as provided by the user (may or may not have prefix).
|
|
|
|
Returns:
|
|
Actual container name as registered in DSM.
|
|
"""
|
|
clean_user = _strip_hash_prefix(user_name)
|
|
try:
|
|
data = await client.request(
|
|
"SYNO.Docker.Container",
|
|
"list",
|
|
params={"limit": "-1", "offset": "0", "type": "all"},
|
|
)
|
|
for c in data.get("containers", []):
|
|
actual = c.get("name", "")
|
|
if actual == user_name or _strip_hash_prefix(actual) == clean_user:
|
|
return actual
|
|
except Exception:
|
|
pass
|
|
return user_name
|
|
|
|
|
|
def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
|
"""Register all container management tools with the MCP server."""
|
|
|
|
@mcp.tool()
|
|
async def list_containers(project_name: str | None = None):
|
|
"""List all containers, optionally filtered by project name."""
|
|
try:
|
|
data = await client.request(
|
|
"SYNO.Docker.Container",
|
|
"list",
|
|
params={"limit": "-1", "offset": "0", "type": "all"},
|
|
)
|
|
except Exception as e:
|
|
return f"Error listing containers: {e}"
|
|
|
|
containers: list[dict[str, Any]] = data.get("containers", [])
|
|
if not containers:
|
|
return "No containers found."
|
|
|
|
# Filter by project if specified
|
|
if project_name:
|
|
containers = [
|
|
c
|
|
for c in containers
|
|
if c.get("project_name") == project_name or _container_in_project(c, project_name)
|
|
]
|
|
if not containers:
|
|
return f"No containers found for project '{project_name}'."
|
|
|
|
lines = [f"Containers ({len(containers)} total):", ""]
|
|
for container in sorted(containers, key=lambda c: c.get("name", "")):
|
|
name = _strip_hash_prefix(container.get("name", "?"))
|
|
state = container.get("status", container.get("state", "?"))
|
|
image = container.get("image", "?")
|
|
lines.append(f" {name}")
|
|
lines.append(f" Status: {state}")
|
|
lines.append(f" Image: {image}")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines).rstrip()
|
|
|
|
@mcp.tool()
|
|
async def get_container_status(container_name: str):
|
|
"""Get detailed status, uptime, ports, and mounts for a container."""
|
|
# SYNO.Docker.Container/get accepts the clean name (no hash prefix).
|
|
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 getting container '{container_name}': {e}"
|
|
|
|
if not data:
|
|
return f"Container '{container_name}' not found."
|
|
|
|
return _format_container_detail(clean_name, data)
|
|
|
|
@mcp.tool()
|
|
async def get_container_logs(
|
|
container_name: str,
|
|
tail: int = 100,
|
|
keyword: str | None = None,
|
|
):
|
|
"""Get recent log output from a container, with optional keyword filter."""
|
|
resolved_name = await _resolve_container_name(client, container_name)
|
|
params: dict[str, Any] = {
|
|
"name": resolved_name,
|
|
"limit": tail,
|
|
"offset": 0,
|
|
"sort_dir": "DESC",
|
|
}
|
|
if keyword:
|
|
params["keyword"] = keyword
|
|
|
|
try:
|
|
data = await client.request(
|
|
"SYNO.Docker.Container.Log",
|
|
"get",
|
|
params=params,
|
|
)
|
|
except Exception as e:
|
|
return f"Error getting logs for '{container_name}': {e}"
|
|
|
|
logs: list[dict[str, Any]] = data.get("logs", [])
|
|
if not logs:
|
|
return f"No logs found for container '{container_name}'."
|
|
|
|
total = data.get("total", len(logs))
|
|
display_name = _strip_hash_prefix(container_name)
|
|
header = f"Logs for {display_name} (showing {len(logs)} of {total}):\n"
|
|
|
|
# Logs are returned in DESC order, reverse for chronological display
|
|
lines = []
|
|
for entry in reversed(logs):
|
|
timestamp = entry.get("created", "")
|
|
stream = entry.get("stream", "")
|
|
text = entry.get("text", "")
|
|
stream_tag = f"[{stream}] " if stream else ""
|
|
lines.append(f"{timestamp} {stream_tag}{text}")
|
|
|
|
return header + "\n".join(lines)
|
|
|
|
@mcp.tool()
|
|
async def container_stats(container_name: str):
|
|
"""Get live CPU, memory, network, and block I/O stats for a container."""
|
|
try:
|
|
data = await client.request("SYNO.Docker.Container", "stats")
|
|
except Exception as e:
|
|
return f"Error fetching container stats: {e}"
|
|
|
|
if not data:
|
|
return "No stats data returned."
|
|
|
|
# Response is a dict keyed by container ID hash; each entry has "name"
|
|
# with a leading slash (e.g. "/jenkins") and may have a hash prefix.
|
|
clean_query = _strip_hash_prefix(container_name)
|
|
target: dict[str, Any] | None = None
|
|
for entry in data.values():
|
|
entry_name = _strip_hash_prefix(entry.get("name", ""))
|
|
if entry_name == clean_query:
|
|
target = entry
|
|
break
|
|
|
|
if target is None:
|
|
available = ", ".join(_strip_hash_prefix(v.get("name", "?")) for v in data.values())
|
|
return f"Container '{container_name}' not found in stats. Available: {available}"
|
|
|
|
# ── CPU % ────────────────────────────────────────────────────────────
|
|
cpu_stats = target.get("cpu_stats", {})
|
|
precpu_stats = target.get("precpu_stats", {})
|
|
cpu_usage = cpu_stats.get("cpu_usage", {})
|
|
precpu_usage = precpu_stats.get("cpu_usage", {})
|
|
|
|
cpu_delta = cpu_usage.get("total_usage", 0) - precpu_usage.get("total_usage", 0)
|
|
system_delta = cpu_stats.get("system_cpu_usage", 0) - precpu_stats.get(
|
|
"system_cpu_usage", 0
|
|
)
|
|
online_cpus = cpu_stats.get("online_cpus") or len(cpu_usage.get("percpu_usage") or [1])
|
|
|
|
if system_delta > 0 and cpu_delta >= 0:
|
|
cpu_pct = (cpu_delta / system_delta) * online_cpus * 100.0
|
|
else:
|
|
cpu_pct = 0.0
|
|
|
|
# ── Memory ───────────────────────────────────────────────────────────
|
|
mem_stats = target.get("memory_stats", {})
|
|
mem_usage = mem_stats.get("usage", 0)
|
|
mem_limit = mem_stats.get("limit", 0)
|
|
|
|
# ── Network I/O ──────────────────────────────────────────────────────
|
|
net_rx = 0
|
|
net_tx = 0
|
|
for iface in (target.get("networks") or {}).values():
|
|
net_rx += iface.get("rx_bytes", 0)
|
|
net_tx += iface.get("tx_bytes", 0)
|
|
|
|
# ── Block I/O ────────────────────────────────────────────────────────
|
|
blk_read = 0
|
|
blk_write = 0
|
|
for entry_io in target.get("blkio_stats", {}).get("io_service_bytes_recursive") or []:
|
|
op = entry_io.get("op", "").lower()
|
|
val = entry_io.get("value", 0)
|
|
if op == "read":
|
|
blk_read += val
|
|
elif op == "write":
|
|
blk_write += val
|
|
|
|
# ── Format ───────────────────────────────────────────────────────────
|
|
from mcp_synology_container.modules.images import _human_size # reuse helper
|
|
|
|
mem_limit_str = f" / {_human_size(mem_limit)}" if mem_limit else ""
|
|
lines = [
|
|
f"Stats for {container_name}:",
|
|
f" CPU: {cpu_pct:.2f}% ({online_cpus} CPUs)",
|
|
f" Memory: {_human_size(mem_usage)}{mem_limit_str}",
|
|
f" Net I/O: rx {_human_size(net_rx)} / tx {_human_size(net_tx)}",
|
|
f" Block I/O: read {_human_size(blk_read)} / write {_human_size(blk_write)}",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
@mcp.tool()
|
|
async def exec_in_container(
|
|
container_name: str,
|
|
command: str,
|
|
confirmed: bool = False,
|
|
):
|
|
"""Execute a shell command inside a running container. Requires confirmed=True."""
|
|
if not confirmed:
|
|
return (
|
|
f"About to run in container '{container_name}':\n"
|
|
f" $ {command}\n\n"
|
|
f"Call this tool again with confirmed=True to proceed."
|
|
)
|
|
|
|
resolved_name = await _resolve_container_name(client, container_name)
|
|
try:
|
|
data = await client.request(
|
|
"SYNO.Docker.Container",
|
|
"exec",
|
|
params={
|
|
"name": resolved_name,
|
|
"command": command,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
return f"Error executing command in '{container_name}': {e}"
|
|
|
|
output = data.get("output", "")
|
|
exit_code = data.get("exit_code", 0)
|
|
|
|
result_lines = [f"Command executed in '{container_name}':"]
|
|
result_lines.append(f" Exit code: {exit_code}")
|
|
if output:
|
|
result_lines.append(" Output:")
|
|
result_lines.append(output)
|
|
|
|
return "\n".join(result_lines)
|
|
|
|
@mcp.tool()
|
|
async def delete_container(container_name: str, confirmed: bool = False):
|
|
"""Delete a stopped container. Requires confirmed=True."""
|
|
if not confirmed:
|
|
return (
|
|
f"Preview: would delete container '{container_name}'.\n"
|
|
f"Call this tool again with confirmed=True to proceed."
|
|
)
|
|
|
|
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": resolved_name},
|
|
)
|
|
except Exception as e:
|
|
return f"Error inspecting container '{container_name}': {e}"
|
|
|
|
if not data:
|
|
return f"Container '{container_name}' not found."
|
|
|
|
details = data.get("details", {}) or {}
|
|
state = details.get("State", {}) or {}
|
|
running = state.get("Running", False)
|
|
|
|
if running:
|
|
return (
|
|
f"Cannot delete '{display_name}': container is still running.\n"
|
|
f"Stop the container first with stop_project or stop_container."
|
|
)
|
|
|
|
try:
|
|
await client.request(
|
|
"SYNO.Docker.Container",
|
|
"delete",
|
|
params={
|
|
"name": json.dumps(resolved_name),
|
|
"force": "false",
|
|
"preserve_profile": "false",
|
|
},
|
|
)
|
|
return f"Deleted container '{display_name}'."
|
|
except Exception as e:
|
|
return f"Error deleting '{display_name}': {e}"
|
|
|
|
|
|
def _container_in_project(container: dict[str, Any], project_name: str) -> bool:
|
|
"""Check if a container belongs to a project based on its labels."""
|
|
labels = container.get("labels", {}) or {}
|
|
if isinstance(labels, dict):
|
|
return labels.get("com.docker.compose.project") == project_name
|
|
return False
|
|
|
|
|
|
def _format_container_detail(name: str, data: dict[str, Any]) -> str:
|
|
"""Format container inspect data as human-readable text.
|
|
|
|
SYNO.Docker.Container/get returns two top-level keys:
|
|
- "details": Docker Engine inspect format (State, NetworkSettings, Mounts, …)
|
|
- "profile": DSM format (image, port_bindings, …)
|
|
"""
|
|
details: dict[str, Any] = data.get("details", {}) or {}
|
|
profile: dict[str, Any] = data.get("profile", {}) 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}",
|
|
f" Status: {status_str}",
|
|
f" Running: {running}",
|
|
f" Image: {image_str}",
|
|
]
|
|
|
|
if state.get("StartedAt"):
|
|
lines.append(f" Started: {state['StartedAt']}")
|
|
if state.get("FinishedAt") and not running:
|
|
lines.append(f" Finished: {state['FinishedAt']}")
|
|
lines.append(f" Exit code: {state.get('ExitCode', '?')}")
|
|
|
|
# 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}")
|
|
|
|
# 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)
|