portainer-mcp/simple_mcp_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

241 lines
8.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Simple MCP server for Portainer that actually works."""
import os
import sys
import json
import asyncio
from typing import Any, Dict, List
# 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
# Add src to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
# Create server
server = Server("portainer-core")
# 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 [
types.Tool(
name="test_connection",
description="Test connection to Portainer",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
types.Tool(
name="get_users",
description="Get list of Portainer users",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
types.Tool(
name="create_user",
description="Create a new Portainer user",
inputSchema={
"type": "object",
"properties": {
"username": {
"type": "string",
"description": "Username for the new user"
},
"password": {
"type": "string",
"description": "Password for the new user"
},
"role": {
"type": "string",
"description": "User role (Administrator, StandardUser, ReadOnlyUser)",
"enum": ["Administrator", "StandardUser", "ReadOnlyUser"],
"default": "StandardUser"
}
},
"required": ["username", "password"]
}
)
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]:
"""Handle tool calls."""
import httpx
if name == "test_connection":
try:
async with httpx.AsyncClient(verify=False) as client:
response = await client.get(
f"{portainer_url}/api/status",
headers={"X-API-Key": api_key} if api_key else {}
)
if response.status_code == 200:
return [types.TextContent(
type="text",
text=f"✓ Connected to Portainer at {portainer_url}"
)]
else:
return [types.TextContent(
type="text",
text=f"✗ Failed to connect: HTTP {response.status_code}"
)]
except Exception as e:
return [types.TextContent(
type="text",
text=f"✗ Connection error: {str(e)}"
)]
elif name == "get_users":
try:
async with httpx.AsyncClient(verify=False) as client:
response = await client.get(
f"{portainer_url}/api/users",
headers={"X-API-Key": api_key}
)
if response.status_code == 200:
users = response.json()
result = f"Found {len(users)} users:\n"
for user in users:
# Handle both old (int) and new (string) role formats
role = user.get("Role", "Unknown")
if isinstance(role, int):
role_map = {1: "Administrator", 2: "StandardUser", 3: "ReadOnlyUser"}
role = role_map.get(role, f"Unknown({role})")
result += f"- {user.get('Username', 'Unknown')} ({role})\n"
return [types.TextContent(type="text", text=result)]
else:
return [types.TextContent(
type="text",
text=f"Failed to get users: HTTP {response.status_code}"
)]
except Exception as e:
return [types.TextContent(
type="text",
text=f"Error getting users: {str(e)}"
)]
elif name == "create_user":
if not arguments:
return [types.TextContent(
type="text",
text="Error: Missing required arguments"
)]
username = arguments.get("username")
password = arguments.get("password")
role = arguments.get("role", "StandardUser")
if not username or not password:
return [types.TextContent(
type="text",
text="Error: Username and password are required"
)]
# Convert role string to integer for older Portainer versions
role_map = {"Administrator": 1, "StandardUser": 2, "ReadOnlyUser": 3}
role_int = role_map.get(role, 2)
try:
async with httpx.AsyncClient(verify=False) as client:
# Try with integer role first (older versions)
response = await client.post(
f"{portainer_url}/api/users",
headers={"X-API-Key": api_key},
json={
"Username": username,
"Password": password,
"Role": role_int
}
)
if response.status_code == 409:
return [types.TextContent(
type="text",
text=f"User '{username}' already exists"
)]
elif response.status_code in [200, 201]:
return [types.TextContent(
type="text",
text=f"✓ User '{username}' created successfully with role {role}"
)]
else:
# Try with string role (newer versions)
response = await client.post(
f"{portainer_url}/api/users",
headers={"X-API-Key": api_key},
json={
"Username": username,
"Password": password,
"Role": role
}
)
if response.status_code in [200, 201]:
return [types.TextContent(
type="text",
text=f"✓ User '{username}' created successfully with role {role}"
)]
else:
return [types.TextContent(
type="text",
text=f"Failed to create user: HTTP {response.status_code} - {response.text}"
)]
except Exception as e:
return [types.TextContent(
type="text",
text=f"Error creating user: {str(e)}"
)]
else:
return [types.TextContent(
type="text",
text=f"Unknown tool: {name}"
)]
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-core",
server_version="0.2.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()