#!/usr/bin/env python3 """MCP server for Portainer Docker and Docker Swarm management.""" import os import sys import json import asyncio from typing import Any, Dict, List, Optional from enum import Enum # Suppress all logging to stderr os.environ["MCP_MODE"] = "true" import mcp.server.stdio import mcp.types as types from mcp.server import NotificationOptions, Server from mcp.server.models import InitializationOptions # Create server server = Server("portainer-docker") # Store for our state portainer_url = os.getenv("PORTAINER_URL", "https://partner.portainer.live") api_key = os.getenv("PORTAINER_API_KEY", "") @server.list_tools() async def handle_list_tools() -> list[types.Tool]: """List available tools.""" return [ # Container Management types.Tool( name="list_containers", description="List containers in an environment", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "all": { "type": "boolean", "description": "Show all containers (default shows only running)", "default": False } }, "required": ["environment_id"] } ), types.Tool( name="inspect_container", description="Get detailed information about a container", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "container_id": { "type": "string", "description": "Container ID or name" } }, "required": ["environment_id", "container_id"] } ), types.Tool( name="create_container", description="Create a new container", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "image": { "type": "string", "description": "Docker image to use" }, "name": { "type": "string", "description": "Container name (optional)" }, "command": { "type": "array", "items": {"type": "string"}, "description": "Command to run (optional)" }, "env": { "type": "array", "items": {"type": "string"}, "description": "Environment variables in KEY=VALUE format (optional)" }, "ports": { "type": "object", "description": "Port bindings (e.g., {'80/tcp': [{'HostPort': '8080'}]})" }, "volumes": { "type": "object", "description": "Volume bindings (e.g., {'/host/path': {'bind': '/container/path', 'mode': 'rw'}})" }, "restart_policy": { "type": "string", "enum": ["no", "always", "unless-stopped", "on-failure"], "default": "no" } }, "required": ["environment_id", "image"] } ), types.Tool( name="start_container", description="Start a stopped container", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "container_id": { "type": "string", "description": "Container ID or name" } }, "required": ["environment_id", "container_id"] } ), types.Tool( name="stop_container", description="Stop a running container", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "container_id": { "type": "string", "description": "Container ID or name" } }, "required": ["environment_id", "container_id"] } ), types.Tool( name="restart_container", description="Restart a container", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "container_id": { "type": "string", "description": "Container ID or name" } }, "required": ["environment_id", "container_id"] } ), types.Tool( name="remove_container", description="Remove a container", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "container_id": { "type": "string", "description": "Container ID or name" }, "force": { "type": "boolean", "description": "Force removal of running container", "default": False } }, "required": ["environment_id", "container_id"] } ), types.Tool( name="get_container_logs", description="Get logs from a container", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "container_id": { "type": "string", "description": "Container ID or name" }, "tail": { "type": "integer", "description": "Number of lines to show from the end", "default": 100 }, "timestamps": { "type": "boolean", "description": "Show timestamps", "default": False } }, "required": ["environment_id", "container_id"] } ), # Image Management types.Tool( name="list_images", description="List Docker images in an environment", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" } }, "required": ["environment_id"] } ), types.Tool( name="pull_image", description="Pull a Docker image", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "image": { "type": "string", "description": "Image name with optional tag (e.g., nginx:latest)" } }, "required": ["environment_id", "image"] } ), types.Tool( name="remove_image", description="Remove a Docker image", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "image_id": { "type": "string", "description": "Image ID or name:tag" }, "force": { "type": "boolean", "description": "Force removal", "default": False } }, "required": ["environment_id", "image_id"] } ), # Volume Management types.Tool( name="list_volumes", description="List Docker volumes", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" } }, "required": ["environment_id"] } ), types.Tool( name="create_volume", description="Create a Docker volume", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "name": { "type": "string", "description": "Volume name" }, "driver": { "type": "string", "description": "Volume driver", "default": "local" } }, "required": ["environment_id", "name"] } ), types.Tool( name="remove_volume", description="Remove a Docker volume", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "volume_name": { "type": "string", "description": "Volume name" } }, "required": ["environment_id", "volume_name"] } ), # Network Management types.Tool( name="list_networks", description="List Docker networks", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" } }, "required": ["environment_id"] } ), types.Tool( name="create_network", description="Create a Docker network", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "name": { "type": "string", "description": "Network name" }, "driver": { "type": "string", "description": "Network driver", "default": "bridge" }, "internal": { "type": "boolean", "description": "Restrict external access", "default": False } }, "required": ["environment_id", "name"] } ), types.Tool( name="remove_network", description="Remove a Docker network", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "network_id": { "type": "string", "description": "Network ID or name" } }, "required": ["environment_id", "network_id"] } ), # Docker Swarm Services types.Tool( name="list_services", description="List Docker Swarm services", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the Swarm environment" } }, "required": ["environment_id"] } ), types.Tool( name="create_service", description="Create a Docker Swarm service", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the Swarm environment" }, "name": { "type": "string", "description": "Service name" }, "image": { "type": "string", "description": "Docker image" }, "replicas": { "type": "integer", "description": "Number of replicas", "default": 1 }, "command": { "type": "array", "items": {"type": "string"}, "description": "Command to run (optional)" }, "env": { "type": "array", "items": {"type": "string"}, "description": "Environment variables in KEY=VALUE format" }, "ports": { "type": "array", "items": { "type": "object", "properties": { "target": {"type": "integer"}, "published": {"type": "integer"}, "protocol": {"type": "string", "enum": ["tcp", "udp"]} } }, "description": "Published ports" } }, "required": ["environment_id", "name", "image"] } ), types.Tool( name="update_service", description="Update a Docker Swarm service", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the Swarm environment" }, "service_id": { "type": "string", "description": "Service ID or name" }, "image": { "type": "string", "description": "New image (optional)" }, "replicas": { "type": "integer", "description": "New replica count (optional)" }, "env": { "type": "array", "items": {"type": "string"}, "description": "New environment variables (optional)" } }, "required": ["environment_id", "service_id"] } ), types.Tool( name="remove_service", description="Remove a Docker Swarm service", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the Swarm environment" }, "service_id": { "type": "string", "description": "Service ID or name" } }, "required": ["environment_id", "service_id"] } ), types.Tool( name="get_service_logs", description="Get logs from a Docker Swarm service", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the Swarm environment" }, "service_id": { "type": "string", "description": "Service ID or name" }, "tail": { "type": "integer", "description": "Number of lines from the end", "default": 100 } }, "required": ["environment_id", "service_id"] } ), # Stack Management types.Tool( name="list_stacks", description="List Docker stacks", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" } }, "required": ["environment_id"] } ), types.Tool( name="deploy_stack", description="Deploy a Docker stack from compose file", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "name": { "type": "string", "description": "Stack name" }, "compose_file": { "type": "string", "description": "Docker Compose file content (YAML)" }, "env": { "type": "array", "items": { "type": "object", "properties": { "name": {"type": "string"}, "value": {"type": "string"} } }, "description": "Environment variables for the stack" } }, "required": ["environment_id", "name", "compose_file"] } ), types.Tool( name="remove_stack", description="Remove a Docker stack", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" }, "stack_id": { "type": "integer", "description": "Stack ID" } }, "required": ["environment_id", "stack_id"] } ), # System Information types.Tool( name="get_docker_info", description="Get Docker system information", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" } }, "required": ["environment_id"] } ), types.Tool( name="get_docker_version", description="Get Docker version information", inputSchema={ "type": "object", "properties": { "environment_id": { "type": "integer", "description": "ID of the environment" } }, "required": ["environment_id"] } ) ] async def make_request(method: str, endpoint: str, json_data: Optional[Dict] = None, params: Optional[Dict] = None, text_response: bool = False) -> Dict[str, Any]: """Make HTTP request to Portainer API.""" import httpx async with httpx.AsyncClient(verify=False, timeout=30.0) as client: headers = {"X-API-Key": api_key} if api_key else {} # Add JSON content type for POST/PUT requests if method in ["POST", "PUT"] and json_data is not None: headers["Content-Type"] = "application/json" if method == "GET": response = await client.get(f"{portainer_url}/api{endpoint}", headers=headers, params=params) elif method == "POST": response = await client.post(f"{portainer_url}/api{endpoint}", headers=headers, json=json_data) elif method == "PUT": response = await client.put(f"{portainer_url}/api{endpoint}", headers=headers, json=json_data) elif method == "DELETE": response = await client.delete(f"{portainer_url}/api{endpoint}", headers=headers) else: raise ValueError(f"Unsupported method: {method}") # Handle text responses (like logs) if text_response: return {"status_code": response.status_code, "data": None, "text": response.text} # Parse JSON response safely try: data = response.json() if response.text and response.headers.get("content-type", "").startswith("application/json") else None except Exception: data = None return {"status_code": response.status_code, "data": data, "text": response.text} def format_container_state(state: str) -> str: """Format container state for display.""" state_map = { "running": "🟒 Running", "paused": "⏸️ Paused", "exited": "πŸ”΄ Stopped", "dead": "πŸ’€ Dead", "created": "πŸ†• Created", "restarting": "πŸ”„ Restarting", "removing": "πŸ—‘οΈ Removing" } return state_map.get(state.lower(), state) def format_bytes(bytes_val: int) -> str: """Format bytes to human readable format.""" for unit in ['B', 'KB', 'MB', 'GB', 'TB']: if bytes_val < 1024.0: return f"{bytes_val:.1f} {unit}" bytes_val /= 1024.0 return f"{bytes_val:.1f} PB" @server.call_tool() async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]: """Handle tool calls.""" import httpx try: env_id = arguments.get("environment_id") if arguments else None # Container Management if name == "list_containers": if not env_id: return [types.TextContent(type="text", text="Error: environment_id is required")] all_containers = arguments.get("all", False) params = {"all": "true"} if all_containers else {"all": "false"} result = await make_request("GET", f"/endpoints/{env_id}/docker/containers/json", params=params) if result["status_code"] == 200 and result["data"] is not None: containers = result["data"] if not containers: return [types.TextContent(type="text", text="No containers found")] output = f"Found {len(containers)} container(s):\n" for container in containers[:20]: # Limit to 20 containers # Container names come with leading slash names = ", ".join([n.lstrip("/") for n in container.get("Names", [])]) state = container.get("State", "unknown") status = container.get("Status", "") image = container.get("Image", "unknown") container_id = container.get("Id", "")[:12] # Short ID output += f"\n- {names} ({container_id})" output += f"\n Image: {image}" output += f"\n Status: {format_container_state(state)} - {status}" # Show ports if any ports = container.get("Ports", []) if ports: port_info = [] for port in ports: if port.get("PublicPort"): port_info.append(f"{port.get('PublicPort')}β†’{port.get('PrivatePort')}/{port.get('Type')}") else: port_info.append(f"{port.get('PrivatePort')}/{port.get('Type')}") output += f"\n Ports: {', '.join(port_info)}" if len(containers) > 20: output += f"\n\n... and {len(containers) - 20} more containers" return [types.TextContent(type="text", text=output)] else: return [types.TextContent(type="text", text=f"Failed to list containers: HTTP {result['status_code']}")] elif name == "inspect_container": container_id = arguments.get("container_id") if not env_id or not container_id: return [types.TextContent(type="text", text="Error: environment_id and container_id are required")] result = await make_request("GET", f"/endpoints/{env_id}/docker/containers/{container_id}/json") if result["status_code"] == 200 and result["data"]: info = result["data"] output = f"Container Details:\n" output += f"- ID: {info.get('Id', '')[:12]}\n" output += f"- Name: {info.get('Name', '').lstrip('/')}\n" output += f"- Image: {info.get('Config', {}).get('Image', '')}\n" output += f"- Status: {format_container_state(info.get('State', {}).get('Status', ''))}\n" output += f"- Created: {info.get('Created', '')}\n" # State details state = info.get('State', {}) if state.get('Running'): output += f"- PID: {state.get('Pid')}\n" output += f"- Started: {state.get('StartedAt')}\n" # Network info networks = info.get('NetworkSettings', {}).get('Networks', {}) if networks: output += "\nNetworks:\n" for net_name, net_info in networks.items(): output += f"- {net_name}: {net_info.get('IPAddress', 'N/A')}\n" # Mounts mounts = info.get('Mounts', []) if mounts: output += "\nMounts:\n" for mount in mounts: output += f"- {mount.get('Source')} β†’ {mount.get('Destination')} ({mount.get('Mode', 'rw')})\n" # Environment variables env_vars = info.get('Config', {}).get('Env', []) if env_vars: output += "\nEnvironment Variables:\n" for env in env_vars[:10]: # Limit to 10 if '=' in env and not any(secret in env.lower() for secret in ['password', 'secret', 'key', 'token']): output += f"- {env}\n" return [types.TextContent(type="text", text=output)] else: return [types.TextContent(type="text", text=f"Failed to inspect container: HTTP {result['status_code']}")] elif name == "create_container": image = arguments.get("image") if not env_id or not image: return [types.TextContent(type="text", text="Error: environment_id and image are required")] # Build container configuration config = { "Image": image, "Hostname": arguments.get("name", ""), "AttachStdin": False, "AttachStdout": True, "AttachStderr": True, "Tty": False, "OpenStdin": False } if arguments.get("command"): config["Cmd"] = arguments["command"] if arguments.get("env"): config["Env"] = arguments["env"] # Host configuration host_config = { "RestartPolicy": {"Name": arguments.get("restart_policy", "no")} } if arguments.get("ports"): config["ExposedPorts"] = {port: {} for port in arguments["ports"].keys()} host_config["PortBindings"] = arguments["ports"] if arguments.get("volumes"): host_config["Binds"] = [] for host_path, container_config in arguments["volumes"].items(): bind = f"{host_path}:{container_config['bind']}" if container_config.get('mode'): bind += f":{container_config['mode']}" host_config["Binds"].append(bind) create_data = { **config, "HostConfig": host_config } # Create container params = {"name": arguments.get("name")} if arguments.get("name") else {} result = await make_request("POST", f"/endpoints/{env_id}/docker/containers/create", json_data=create_data, params=params) if result["status_code"] in [200, 201] and result["data"]: container_id = result["data"].get("Id", "")[:12] output = f"βœ“ Container created: {container_id}" # Auto-start the container start_result = await make_request("POST", f"/endpoints/{env_id}/docker/containers/{container_id}/start") if start_result["status_code"] in [200, 204]: output += "\nβœ“ Container started successfully" else: output += f"\n⚠️ Container created but failed to start: HTTP {start_result['status_code']}" return [types.TextContent(type="text", text=output)] else: error_msg = f"Failed to create container: HTTP {result['status_code']}" if result.get("text"): error_msg += f"\n{result['text']}" return [types.TextContent(type="text", text=error_msg)] elif name == "start_container": container_id = arguments.get("container_id") if not env_id or not container_id: return [types.TextContent(type="text", text="Error: environment_id and container_id are required")] result = await make_request("POST", f"/endpoints/{env_id}/docker/containers/{container_id}/start") if result["status_code"] in [200, 204, 304]: # 304 = already started return [types.TextContent(type="text", text=f"βœ“ Container {container_id} started")] else: return [types.TextContent(type="text", text=f"Failed to start container: HTTP {result['status_code']}")] elif name == "stop_container": container_id = arguments.get("container_id") if not env_id or not container_id: return [types.TextContent(type="text", text="Error: environment_id and container_id are required")] result = await make_request("POST", f"/endpoints/{env_id}/docker/containers/{container_id}/stop") if result["status_code"] in [200, 204, 304]: # 304 = already stopped return [types.TextContent(type="text", text=f"βœ“ Container {container_id} stopped")] else: return [types.TextContent(type="text", text=f"Failed to stop container: HTTP {result['status_code']}")] elif name == "restart_container": container_id = arguments.get("container_id") if not env_id or not container_id: return [types.TextContent(type="text", text="Error: environment_id and container_id are required")] result = await make_request("POST", f"/endpoints/{env_id}/docker/containers/{container_id}/restart") if result["status_code"] in [200, 204]: return [types.TextContent(type="text", text=f"βœ“ Container {container_id} restarted")] else: return [types.TextContent(type="text", text=f"Failed to restart container: HTTP {result['status_code']}")] elif name == "remove_container": container_id = arguments.get("container_id") if not env_id or not container_id: return [types.TextContent(type="text", text="Error: environment_id and container_id are required")] params = {"force": "true"} if arguments.get("force") else {} result = await make_request("DELETE", f"/endpoints/{env_id}/docker/containers/{container_id}", params=params) if result["status_code"] in [200, 204]: return [types.TextContent(type="text", text=f"βœ“ Container {container_id} removed")] else: return [types.TextContent(type="text", text=f"Failed to remove container: HTTP {result['status_code']}")] elif name == "get_container_logs": container_id = arguments.get("container_id") if not env_id or not container_id: return [types.TextContent(type="text", text="Error: environment_id and container_id are required")] params = { "stdout": "true", "stderr": "true", "tail": str(arguments.get("tail", 100)) } if arguments.get("timestamps"): params["timestamps"] = "true" result = await make_request("GET", f"/endpoints/{env_id}/docker/containers/{container_id}/logs", params=params, text_response=True) if result["status_code"] == 200: logs = result.get("text", "") if logs: # Docker logs have special encoding, try to clean them up import re # Remove ANSI escape codes and Docker log headers logs = re.sub(r'\x1b\[[0-9;]*m', '', logs) logs = re.sub(r'[\x00-\x08\x0e-\x1f]', '', logs) output = f"Container logs (last {arguments.get('tail', 100)} lines):\n" output += "-" * 50 + "\n" output += logs return [types.TextContent(type="text", text=output)] else: return [types.TextContent(type="text", text="No logs available")] else: return [types.TextContent(type="text", text=f"Failed to get logs: HTTP {result['status_code']}")] # Image Management elif name == "list_images": if not env_id: return [types.TextContent(type="text", text="Error: environment_id is required")] result = await make_request("GET", f"/endpoints/{env_id}/docker/images/json") if result["status_code"] == 200 and result["data"]: images = result["data"] output = f"Found {len(images)} image(s):\n" for image in images[:20]: # Limit to 20 tags = image.get("RepoTags", []) if tags and tags[0] != ":": image_name = tags[0] else: image_name = image.get("Id", "")[:12] size = format_bytes(image.get("Size", 0)) created = image.get("Created", 0) output += f"\n- {image_name}" output += f"\n ID: {image.get('Id', '')[:12]}" output += f"\n Size: {size}" # Additional tags if len(tags) > 1: output += f"\n Other tags: {', '.join(tags[1:])}" if len(images) > 20: output += f"\n\n... and {len(images) - 20} more images" return [types.TextContent(type="text", text=output)] else: return [types.TextContent(type="text", text=f"Failed to list images: HTTP {result['status_code']}")] elif name == "pull_image": image = arguments.get("image") if not env_id or not image: return [types.TextContent(type="text", text="Error: environment_id and image are required")] # Parse image name and tag if ":" in image: image_name, tag = image.rsplit(":", 1) else: image_name = image tag = "latest" params = {"fromImage": image_name, "tag": tag} result = await make_request("POST", f"/endpoints/{env_id}/docker/images/create", params=params) if result["status_code"] == 200: return [types.TextContent(type="text", text=f"βœ“ Image {image} pulled successfully")] else: return [types.TextContent(type="text", text=f"Failed to pull image: HTTP {result['status_code']}")] elif name == "remove_image": image_id = arguments.get("image_id") if not env_id or not image_id: return [types.TextContent(type="text", text="Error: environment_id and image_id are required")] params = {"force": "true"} if arguments.get("force") else {} result = await make_request("DELETE", f"/endpoints/{env_id}/docker/images/{image_id}", params=params) if result["status_code"] == 200: return [types.TextContent(type="text", text=f"βœ“ Image {image_id} removed")] else: return [types.TextContent(type="text", text=f"Failed to remove image: HTTP {result['status_code']}")] # Volume Management elif name == "list_volumes": if not env_id: return [types.TextContent(type="text", text="Error: environment_id is required")] result = await make_request("GET", f"/endpoints/{env_id}/docker/volumes") if result["status_code"] == 200 and result["data"]: volumes = result["data"].get("Volumes", []) output = f"Found {len(volumes)} volume(s):\n" for volume in volumes[:20]: output += f"\n- {volume.get('Name')}" output += f"\n Driver: {volume.get('Driver')}" output += f"\n Mountpoint: {volume.get('Mountpoint')}" if volume.get('Options'): output += f"\n Options: {volume.get('Options')}" if len(volumes) > 20: output += f"\n\n... and {len(volumes) - 20} more volumes" return [types.TextContent(type="text", text=output)] else: return [types.TextContent(type="text", text=f"Failed to list volumes: HTTP {result['status_code']}")] elif name == "create_volume": volume_name = arguments.get("name") if not env_id or not volume_name: return [types.TextContent(type="text", text="Error: environment_id and name are required")] volume_data = { "Name": volume_name, "Driver": arguments.get("driver", "local") } result = await make_request("POST", f"/endpoints/{env_id}/docker/volumes/create", json_data=volume_data) if result["status_code"] in [200, 201]: return [types.TextContent(type="text", text=f"βœ“ Volume '{volume_name}' created")] else: return [types.TextContent(type="text", text=f"Failed to create volume: HTTP {result['status_code']}")] elif name == "remove_volume": volume_name = arguments.get("volume_name") if not env_id or not volume_name: return [types.TextContent(type="text", text="Error: environment_id and volume_name are required")] result = await make_request("DELETE", f"/endpoints/{env_id}/docker/volumes/{volume_name}") if result["status_code"] in [200, 204]: return [types.TextContent(type="text", text=f"βœ“ Volume '{volume_name}' removed")] else: return [types.TextContent(type="text", text=f"Failed to remove volume: HTTP {result['status_code']}")] # Network Management elif name == "list_networks": if not env_id: return [types.TextContent(type="text", text="Error: environment_id is required")] result = await make_request("GET", f"/endpoints/{env_id}/docker/networks") if result["status_code"] == 200 and result["data"]: networks = result["data"] output = f"Found {len(networks)} network(s):\n" for network in networks: output += f"\n- {network.get('Name')} ({network.get('Id', '')[:12]})" output += f"\n Driver: {network.get('Driver')}" output += f"\n Scope: {network.get('Scope')}" if network.get('Internal'): output += f"\n Internal: Yes" if network.get('Containers'): output += f"\n Containers: {len(network['Containers'])}" return [types.TextContent(type="text", text=output)] else: return [types.TextContent(type="text", text=f"Failed to list networks: HTTP {result['status_code']}")] elif name == "create_network": network_name = arguments.get("name") if not env_id or not network_name: return [types.TextContent(type="text", text="Error: environment_id and name are required")] network_data = { "Name": network_name, "Driver": arguments.get("driver", "bridge"), "Internal": arguments.get("internal", False) } result = await make_request("POST", f"/endpoints/{env_id}/docker/networks/create", json_data=network_data) if result["status_code"] in [200, 201]: return [types.TextContent(type="text", text=f"βœ“ Network '{network_name}' created")] else: return [types.TextContent(type="text", text=f"Failed to create network: HTTP {result['status_code']}")] elif name == "remove_network": network_id = arguments.get("network_id") if not env_id or not network_id: return [types.TextContent(type="text", text="Error: environment_id and network_id are required")] result = await make_request("DELETE", f"/endpoints/{env_id}/docker/networks/{network_id}") if result["status_code"] in [200, 204]: return [types.TextContent(type="text", text=f"βœ“ Network '{network_id}' removed")] else: return [types.TextContent(type="text", text=f"Failed to remove network: HTTP {result['status_code']}")] # Docker Swarm Services elif name == "list_services": if not env_id: return [types.TextContent(type="text", text="Error: environment_id is required")] result = await make_request("GET", f"/endpoints/{env_id}/docker/services") if result["status_code"] == 200 and result["data"]: services = result["data"] output = f"Found {len(services)} service(s):\n" for service in services[:20]: spec = service.get("Spec", {}) mode = spec.get("Mode", {}) # Get replica info if "Replicated" in mode: replicas = mode["Replicated"].get("Replicas", 0) mode_str = f"replicated ({replicas} replicas)" else: mode_str = "global" output += f"\n- {spec.get('Name')} ({service.get('ID', '')[:12]})" output += f"\n Image: {spec.get('TaskTemplate', {}).get('ContainerSpec', {}).get('Image', 'unknown')}" output += f"\n Mode: {mode_str}" # Get current state if service.get("UpdateStatus"): status = service["UpdateStatus"].get("State", "") if status: output += f"\n Update Status: {status}" if len(services) > 20: output += f"\n\n... and {len(services) - 20} more services" return [types.TextContent(type="text", text=output)] else: error_msg = f"Failed to list services: HTTP {result['status_code']}" if result['status_code'] == 503: error_msg += "\nNote: This environment might not be a Docker Swarm cluster" return [types.TextContent(type="text", text=error_msg)] elif name == "create_service": service_name = arguments.get("name") image = arguments.get("image") if not env_id or not service_name or not image: return [types.TextContent(type="text", text="Error: environment_id, name, and image are required")] service_spec = { "Name": service_name, "TaskTemplate": { "ContainerSpec": { "Image": image } }, "Mode": { "Replicated": { "Replicas": arguments.get("replicas", 1) } } } # Add command if provided if arguments.get("command"): service_spec["TaskTemplate"]["ContainerSpec"]["Command"] = arguments["command"] # Add environment variables if arguments.get("env"): service_spec["TaskTemplate"]["ContainerSpec"]["Env"] = arguments["env"] # Add ports if arguments.get("ports"): service_spec["EndpointSpec"] = { "Ports": arguments["ports"] } result = await make_request("POST", f"/endpoints/{env_id}/docker/services/create", json_data=service_spec) if result["status_code"] in [200, 201]: return [types.TextContent(type="text", text=f"βœ“ Service '{service_name}' created")] else: error_msg = f"Failed to create service: HTTP {result['status_code']}" if result.get("text"): error_msg += f"\n{result['text']}" return [types.TextContent(type="text", text=error_msg)] elif name == "update_service": service_id = arguments.get("service_id") if not env_id or not service_id: return [types.TextContent(type="text", text="Error: environment_id and service_id are required")] # Get current service spec result = await make_request("GET", f"/endpoints/{env_id}/docker/services/{service_id}") if result["status_code"] != 200: return [types.TextContent(type="text", text=f"Failed to get service: HTTP {result['status_code']}")] service = result["data"] spec = service.get("Spec", {}) version = service.get("Version", {}).get("Index") # Update fields if provided if arguments.get("image"): spec["TaskTemplate"]["ContainerSpec"]["Image"] = arguments["image"] if arguments.get("replicas") is not None: if "Replicated" in spec.get("Mode", {}): spec["Mode"]["Replicated"]["Replicas"] = arguments["replicas"] if arguments.get("env"): spec["TaskTemplate"]["ContainerSpec"]["Env"] = arguments["env"] # Update service result = await make_request("POST", f"/endpoints/{env_id}/docker/services/{service_id}/update", json_data=spec, params={"version": version}) if result["status_code"] == 200: return [types.TextContent(type="text", text=f"βœ“ Service '{service_id}' updated")] else: return [types.TextContent(type="text", text=f"Failed to update service: HTTP {result['status_code']}")] elif name == "remove_service": service_id = arguments.get("service_id") if not env_id or not service_id: return [types.TextContent(type="text", text="Error: environment_id and service_id are required")] result = await make_request("DELETE", f"/endpoints/{env_id}/docker/services/{service_id}") if result["status_code"] in [200, 204]: return [types.TextContent(type="text", text=f"βœ“ Service '{service_id}' removed")] else: return [types.TextContent(type="text", text=f"Failed to remove service: HTTP {result['status_code']}")] elif name == "get_service_logs": service_id = arguments.get("service_id") if not env_id or not service_id: return [types.TextContent(type="text", text="Error: environment_id and service_id are required")] params = { "stdout": "true", "stderr": "true", "tail": str(arguments.get("tail", 100)) } result = await make_request("GET", f"/endpoints/{env_id}/docker/services/{service_id}/logs", params=params, text_response=True) if result["status_code"] == 200: logs = result.get("text", "") if logs: output = f"Service logs (last {arguments.get('tail', 100)} lines):\n" output += "-" * 50 + "\n" output += logs return [types.TextContent(type="text", text=output)] else: return [types.TextContent(type="text", text="No logs available")] else: return [types.TextContent(type="text", text=f"Failed to get logs: HTTP {result['status_code']}")] # Stack Management elif name == "list_stacks": if not env_id: return [types.TextContent(type="text", text="Error: environment_id is required")] result = await make_request("GET", "/stacks") if result["status_code"] == 200 and result["data"]: stacks = result["data"] # Filter stacks for this environment env_stacks = [s for s in stacks if s.get("EndpointId") == env_id] output = f"Found {len(env_stacks)} stack(s) in this environment:\n" for stack in env_stacks: output += f"\n- {stack.get('Name')} (ID: {stack.get('Id')})" output += f"\n Type: {stack.get('Type', 'unknown')}" output += f"\n Status: {stack.get('Status', 'unknown')}" if stack.get("GitConfig"): output += f"\n Git Repository: Yes" return [types.TextContent(type="text", text=output)] else: return [types.TextContent(type="text", text=f"Failed to list stacks: HTTP {result['status_code']}")] elif name == "deploy_stack": stack_name = arguments.get("name") compose_file = arguments.get("compose_file") if not env_id or not stack_name or not compose_file: return [types.TextContent(type="text", text="Error: environment_id, name, and compose_file are required")] stack_data = { "Name": stack_name, "StackFileContent": compose_file, "Env": arguments.get("env", []) } params = { "type": 2, # Compose stack "method": "string", # Stack file provided as string "endpointId": env_id } result = await make_request("POST", "/stacks", json_data=stack_data, params=params) if result["status_code"] in [200, 201]: return [types.TextContent(type="text", text=f"βœ“ Stack '{stack_name}' deployed successfully")] else: error_msg = f"Failed to deploy stack: HTTP {result['status_code']}" if result.get("text"): error_msg += f"\n{result['text']}" return [types.TextContent(type="text", text=error_msg)] elif name == "remove_stack": stack_id = arguments.get("stack_id") if not env_id or not stack_id: return [types.TextContent(type="text", text="Error: environment_id and stack_id are required")] result = await make_request("DELETE", f"/stacks/{stack_id}") if result["status_code"] in [200, 204]: return [types.TextContent(type="text", text=f"βœ“ Stack removed successfully")] else: return [types.TextContent(type="text", text=f"Failed to remove stack: HTTP {result['status_code']}")] # System Information elif name == "get_docker_info": if not env_id: return [types.TextContent(type="text", text="Error: environment_id is required")] result = await make_request("GET", f"/endpoints/{env_id}/docker/info") if result["status_code"] == 200 and result["data"]: info = result["data"] output = "Docker System Information:\n" output += f"- Docker Version: {info.get('ServerVersion', 'unknown')}\n" output += f"- API Version: {info.get('ApiVersion', 'unknown')}\n" output += f"- Operating System: {info.get('OperatingSystem', 'unknown')}\n" output += f"- Architecture: {info.get('Architecture', 'unknown')}\n" output += f"- Kernel Version: {info.get('KernelVersion', 'unknown')}\n" output += f"- Total Memory: {format_bytes(info.get('MemTotal', 0))}\n" output += f"- CPUs: {info.get('NCPU', 0)}\n" output += f"- Containers: {info.get('Containers', 0)} ({info.get('ContainersRunning', 0)} running)\n" output += f"- Images: {info.get('Images', 0)}\n" # Swarm info swarm = info.get('Swarm', {}) if swarm.get('LocalNodeState') == 'active': output += f"\nSwarm Status:\n" output += f"- Node ID: {swarm.get('NodeID', '')[:12]}\n" output += f"- Is Manager: {swarm.get('ControlAvailable', False)}\n" if swarm.get('RemoteManagers'): output += f"- Managers: {len(swarm['RemoteManagers'])}\n" return [types.TextContent(type="text", text=output)] else: return [types.TextContent(type="text", text=f"Failed to get Docker info: HTTP {result['status_code']}")] elif name == "get_docker_version": if not env_id: return [types.TextContent(type="text", text="Error: environment_id is required")] result = await make_request("GET", f"/endpoints/{env_id}/docker/version") if result["status_code"] == 200 and result["data"]: version = result["data"] output = "Docker Version Information:\n" output += f"- Version: {version.get('Version', 'unknown')}\n" output += f"- API Version: {version.get('ApiVersion', 'unknown')}\n" output += f"- Git Commit: {version.get('GitCommit', 'unknown')}\n" output += f"- Go Version: {version.get('GoVersion', 'unknown')}\n" output += f"- OS/Arch: {version.get('Os', 'unknown')}/{version.get('Arch', 'unknown')}\n" output += f"- Build Time: {version.get('BuildTime', 'unknown')}\n" return [types.TextContent(type="text", text=output)] else: return [types.TextContent(type="text", text=f"Failed to get Docker version: HTTP {result['status_code']}")] else: return [types.TextContent(type="text", text=f"Unknown tool: {name}")] except httpx.TimeoutException: return [types.TextContent(type="text", text="Error: Request timed out. The operation may take longer than expected.")] except httpx.ConnectError: return [types.TextContent(type="text", text="Error: Could not connect to Portainer server. Please check the URL and network connection.")] except Exception as e: import traceback error_details = f"Error: {str(e)}\nType: {type(e).__name__}" return [types.TextContent(type="text", text=error_details)] async def run(): """Run the MCP server.""" # Use stdio transport async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): await server.run( read_stream, write_stream, InitializationOptions( server_name="portainer-docker", server_version="1.0.0", capabilities=server.get_capabilities( notification_options=NotificationOptions( prompts_changed=False, resources_changed=False, tools_changed=False, ), experimental_capabilities={}, ), ), ) def main(): """Main entry point.""" asyncio.run(run()) if __name__ == "__main__": main()