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:
2026-04-21 09:04:17 +02:00
parent a1d4b1d709
commit ad199674e7
7 changed files with 46 additions and 210 deletions
+1 -1
View File
@@ -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 = [
+8 -43
View File
@@ -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"
+6 -28
View File
@@ -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",
+5 -22
View File
@@ -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")
+10 -48
View File
@@ -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 "
+4 -17
View File
@@ -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]] = []