portainer-mcp/portainer_docker_server.py
Adolfo Delorenzo e27251b922 feat: add three new Portainer MCP servers
- Add portainer-environments server for environment/endpoint management
- Add portainer-docker server for Docker and Swarm container operations
- Add merged portainer server combining core + teams functionality
- Fix JSON schema issues and API compatibility
- Add comprehensive documentation for each server
- Add .gitignore and .env.example for security

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-18 13:00:05 -03:00

1431 lines
61 KiB
Python
Executable File

#!/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] != "<none>:<none>":
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()