fix: v0.2.7 — remove -> str annotations and trim docstrings to reduce tools/list payload
FastMCP generates outputSchema for every tool with a return annotation, roughly doubling the tools/list payload size. Multi-line docstrings with Args/Returns sections add further bulk that Claude Desktop must parse. - Strip -> str from all 23 @mcp.tool() functions - Trim every tool docstring to a single descriptive line (≤100 chars) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mcp-synology-container"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
description = "MCP server for Synology Container Manager"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -66,15 +66,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
"""Register all compose file management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def read_compose(project_name: str) -> str:
|
||||
"""Read the compose file of a project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the Container Manager project.
|
||||
|
||||
Returns:
|
||||
The compose file content as YAML text.
|
||||
"""
|
||||
async def read_compose(project_name: str):
|
||||
"""Read the compose file (YAML) for a project."""
|
||||
path = await _find_compose_path(client, config, project_name)
|
||||
if path is None:
|
||||
project = await _find_project(client, project_name)
|
||||
@@ -102,17 +95,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
service_name: str,
|
||||
new_tag: str,
|
||||
confirmed: bool = False,
|
||||
) -> str:
|
||||
"""Update the image tag of a service in the compose file.
|
||||
|
||||
After confirming, suggests running redeploy_project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the Container Manager project.
|
||||
service_name: Name of the service within the compose file.
|
||||
new_tag: New image tag (e.g. "latest", "1.2.3").
|
||||
confirmed: Must be True to proceed. Set to True to confirm the change.
|
||||
"""
|
||||
):
|
||||
"""Update a service's image tag in the compose file. Requires confirmed=True."""
|
||||
path = await _find_compose_path(client, config, project_name)
|
||||
if path is None:
|
||||
return f"No compose file found for project '{project_name}'."
|
||||
@@ -230,18 +214,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
var_name: str,
|
||||
var_value: str,
|
||||
confirmed: bool = False,
|
||||
) -> str:
|
||||
"""Add or update an environment variable in a service's compose definition.
|
||||
|
||||
After confirming, suggests running redeploy_project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the Container Manager project.
|
||||
service_name: Name of the service within the compose file.
|
||||
var_name: Environment variable name.
|
||||
var_value: New value for the variable.
|
||||
confirmed: Must be True to proceed. Set to True to confirm the change.
|
||||
"""
|
||||
):
|
||||
"""Add or update an env var in a service's compose definition. Requires confirmed=True."""
|
||||
path = await _find_compose_path(client, config, project_name)
|
||||
if path is None:
|
||||
return f"No compose file found for project '{project_name}'."
|
||||
@@ -333,17 +307,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
|
||||
project_name: str,
|
||||
new_content: str,
|
||||
confirmed: bool = False,
|
||||
) -> str:
|
||||
"""Replace the entire compose file with new content.
|
||||
|
||||
Validates that the content is valid YAML before writing.
|
||||
After confirming, suggests running redeploy_project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the Container Manager project.
|
||||
new_content: Complete new content for the compose file (must be valid YAML).
|
||||
confirmed: Must be True to proceed. Set to True to confirm the overwrite.
|
||||
"""
|
||||
):
|
||||
"""Replace the entire compose file with new YAML content. Requires confirmed=True."""
|
||||
# Validate YAML before anything else
|
||||
try:
|
||||
parsed = yaml.safe_load(new_content)
|
||||
|
||||
@@ -64,13 +64,8 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
"""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.
|
||||
"""
|
||||
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",
|
||||
@@ -107,12 +102,8 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
@@ -134,14 +125,8 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
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.
|
||||
"""
|
||||
):
|
||||
"""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,
|
||||
@@ -181,15 +166,8 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
return header + "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def container_stats(container_name: str) -> str:
|
||||
"""Get live resource usage statistics for a container.
|
||||
|
||||
Reports CPU %, RAM used/limit, Network I/O (rx/tx), and Block I/O
|
||||
(read/write) for the named container.
|
||||
|
||||
Args:
|
||||
container_name: Name of the container (e.g. "jenkins").
|
||||
"""
|
||||
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:
|
||||
@@ -270,17 +248,8 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
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.
|
||||
"""
|
||||
):
|
||||
"""Execute a shell command inside a running container. Requires confirmed=True."""
|
||||
if not confirmed:
|
||||
return (
|
||||
f"About to run in container '{container_name}':\n"
|
||||
@@ -313,16 +282,8 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
|
||||
return "\n".join(result_lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_container(container_name: str, confirmed: bool = False) -> str:
|
||||
"""Delete a container.
|
||||
|
||||
Container must be stopped before deletion. Without confirmed=True,
|
||||
returns a preview only.
|
||||
|
||||
Args:
|
||||
container_name: Name of the container.
|
||||
confirmed: Must be True to proceed. Set to True to confirm deletion.
|
||||
"""
|
||||
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"
|
||||
|
||||
@@ -102,12 +102,8 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
"""Register all image management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def list_images() -> str:
|
||||
"""List all local Docker images sorted by size (largest first).
|
||||
|
||||
Shows name:tag, size, creation date, and whether the image is
|
||||
currently used by at least one container.
|
||||
"""
|
||||
async def list_images():
|
||||
"""List local Docker images sorted by size, showing tag, date, and in-use status."""
|
||||
try:
|
||||
img_data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
@@ -156,18 +152,8 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_image(image_id: str, confirmed: bool = False) -> str:
|
||||
"""Delete a local Docker image by name:tag or image ID hash.
|
||||
|
||||
Without confirmed=True, returns a preview of what would be deleted.
|
||||
Refuses deletion if the image is used by any container.
|
||||
|
||||
Args:
|
||||
image_id: Image reference as "name:tag" (e.g. "nginx:1.24")
|
||||
or a full/short image hash (e.g. "sha256:14300de7…").
|
||||
confirmed: Must be True to actually delete. Default False shows
|
||||
a preview only.
|
||||
"""
|
||||
async def delete_image(image_id: str, confirmed: bool = False):
|
||||
"""Delete a local image by name:tag or hash. Requires confirmed=True; refuses if in use."""
|
||||
# Parse name and tag using the last ":" as separator so that
|
||||
# registry-prefixed images (e.g. "ghcr.io/foo/bar:v1") are handled
|
||||
# correctly. rpartition returns ("", "", original) when ":" is absent.
|
||||
@@ -284,16 +270,8 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
return f"Deleted {display_name} — {size_str} freed."
|
||||
|
||||
@mcp.tool()
|
||||
async def check_image_updates(project_name: str | None = None) -> str:
|
||||
"""Check for available image updates for a project or all images.
|
||||
|
||||
Queries the local image list and reports which images have the
|
||||
'upgradable' flag set by the NAS registry check.
|
||||
|
||||
Args:
|
||||
project_name: Optional project name to filter images.
|
||||
If omitted, checks all locally available images.
|
||||
"""
|
||||
async def check_image_updates(project_name: str | None = None):
|
||||
"""Check which local images have updates available (upgradable flag from NAS registry)."""
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.Docker.Image",
|
||||
|
||||
@@ -19,7 +19,7 @@ def register_networks(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
"""Register all network management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def list_networks() -> str:
|
||||
async def list_networks():
|
||||
"""List all Docker networks with driver, subnet, gateway, and attached containers."""
|
||||
try:
|
||||
data = await client.request("SYNO.Docker.Network", "list")
|
||||
@@ -60,18 +60,8 @@ def register_networks(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
ip_range: str | None = None,
|
||||
enable_ipv6: bool = False,
|
||||
confirmed: bool = False,
|
||||
) -> str:
|
||||
"""Create a new Docker network.
|
||||
|
||||
Args:
|
||||
name: Name for the new network.
|
||||
driver: Network driver. Defaults to "bridge".
|
||||
subnet: Subnet in CIDR notation (e.g. "172.28.0.0/16").
|
||||
gateway: Gateway IP address.
|
||||
ip_range: Allocatable IP range in CIDR notation.
|
||||
enable_ipv6: Enable IPv6 support. Defaults to False.
|
||||
confirmed: Must be True to actually create. Default False shows a preview.
|
||||
"""
|
||||
):
|
||||
"""Create a Docker network with optional subnet/gateway/IPv6. Requires confirmed=True."""
|
||||
details = [f" Name: {name}", f" Driver: {driver}"]
|
||||
if subnet:
|
||||
details.append(f" Subnet: {subnet}")
|
||||
@@ -113,15 +103,8 @@ def register_networks(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
return f"Network '{name}' created{id_str}."
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_network(name: str, confirmed: bool = False) -> str:
|
||||
"""Delete a Docker network by name.
|
||||
|
||||
Refuses deletion if any container is still attached to the network.
|
||||
|
||||
Args:
|
||||
name: Name of the network to delete.
|
||||
confirmed: Must be True to actually delete. Default False shows a preview.
|
||||
"""
|
||||
async def delete_network(name: str, confirmed: bool = False):
|
||||
"""Delete a Docker network. Requires confirmed=True; refuses if containers are attached."""
|
||||
# Fetch network list to validate existence and check for attached containers
|
||||
try:
|
||||
data = await client.request("SYNO.Docker.Network", "list")
|
||||
|
||||
@@ -24,12 +24,8 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
"""Register all project management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def list_projects() -> str:
|
||||
"""List all Container Manager projects with their current status.
|
||||
|
||||
Returns a formatted table of projects including name, status, path,
|
||||
and container count.
|
||||
"""
|
||||
async def list_projects():
|
||||
"""List all Container Manager projects with name, status, path, and container count."""
|
||||
try:
|
||||
data = await client.request("SYNO.Docker.Project", "list")
|
||||
except Exception as e:
|
||||
@@ -54,12 +50,8 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
@mcp.tool()
|
||||
async def get_project_status(project_name: str) -> str:
|
||||
"""Get detailed status of a specific project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project to inspect.
|
||||
"""
|
||||
async def get_project_status(project_name: str):
|
||||
"""Get detailed status and container list for a specific project."""
|
||||
project = await _find_project(client, project_name)
|
||||
if project is None:
|
||||
return f"Project '{project_name}' not found."
|
||||
@@ -67,12 +59,8 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
return _format_project_detail(project)
|
||||
|
||||
@mcp.tool()
|
||||
async def start_project(project_name: str) -> str:
|
||||
"""Start a Container Manager project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project to start.
|
||||
"""
|
||||
async def start_project(project_name: str):
|
||||
"""Start a Container Manager project."""
|
||||
project = await _find_project(client, project_name)
|
||||
if project is None:
|
||||
return f"Project '{project_name}' not found."
|
||||
@@ -89,16 +77,8 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
return f"Error starting project '{project_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def stop_project(project_name: str, confirmed: bool = False) -> str:
|
||||
"""Stop a running Container Manager project.
|
||||
|
||||
This operation stops all containers in the project.
|
||||
Requires confirmation before executing.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project to stop.
|
||||
confirmed: Must be True to proceed. Set to True to confirm the stop operation.
|
||||
"""
|
||||
async def stop_project(project_name: str, confirmed: bool = False):
|
||||
"""Stop all containers in a project. Requires confirmed=True."""
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Stopping project '{project_name}' will halt all its containers.\n"
|
||||
@@ -121,26 +101,8 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
||||
return f"Error stopping project '{project_name}': {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def redeploy_project(project_name: str, confirmed: bool = False) -> str:
|
||||
"""Redeploy a project, pulling the latest images via SYNO.Docker.Project/build_stream.
|
||||
|
||||
This is the programmatic equivalent of the DSM "Erstellen" (Build) button.
|
||||
Unified 3-step flow for all project states:
|
||||
|
||||
Step 1 — Stop (skipped for STOPPED; error-suppressed for BUILD_FAILED)
|
||||
Step 2 — Trigger build_stream: DSM pulls updated images and starts the
|
||||
project. This is a Server-Sent Events endpoint; the call
|
||||
returns once DSM confirms it accepted the request.
|
||||
Step 3 — Poll SYNO.Docker.Project/list every 2 s for up to 5 min
|
||||
until the project reaches RUNNING. A warning is emitted on
|
||||
timeout instead of returning an error.
|
||||
|
||||
Requires confirmation before executing.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project to redeploy.
|
||||
confirmed: Must be True to proceed. Set to True to confirm the redeploy.
|
||||
"""
|
||||
async def redeploy_project(project_name: str, confirmed: bool = False):
|
||||
"""Pull latest images and restart a project via build_stream. Requires confirmed=True."""
|
||||
if not confirmed:
|
||||
return (
|
||||
f"Redeploying project '{project_name}' will stop and restart all its "
|
||||
|
||||
@@ -20,13 +20,8 @@ def register_system(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
"""Register all system-level tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def system_df() -> str:
|
||||
"""Show Docker disk usage: images, containers, and volumes.
|
||||
|
||||
Assembles disk-usage data from the image and container lists.
|
||||
Images that are not referenced by any container are marked as
|
||||
reclaimable.
|
||||
"""
|
||||
async def system_df():
|
||||
"""Show Docker disk usage: image count/size and container running/stopped counts."""
|
||||
errors: list[str] = []
|
||||
|
||||
# ── Images ───────────────────────────────────────────────────────────
|
||||
@@ -94,16 +89,8 @@ def register_system(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def system_prune(confirmed: bool = False) -> str:
|
||||
"""Remove unused Docker resources: dangling images, stopped containers, unused networks.
|
||||
|
||||
Without confirmed=True, shows a preview of what would be removed.
|
||||
With confirmed=True, runs the prune and reports reclaimed space.
|
||||
|
||||
Args:
|
||||
confirmed: Must be True to actually prune. Default False shows
|
||||
a preview only.
|
||||
"""
|
||||
async def system_prune(confirmed: bool = False):
|
||||
"""Remove unused Docker resources (images, stopped containers). Requires confirmed=True."""
|
||||
# ── Gather preview data ───────────────────────────────────────────────
|
||||
dangling_images: list[dict[str, Any]] = []
|
||||
stopped_containers: list[dict[str, Any]] = []
|
||||
|
||||
Reference in New Issue
Block a user