Add list_networks, create_network, delete_network tools (Gruppe 4)

list_networks:
  SYNO.Docker.Network/list → shows name, driver, subnet, gateway,
  attached containers. Response key is "network" (not "networks").

create_network(name, driver, subnet, gateway, ip_range, enable_ipv6, confirmed):
  Dry-run preview without confirmed=True. Passes enable_ipv6 as
  json.dumps(bool) per N4S4 reference. Optional params (subnet,
  gateway, iprange) omitted from request when not provided.

delete_network(name, confirmed):
  Validates network exists and has no attached containers before
  deleting. Clear error listing attached container names if blocked.

15 unit tests covering all paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 18:42:10 +02:00
parent 6bdd2bcb6a
commit 4cee16922f
3 changed files with 501 additions and 0 deletions
@@ -0,0 +1,155 @@
"""MCP tools for SYNO.Docker.Network: list, create, delete."""
from __future__ import annotations
import json
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_networks(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
"""Register all network management tools with the MCP server."""
@mcp.tool()
async def list_networks() -> str:
"""List all Docker networks with driver, subnet, gateway, and attached containers."""
try:
data = await client.request("SYNO.Docker.Network", "list")
except Exception as e:
return f"Error listing networks: {e}"
networks: list[dict[str, Any]] = data.get("network", [])
if not networks:
return "No networks found."
lines = [f"Networks ({len(networks)} total):", ""]
for net in sorted(networks, key=lambda n: n.get("name", "")):
name = net.get("name", "?")
driver = net.get("driver", "?")
subnet = net.get("subnet") or ""
gateway = net.get("gateway") or ""
ipv6 = "IPv6" if net.get("enable_ipv6") else ""
containers: list[str] = net.get("containers") or []
lines.append(f" {name}")
lines.append(f" Driver: {driver}{(' ' + ipv6) if ipv6 else ''}")
lines.append(f" Subnet: {subnet}")
lines.append(f" Gateway: {gateway}")
if containers:
lines.append(f" Containers ({len(containers)}): {', '.join(containers)}")
else:
lines.append(" Containers: none")
lines.append("")
return "\n".join(lines).rstrip()
@mcp.tool()
async def create_network(
name: str,
driver: str = "bridge",
subnet: str | None = None,
gateway: str | None = None,
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.
"""
details = [f" Name: {name}", f" Driver: {driver}"]
if subnet:
details.append(f" Subnet: {subnet}")
if gateway:
details.append(f" Gateway: {gateway}")
if ip_range:
details.append(f" IP range:{ip_range}")
if enable_ipv6:
details.append(" IPv6: enabled")
if not confirmed:
return (
"Preview: would create network:\n"
+ "\n".join(details)
+ f"\n\nCall create_network(name={name!r}, confirmed=True) to confirm."
)
params: dict[str, Any] = {
"name": name,
"driver": driver,
"enable_ipv6": json.dumps(enable_ipv6),
}
if subnet is not None:
params["subnet"] = subnet
if gateway is not None:
params["gateway"] = gateway
if ip_range is not None:
params["iprange"] = ip_range
try:
result = await client.request("SYNO.Docker.Network", "create", params=params)
except Exception as e:
return f"Error creating network '{name}': {e}"
net_id = result.get("id", "")
id_str = f" (ID: {net_id[:12]})" if net_id else ""
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.
"""
# Fetch network list to validate existence and check for attached containers
try:
data = await client.request("SYNO.Docker.Network", "list")
except Exception as e:
return f"Error fetching network list: {e}"
networks: list[dict[str, Any]] = data.get("network", [])
target = next((n for n in networks if n.get("name") == name), None)
if target is None:
return f"Network '{name}' not found."
attached: list[str] = target.get("containers") or []
if attached:
return (
f"Cannot delete network '{name}': "
f"{len(attached)} container(s) attached: {', '.join(attached)}"
)
if not confirmed:
driver = target.get("driver", "?")
subnet = target.get("subnet") or ""
return (
f"Preview: would delete network '{name}' (driver={driver}, subnet={subnet}).\n"
f"Call delete_network(name={name!r}, confirmed=True) to confirm."
)
try:
await client.request("SYNO.Docker.Network", "delete", params={"name": name})
except Exception as e:
return f"Error deleting network '{name}': {e}"
return f"Network '{name}' deleted."
+2
View File
@@ -29,6 +29,7 @@ def create_server(config: AppConfig, client: DsmClient) -> FastMCP:
from mcp_synology_container.modules.compose import register_compose
from mcp_synology_container.modules.containers import register_containers
from mcp_synology_container.modules.images import register_images
from mcp_synology_container.modules.networks import register_networks
from mcp_synology_container.modules.projects import register_projects
from mcp_synology_container.modules.system import register_system
@@ -37,6 +38,7 @@ def create_server(config: AppConfig, client: DsmClient) -> FastMCP:
register_compose(mcp, config, client)
register_images(mcp, config, client)
register_system(mcp, config, client)
register_networks(mcp, config, client)
logger.info("MCP server configured with all tool modules")
return mcp