- 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>
241 lines
8.4 KiB
Python
Executable File
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() |