Files
unraid-mcp/unraid_mcp/tools/docker.py
Jacob Magar 569956ade0 fix(docker): remove 19 stale actions absent from live API v4.29.2
Only list, details, start, stop, restart, networks, network_details
remain. Removed logs, port_conflicts, check_updates from QUERIES and all
organizer mutations + pause/unpause/remove/update/update_all from
MUTATIONS. DESTRUCTIVE_ACTIONS is now an empty set.
2026-03-15 21:58:50 -04:00

343 lines
13 KiB
Python

"""Docker container management.
Provides the `unraid_docker` tool with 7 actions for container lifecycle
and network inspection.
"""
import re
from typing import Any, Literal, get_args
from fastmcp import FastMCP
from ..config.logging import logger
from ..core.client import make_graphql_request
from ..core.exceptions import ToolError, tool_error_handler
from ..core.utils import safe_get
QUERIES: dict[str, str] = {
"list": """
query ListDockerContainers {
docker { containers(skipCache: false) {
id names image state status autoStart
} }
}
""",
"details": """
query GetContainerDetails {
docker { containers(skipCache: false) {
id names image imageId command created
ports { ip privatePort publicPort type }
sizeRootFs labels state status
hostConfig { networkMode }
networkSettings mounts autoStart
} }
}
""",
"networks": """
query GetDockerNetworks {
docker { networks { id name driver scope } }
}
""",
"network_details": """
query GetDockerNetwork {
docker { networks { id name driver scope enableIPv6 internal attachable containers options labels } }
}
""",
}
MUTATIONS: dict[str, str] = {
"start": """
mutation StartContainer($id: PrefixedID!) {
docker { start(id: $id) { id names state status } }
}
""",
"stop": """
mutation StopContainer($id: PrefixedID!) {
docker { stop(id: $id) { id names state status } }
}
""",
}
DESTRUCTIVE_ACTIONS: set[str] = set()
# NOTE (Code-M-07): "details" is listed here because it requires a container_id
# parameter, but unlike mutations it uses fuzzy name matching (not strict).
# This is intentional: read-only queries are safe with fuzzy matching.
_ACTIONS_REQUIRING_CONTAINER_ID = {"start", "stop", "details"}
ALL_ACTIONS = set(QUERIES) | set(MUTATIONS) | {"restart"}
DOCKER_ACTIONS = Literal[
"list",
"details",
"start",
"stop",
"restart",
"networks",
"network_details",
]
if set(get_args(DOCKER_ACTIONS)) != ALL_ACTIONS:
_missing = ALL_ACTIONS - set(get_args(DOCKER_ACTIONS))
_extra = set(get_args(DOCKER_ACTIONS)) - ALL_ACTIONS
raise RuntimeError(
f"DOCKER_ACTIONS and ALL_ACTIONS are out of sync. "
f"Missing from Literal: {_missing or 'none'}. Extra in Literal: {_extra or 'none'}"
)
# Full PrefixedID: 64 hex chars + optional suffix (e.g., ":local")
_DOCKER_ID_PATTERN = re.compile(r"^[a-f0-9]{64}(:[a-z0-9]+)?$", re.IGNORECASE)
# Short hex prefix: at least 12 hex chars (standard Docker short ID length)
_DOCKER_SHORT_ID_PATTERN = re.compile(r"^[a-f0-9]{12,63}$", re.IGNORECASE)
def find_container_by_identifier(
identifier: str, containers: list[dict[str, Any]], *, strict: bool = False
) -> dict[str, Any] | None:
"""Find a container by ID or name with optional fuzzy matching.
Match priority:
1. Exact ID match
2. Exact name match (case-sensitive)
When strict=False (default), also tries:
3. Name starts with identifier (case-insensitive)
4. Name contains identifier as substring (case-insensitive)
When strict=True, only exact matches (1 & 2) are used.
Use strict=True for mutations to prevent targeting the wrong container.
"""
if not containers:
return None
# Priority 1 & 2: exact matches
for c in containers:
if c.get("id") == identifier:
return c
if identifier in c.get("names", []):
return c
# Strict mode: no fuzzy matching allowed
if strict:
return None
id_lower = identifier.lower()
# Priority 3: prefix match (more precise than substring)
for c in containers:
for name in c.get("names", []):
if name.lower().startswith(id_lower):
logger.debug(f"Prefix match: '{identifier}' -> '{name}'")
return c
# Priority 4: substring match (least precise)
for c in containers:
for name in c.get("names", []):
if id_lower in name.lower():
logger.debug(f"Substring match: '{identifier}' -> '{name}'")
return c
return None
def get_available_container_names(containers: list[dict[str, Any]]) -> list[str]:
"""Extract all container names for error messages."""
names: list[str] = []
for c in containers:
names.extend(c.get("names", []))
return names
async def _resolve_container_id(container_id: str, *, strict: bool = False) -> str:
"""Resolve a container name/identifier to its actual PrefixedID.
Optimization: if the identifier is a full 64-char hex ID (with optional
:suffix), skip the container list fetch entirely and use it directly.
If it's a short hex prefix (12-63 chars), fetch the list and match by
ID prefix. Only fetch the container list for name-based lookups.
Args:
container_id: Container name or ID to resolve
strict: When True, only exact name/ID matches are allowed (no fuzzy).
Use for mutations to prevent targeting the wrong container.
"""
# Full PrefixedID: skip the list fetch entirely
if _DOCKER_ID_PATTERN.match(container_id):
return container_id
logger.info(f"Resolving container identifier '{container_id}' (strict={strict})")
list_query = """
query ResolveContainerID {
docker { containers(skipCache: true) { id names } }
}
"""
data = await make_graphql_request(list_query)
containers = safe_get(data, "docker", "containers", default=[])
# Short hex prefix: match by ID prefix before trying name matching
if _DOCKER_SHORT_ID_PATTERN.match(container_id):
id_lower = container_id.lower()
matches: list[dict[str, Any]] = []
for c in containers:
cid = (c.get("id") or "").lower()
if cid.startswith(id_lower) or cid.split(":")[0].startswith(id_lower):
matches.append(c)
if len(matches) == 1:
actual_id = str(matches[0].get("id", ""))
logger.info(f"Resolved short ID '{container_id}' -> '{actual_id}'")
return actual_id
if len(matches) > 1:
candidate_ids = [str(c.get("id", "")) for c in matches[:5]]
raise ToolError(
f"Short container ID prefix '{container_id}' is ambiguous. "
f"Matches: {', '.join(candidate_ids)}. Use a longer ID or exact name."
)
resolved = find_container_by_identifier(container_id, containers, strict=strict)
if resolved:
actual_id = str(resolved.get("id", ""))
logger.info(f"Resolved '{container_id}' -> '{actual_id}'")
return actual_id
available = get_available_container_names(containers)
if strict:
msg = (
f"Container '{container_id}' not found by exact match. "
f"Mutations require an exact container name or full ID — "
f"fuzzy/substring matching is not allowed for safety."
)
else:
msg = f"Container '{container_id}' not found."
if available:
msg += f" Available: {', '.join(available[:10])}"
raise ToolError(msg)
def register_docker_tool(mcp: FastMCP) -> None:
"""Register the unraid_docker tool with the FastMCP instance."""
@mcp.tool()
async def unraid_docker(
action: DOCKER_ACTIONS,
container_id: str | None = None,
network_id: str | None = None,
*,
confirm: bool = False,
) -> dict[str, Any]:
"""Manage Docker containers and networks.
Actions:
list - List all containers
details - Detailed info for a container (requires container_id)
start - Start a container (requires container_id)
stop - Stop a container (requires container_id)
restart - Stop then start a container (requires container_id)
networks - List Docker networks
network_details - Details of a network (requires network_id)
"""
if action not in ALL_ACTIONS:
raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(ALL_ACTIONS)}")
if action in _ACTIONS_REQUIRING_CONTAINER_ID and not container_id:
raise ToolError(f"container_id is required for '{action}' action")
if action == "network_details" and not network_id:
raise ToolError("network_id is required for 'network_details' action")
with tool_error_handler("docker", action, logger):
logger.info(f"Executing unraid_docker action={action}")
# --- Read-only queries ---
if action == "list":
data = await make_graphql_request(QUERIES["list"])
containers = safe_get(data, "docker", "containers", default=[])
return {"containers": containers}
if action == "details":
# Resolve name -> ID first (skips list fetch if already an ID)
actual_id = await _resolve_container_id(container_id or "")
data = await make_graphql_request(QUERIES["details"])
containers = safe_get(data, "docker", "containers", default=[])
# Match by resolved ID (exact match, no second list fetch needed)
for c in containers:
if c.get("id") == actual_id:
return c
raise ToolError(f"Container '{container_id}' not found in details response.")
if action == "networks":
data = await make_graphql_request(QUERIES["networks"])
networks = safe_get(data, "docker", "networks", default=[])
return {"networks": networks}
if action == "network_details":
data = await make_graphql_request(QUERIES["network_details"])
all_networks = safe_get(data, "docker", "networks", default=[])
# Filter client-side by network_id since the API returns all networks
for net in all_networks:
if net.get("id") == network_id or net.get("name") == network_id:
return dict(net)
raise ToolError(f"Network '{network_id}' not found.")
# --- Mutations (strict matching: no fuzzy/substring) ---
if action == "restart":
actual_id = await _resolve_container_id(container_id or "", strict=True)
# Stop (idempotent: treat "already stopped" as success)
stop_data = await make_graphql_request(
MUTATIONS["stop"],
{"id": actual_id},
operation_context={"operation": "stop"},
)
stop_was_idempotent = stop_data.get("idempotent_success", False)
# Start (idempotent: treat "already running" as success)
start_data = await make_graphql_request(
MUTATIONS["start"],
{"id": actual_id},
operation_context={"operation": "start"},
)
if start_data.get("idempotent_success"):
result = {}
else:
result = safe_get(start_data, "docker", "start", default={})
response: dict[str, Any] = {
"success": True,
"action": "restart",
"container": result,
}
if stop_was_idempotent:
response["note"] = "Container was already stopped before restart"
return response
# Single-container mutations (start, stop)
if action in MUTATIONS:
actual_id = await _resolve_container_id(container_id or "", strict=True)
op_context: dict[str, str] | None = (
{"operation": action} if action in ("start", "stop") else None
)
data = await make_graphql_request(
MUTATIONS[action],
{"id": actual_id},
operation_context=op_context,
)
# Handle idempotent success
if data.get("idempotent_success"):
return {
"success": True,
"action": action,
"idempotent": True,
"message": f"Container already in desired state for '{action}'",
}
docker_data = data.get("docker") or {}
field = action
result_container = docker_data.get(field)
return {
"success": True,
"action": action,
"container": result_container,
}
raise ToolError(f"Unhandled action '{action}' — this is a bug")
logger.info("Docker tool registered successfully")