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] [project]
name = "mcp-synology-container" name = "mcp-synology-container"
version = "0.2.6" version = "0.2.7"
description = "MCP server for Synology Container Manager" description = "MCP server for Synology Container Manager"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ 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.""" """Register all compose file management tools with the MCP server."""
@mcp.tool() @mcp.tool()
async def read_compose(project_name: str) -> str: async def read_compose(project_name: str):
"""Read the compose file of a project. """Read the compose file (YAML) for a project."""
Args:
project_name: Name of the Container Manager project.
Returns:
The compose file content as YAML text.
"""
path = await _find_compose_path(client, config, project_name) path = await _find_compose_path(client, config, project_name)
if path is None: if path is None:
project = await _find_project(client, project_name) project = await _find_project(client, project_name)
@@ -102,17 +95,8 @@ def register_compose(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None
service_name: str, service_name: str,
new_tag: str, new_tag: str,
confirmed: bool = False, confirmed: bool = False,
) -> str: ):
"""Update the image tag of a service in the compose file. """Update a service's image tag in the compose file. Requires confirmed=True."""
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.
"""
path = await _find_compose_path(client, config, project_name) path = await _find_compose_path(client, config, project_name)
if path is None: if path is None:
return f"No compose file found for project '{project_name}'." 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_name: str,
var_value: str, var_value: str,
confirmed: bool = False, confirmed: bool = False,
) -> str: ):
"""Add or update an environment variable in a service's compose definition. """Add or update an env var in a service's compose definition. Requires confirmed=True."""
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.
"""
path = await _find_compose_path(client, config, project_name) path = await _find_compose_path(client, config, project_name)
if path is None: if path is None:
return f"No compose file found for project '{project_name}'." 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, project_name: str,
new_content: str, new_content: str,
confirmed: bool = False, confirmed: bool = False,
) -> str: ):
"""Replace the entire compose file with new content. """Replace the entire compose file with new YAML content. Requires confirmed=True."""
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.
"""
# Validate YAML before anything else # Validate YAML before anything else
try: try:
parsed = yaml.safe_load(new_content) 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.""" """Register all container management tools with the MCP server."""
@mcp.tool() @mcp.tool()
async def list_containers(project_name: str | None = None) -> str: async def list_containers(project_name: str | None = None):
"""List containers, optionally filtered by project name. """List all containers, optionally filtered by project name."""
Args:
project_name: Optional project name to filter containers.
If omitted, lists all containers.
"""
try: try:
data = await client.request( data = await client.request(
"SYNO.Docker.Container", "SYNO.Docker.Container",
@@ -107,12 +102,8 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
return "\n".join(lines).rstrip() return "\n".join(lines).rstrip()
@mcp.tool() @mcp.tool()
async def get_container_status(container_name: str) -> str: async def get_container_status(container_name: str):
"""Get detailed status, uptime, and resource usage of a container. """Get detailed status, uptime, ports, and mounts for a container."""
Args:
container_name: Name of the container to inspect.
"""
# SYNO.Docker.Container/get accepts the clean name (no hash prefix). # SYNO.Docker.Container/get accepts the clean name (no hash prefix).
clean_name = _strip_hash_prefix(container_name) clean_name = _strip_hash_prefix(container_name)
try: try:
@@ -134,14 +125,8 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
container_name: str, container_name: str,
tail: int = 100, tail: int = 100,
keyword: str | None = None, keyword: str | None = None,
) -> str: ):
"""Get log output from a container. """Get recent log output from a container, with optional keyword filter."""
Args:
container_name: Name of the container.
tail: Number of recent log lines to return (default 100).
keyword: Optional keyword to filter log lines.
"""
resolved_name = await _resolve_container_name(client, container_name) resolved_name = await _resolve_container_name(client, container_name)
params: dict[str, Any] = { params: dict[str, Any] = {
"name": resolved_name, "name": resolved_name,
@@ -181,15 +166,8 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
return header + "\n".join(lines) return header + "\n".join(lines)
@mcp.tool() @mcp.tool()
async def container_stats(container_name: str) -> str: async def container_stats(container_name: str):
"""Get live resource usage statistics for a container. """Get live CPU, memory, network, and block I/O stats 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").
"""
try: try:
data = await client.request("SYNO.Docker.Container", "stats") data = await client.request("SYNO.Docker.Container", "stats")
except Exception as e: except Exception as e:
@@ -270,17 +248,8 @@ def register_containers(mcp: FastMCP, config: AppConfig, client: DsmClient) -> N
container_name: str, container_name: str,
command: str, command: str,
confirmed: bool = False, confirmed: bool = False,
) -> str: ):
"""Execute a command in a running container. """Execute a shell command inside a running container. Requires confirmed=True."""
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: if not confirmed:
return ( return (
f"About to run in container '{container_name}':\n" 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) return "\n".join(result_lines)
@mcp.tool() @mcp.tool()
async def delete_container(container_name: str, confirmed: bool = False) -> str: async def delete_container(container_name: str, confirmed: bool = False):
"""Delete a container. """Delete a stopped container. Requires confirmed=True."""
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.
"""
if not confirmed: if not confirmed:
return ( return (
f"Preview: would delete container '{container_name}'.\n" 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.""" """Register all image management tools with the MCP server."""
@mcp.tool() @mcp.tool()
async def list_images() -> str: async def list_images():
"""List all local Docker images sorted by size (largest first). """List local Docker images sorted by size, showing tag, date, and in-use status."""
Shows name:tag, size, creation date, and whether the image is
currently used by at least one container.
"""
try: try:
img_data = await client.request( img_data = await client.request(
"SYNO.Docker.Image", "SYNO.Docker.Image",
@@ -156,18 +152,8 @@ def register_images(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
return "\n".join(lines) return "\n".join(lines)
@mcp.tool() @mcp.tool()
async def delete_image(image_id: str, confirmed: bool = False) -> str: async def delete_image(image_id: str, confirmed: bool = False):
"""Delete a local Docker image by name:tag or image ID hash. """Delete a local image by name:tag or hash. Requires confirmed=True; refuses if in use."""
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.
"""
# Parse name and tag using the last ":" as separator so that # Parse name and tag using the last ":" as separator so that
# registry-prefixed images (e.g. "ghcr.io/foo/bar:v1") are handled # registry-prefixed images (e.g. "ghcr.io/foo/bar:v1") are handled
# correctly. rpartition returns ("", "", original) when ":" is absent. # 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." return f"Deleted {display_name}{size_str} freed."
@mcp.tool() @mcp.tool()
async def check_image_updates(project_name: str | None = None) -> str: async def check_image_updates(project_name: str | None = None):
"""Check for available image updates for a project or all images. """Check which local images have updates available (upgradable flag from NAS registry)."""
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.
"""
try: try:
data = await client.request( data = await client.request(
"SYNO.Docker.Image", "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.""" """Register all network management tools with the MCP server."""
@mcp.tool() @mcp.tool()
async def list_networks() -> str: async def list_networks():
"""List all Docker networks with driver, subnet, gateway, and attached containers.""" """List all Docker networks with driver, subnet, gateway, and attached containers."""
try: try:
data = await client.request("SYNO.Docker.Network", "list") 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, ip_range: str | None = None,
enable_ipv6: bool = False, enable_ipv6: bool = False,
confirmed: bool = False, confirmed: bool = False,
) -> str: ):
"""Create a new Docker network. """Create a Docker network with optional subnet/gateway/IPv6. Requires confirmed=True."""
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.
"""
details = [f" Name: {name}", f" Driver: {driver}"] details = [f" Name: {name}", f" Driver: {driver}"]
if subnet: if subnet:
details.append(f" Subnet: {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}." return f"Network '{name}' created{id_str}."
@mcp.tool() @mcp.tool()
async def delete_network(name: str, confirmed: bool = False) -> str: async def delete_network(name: str, confirmed: bool = False):
"""Delete a Docker network by name. """Delete a Docker network. Requires confirmed=True; refuses if containers are attached."""
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.
"""
# Fetch network list to validate existence and check for attached containers # Fetch network list to validate existence and check for attached containers
try: try:
data = await client.request("SYNO.Docker.Network", "list") 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.""" """Register all project management tools with the MCP server."""
@mcp.tool() @mcp.tool()
async def list_projects() -> str: async def list_projects():
"""List all Container Manager projects with their current status. """List all Container Manager projects with name, status, path, and container count."""
Returns a formatted table of projects including name, status, path,
and container count.
"""
try: try:
data = await client.request("SYNO.Docker.Project", "list") data = await client.request("SYNO.Docker.Project", "list")
except Exception as e: except Exception as e:
@@ -54,12 +50,8 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
return "\n".join(lines).rstrip() return "\n".join(lines).rstrip()
@mcp.tool() @mcp.tool()
async def get_project_status(project_name: str) -> str: async def get_project_status(project_name: str):
"""Get detailed status of a specific project. """Get detailed status and container list for a specific project."""
Args:
project_name: Name of the project to inspect.
"""
project = await _find_project(client, project_name) project = await _find_project(client, project_name)
if project is None: if project is None:
return f"Project '{project_name}' not found." 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) return _format_project_detail(project)
@mcp.tool() @mcp.tool()
async def start_project(project_name: str) -> str: async def start_project(project_name: str):
"""Start a Container Manager project. """Start a Container Manager project."""
Args:
project_name: Name of the project to start.
"""
project = await _find_project(client, project_name) project = await _find_project(client, project_name)
if project is None: if project is None:
return f"Project '{project_name}' not found." 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}" return f"Error starting project '{project_name}': {e}"
@mcp.tool() @mcp.tool()
async def stop_project(project_name: str, confirmed: bool = False) -> str: async def stop_project(project_name: str, confirmed: bool = False):
"""Stop a running Container Manager project. """Stop all containers in a project. Requires confirmed=True."""
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.
"""
if not confirmed: if not confirmed:
return ( return (
f"Stopping project '{project_name}' will halt all its containers.\n" 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}" return f"Error stopping project '{project_name}': {e}"
@mcp.tool() @mcp.tool()
async def redeploy_project(project_name: str, confirmed: bool = False) -> str: async def redeploy_project(project_name: str, confirmed: bool = False):
"""Redeploy a project, pulling the latest images via SYNO.Docker.Project/build_stream. """Pull latest images and restart a project via build_stream. Requires confirmed=True."""
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.
"""
if not confirmed: if not confirmed:
return ( return (
f"Redeploying project '{project_name}' will stop and restart all its " 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.""" """Register all system-level tools with the MCP server."""
@mcp.tool() @mcp.tool()
async def system_df() -> str: async def system_df():
"""Show Docker disk usage: images, containers, and volumes. """Show Docker disk usage: image count/size and container running/stopped counts."""
Assembles disk-usage data from the image and container lists.
Images that are not referenced by any container are marked as
reclaimable.
"""
errors: list[str] = [] errors: list[str] = []
# ── Images ─────────────────────────────────────────────────────────── # ── Images ───────────────────────────────────────────────────────────
@@ -94,16 +89,8 @@ def register_system(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
return "\n".join(lines) return "\n".join(lines)
@mcp.tool() @mcp.tool()
async def system_prune(confirmed: bool = False) -> str: async def system_prune(confirmed: bool = False):
"""Remove unused Docker resources: dangling images, stopped containers, unused networks. """Remove unused Docker resources (images, stopped containers). Requires confirmed=True."""
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.
"""
# ── Gather preview data ─────────────────────────────────────────────── # ── Gather preview data ───────────────────────────────────────────────
dangling_images: list[dict[str, Any]] = [] dangling_images: list[dict[str, Any]] = []
stopped_containers: list[dict[str, Any]] = [] stopped_containers: list[dict[str, Any]] = []