216 lines
7.1 KiB
Python
216 lines
7.1 KiB
Python
"""MCP tools for SYNO.Docker.Container: list, status, logs, exec."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
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__)
|
|
|
|
|
|
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) -> str:
|
|
"""List containers, optionally filtered by project name.
|
|
|
|
Args:
|
|
project_name: Optional project name to filter containers.
|
|
If omitted, lists all containers.
|
|
"""
|
|
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 = 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) -> str:
|
|
"""Get detailed status, uptime, and resource usage of a container.
|
|
|
|
Args:
|
|
container_name: Name of the container to inspect.
|
|
"""
|
|
try:
|
|
data = await client.request(
|
|
"SYNO.Docker.Container",
|
|
"get",
|
|
params={"name": container_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(container_name, data)
|
|
|
|
@mcp.tool()
|
|
async def get_container_logs(
|
|
container_name: str,
|
|
tail: int = 100,
|
|
keyword: str | None = None,
|
|
) -> str:
|
|
"""Get log output from a container.
|
|
|
|
Args:
|
|
container_name: Name of the container.
|
|
tail: Number of recent log lines to return (default 100).
|
|
keyword: Optional keyword to filter log lines.
|
|
"""
|
|
params: dict[str, Any] = {
|
|
"name": container_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))
|
|
header = f"Logs for {container_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 exec_in_container(
|
|
container_name: str,
|
|
command: str,
|
|
confirmed: bool = False,
|
|
) -> str:
|
|
"""Execute a command in a running container.
|
|
|
|
This executes a shell command inside the container. Use with caution.
|
|
Requires confirmation before executing.
|
|
|
|
Args:
|
|
container_name: Name of the container.
|
|
command: Shell command to execute.
|
|
confirmed: Must be True to proceed. Set to True to confirm execution.
|
|
"""
|
|
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."
|
|
)
|
|
|
|
try:
|
|
data = await client.request(
|
|
"SYNO.Docker.Container",
|
|
"exec",
|
|
params={
|
|
"name": container_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)
|
|
|
|
|
|
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."""
|
|
state = data.get("State", {}) or {}
|
|
config = data.get("Config", {}) or {}
|
|
host_config = data.get("HostConfig", {}) or {}
|
|
|
|
lines = [
|
|
f"Container: {name}",
|
|
f" Status: {state.get('Status', '?')}",
|
|
f" Running: {state.get('Running', False)}",
|
|
f" Image: {config.get('Image', '?')}",
|
|
]
|
|
|
|
if state.get("StartedAt"):
|
|
lines.append(f" Started: {state.get('StartedAt')}")
|
|
if state.get("FinishedAt") and not state.get("Running"):
|
|
lines.append(f" Finished: {state.get('FinishedAt')}")
|
|
lines.append(f" Exit code: {state.get('ExitCode', '?')}")
|
|
|
|
memory = host_config.get("Memory", 0)
|
|
if memory:
|
|
mb = memory // (1024 * 1024)
|
|
lines.append(f" Memory limit: {mb} MiB")
|
|
|
|
env = config.get("Env", []) or []
|
|
if env:
|
|
lines.append(f" Env vars: {len(env)}")
|
|
|
|
return "\n".join(lines)
|