feat: Add multi-cluster support with JSON config
All checks were successful
Build and Push Proxmox MCP Docker Image / build (push) Successful in 8s
All checks were successful
Build and Push Proxmox MCP Docker Image / build (push) Successful in 8s
This commit is contained in:
246
server.py
246
server.py
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from proxmoxer import ProxmoxAPI
|
||||
@@ -12,103 +14,221 @@ load_dotenv()
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- Proxmox API Configuration ---
|
||||
PROXMOX_URL = os.getenv("PROXMOX_URL")
|
||||
PROXMOX_USER = os.getenv("PROXMOX_USER")
|
||||
PROXMOX_PASSWORD = os.getenv("PROXMOX_PASSWORD")
|
||||
PROXMOX_TOKEN_ID = os.getenv("PROXMOX_TOKEN_ID")
|
||||
PROXMOX_VERIFY_SSL = os.getenv("PROXMOX_VERIFY_SSL", "false").lower() == "true"
|
||||
|
||||
# --- MCP Transport Security ---
|
||||
# Allow external access via reverse proxy and local development
|
||||
MCP_ALLOWED_HOSTS = os.getenv("MCP_ALLOWED_HOSTS", "proxmox-mcp.ext.ben.io,localhost:*,127.0.0.1:*")
|
||||
MCP_ALLOWED_HOSTS = os.getenv("MCP_ALLOWED_HOSTS", "localhost:*,127.0.0.1:*")
|
||||
|
||||
# --- Initialize Proxmox Client ---
|
||||
proxmox = None
|
||||
if PROXMOX_URL and PROXMOX_USER:
|
||||
try:
|
||||
if PROXMOX_TOKEN_ID and PROXMOX_PASSWORD:
|
||||
# Token-based authentication
|
||||
# PROXMOX_USER should be like "user@pam" or "user@pve"
|
||||
# PROXMOX_TOKEN_ID is the token name (e.g., "mcp-token")
|
||||
# PROXMOX_PASSWORD is the token secret value
|
||||
proxmox = ProxmoxAPI(
|
||||
PROXMOX_URL,
|
||||
user=PROXMOX_USER,
|
||||
token_name=PROXMOX_TOKEN_ID,
|
||||
token_value=PROXMOX_PASSWORD,
|
||||
verify_ssl=PROXMOX_VERIFY_SSL
|
||||
# --- Cluster Configuration ---
|
||||
CLUSTERS_CONFIG_PATH = os.getenv("CLUSTERS_CONFIG_PATH", "/app/clusters.json")
|
||||
|
||||
|
||||
class ClusterManager:
|
||||
"""Manages multiple Proxmox cluster connections."""
|
||||
|
||||
def __init__(self, config_path: str):
|
||||
self.clusters: dict[str, ProxmoxAPI] = {}
|
||||
self.config: dict = {}
|
||||
self._load_config(config_path)
|
||||
|
||||
def _load_config(self, config_path: str) -> None:
|
||||
"""Load cluster configuration from JSON file."""
|
||||
path = Path(config_path)
|
||||
if not path.exists():
|
||||
logger.warning(f"Clusters config not found at {config_path}")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(path) as f:
|
||||
self.config = json.load(f)
|
||||
|
||||
clusters_config = self.config.get("clusters", {})
|
||||
for name, cfg in clusters_config.items():
|
||||
self._connect_cluster(name, cfg)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON in clusters config: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load clusters config: {e}")
|
||||
|
||||
def _connect_cluster(self, name: str, cfg: dict) -> None:
|
||||
"""Connect to a single Proxmox cluster."""
|
||||
try:
|
||||
url = cfg.get("url")
|
||||
user = cfg.get("user")
|
||||
token_id = cfg.get("token_id")
|
||||
token_secret = cfg.get("token_secret")
|
||||
verify_ssl = cfg.get("verify_ssl", False)
|
||||
|
||||
if not all([url, user, token_id, token_secret]):
|
||||
logger.warning(f"Cluster '{name}' missing required fields, skipping")
|
||||
return
|
||||
|
||||
client = ProxmoxAPI(
|
||||
url,
|
||||
user=user,
|
||||
token_name=token_id,
|
||||
token_value=token_secret,
|
||||
verify_ssl=verify_ssl
|
||||
)
|
||||
logger.info(f"Proxmox API client configured with token auth for {PROXMOX_USER}")
|
||||
elif PROXMOX_PASSWORD:
|
||||
# Password-based authentication
|
||||
proxmox = ProxmoxAPI(
|
||||
PROXMOX_URL,
|
||||
user=PROXMOX_USER,
|
||||
password=PROXMOX_PASSWORD,
|
||||
verify_ssl=PROXMOX_VERIFY_SSL
|
||||
)
|
||||
logger.info(f"Proxmox API client configured with password auth for {PROXMOX_USER}")
|
||||
else:
|
||||
logger.warning("PROXMOX_PASSWORD (or token secret) not set. Tools may fail.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to configure Proxmox API: {e}")
|
||||
else:
|
||||
logger.warning("PROXMOX_URL or PROXMOX_USER not set. Tools may fail.")
|
||||
self.clusters[name] = client
|
||||
logger.info(f"Connected to cluster '{name}' ({url})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to cluster '{name}': {e}")
|
||||
|
||||
def get(self, name: str = None) -> ProxmoxAPI | None:
|
||||
"""Get a cluster client by name. If name is None and only one cluster, return it."""
|
||||
if not self.clusters:
|
||||
return None
|
||||
|
||||
if name:
|
||||
return self.clusters.get(name)
|
||||
|
||||
# If only one cluster, return it as default
|
||||
if len(self.clusters) == 1:
|
||||
return next(iter(self.clusters.values()))
|
||||
|
||||
return None
|
||||
|
||||
def list_names(self) -> list[str]:
|
||||
"""Return list of configured cluster names."""
|
||||
return list(self.clusters.keys())
|
||||
|
||||
def require_cluster_param(self) -> bool:
|
||||
"""Returns True if cluster parameter is required (multiple clusters configured)."""
|
||||
return len(self.clusters) > 1
|
||||
|
||||
|
||||
# Initialize cluster manager
|
||||
cluster_manager = ClusterManager(CLUSTERS_CONFIG_PATH)
|
||||
|
||||
# --- FastMCP Server ---
|
||||
# Configure transport security to allow external access via reverse proxy
|
||||
transport_security = TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=True,
|
||||
allowed_hosts=[h.strip() for h in MCP_ALLOWED_HOSTS.split(",")],
|
||||
allowed_origins=[], # Empty = allow any origin (or none for same-origin)
|
||||
allowed_origins=[],
|
||||
)
|
||||
mcp = FastMCP("Proxmox MCP", transport_security=transport_security)
|
||||
|
||||
|
||||
def _get_cluster_or_error(cluster: str = None) -> tuple[ProxmoxAPI | None, dict | None]:
|
||||
"""Helper to get cluster client or return error dict."""
|
||||
if not cluster_manager.clusters:
|
||||
return None, {"error": "No clusters configured. Mount clusters.json to /app/clusters.json"}
|
||||
|
||||
if cluster_manager.require_cluster_param() and not cluster:
|
||||
return None, {
|
||||
"error": "Multiple clusters configured. Specify 'cluster' parameter.",
|
||||
"available_clusters": cluster_manager.list_names()
|
||||
}
|
||||
|
||||
client = cluster_manager.get(cluster)
|
||||
if not client:
|
||||
return None, {
|
||||
"error": f"Cluster '{cluster}' not found",
|
||||
"available_clusters": cluster_manager.list_names()
|
||||
}
|
||||
|
||||
return client, None
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_nodes() -> dict:
|
||||
"""Lists all Proxmox nodes."""
|
||||
if not proxmox: return {"error": "Proxmox API not configured"}
|
||||
def list_clusters() -> dict:
|
||||
"""Lists all configured Proxmox clusters."""
|
||||
names = cluster_manager.list_names()
|
||||
if not names:
|
||||
return {"error": "No clusters configured", "clusters": []}
|
||||
return {"clusters": names}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_nodes(cluster: str = None) -> dict:
|
||||
"""Lists all nodes in a Proxmox cluster.
|
||||
|
||||
Args:
|
||||
cluster: Cluster name (optional if only one cluster configured)
|
||||
"""
|
||||
client, error = _get_cluster_or_error(cluster)
|
||||
if error:
|
||||
return error
|
||||
|
||||
try:
|
||||
nodes = proxmox.nodes.get()
|
||||
return {"nodes": nodes}
|
||||
nodes = client.nodes.get()
|
||||
return {"cluster": cluster or cluster_manager.list_names()[0], "nodes": nodes}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_cluster_resources() -> dict:
|
||||
"""Gets a summary of all cluster resources."""
|
||||
if not proxmox: return {"error": "Proxmox API not configured"}
|
||||
def get_cluster_resources(cluster: str = None) -> dict:
|
||||
"""Gets a summary of all resources in a Proxmox cluster.
|
||||
|
||||
Args:
|
||||
cluster: Cluster name (optional if only one cluster configured)
|
||||
"""
|
||||
client, error = _get_cluster_or_error(cluster)
|
||||
if error:
|
||||
return error
|
||||
|
||||
try:
|
||||
resources = proxmox.cluster.resources.get()
|
||||
return {"resources": resources}
|
||||
resources = client.cluster.resources.get()
|
||||
return {"cluster": cluster or cluster_manager.list_names()[0], "resources": resources}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def proxmox_api_call(path: str, method: str = "GET", data: dict = {}, node: str = None, vmid: int = None, lxcid: int = None) -> dict:
|
||||
"""Executes a raw Proxmox API call."""
|
||||
if not proxmox: return {"error": "Proxmox API not configured"}
|
||||
def proxmox_api_call(
|
||||
path: str,
|
||||
method: str = "GET",
|
||||
data: dict = None,
|
||||
cluster: str = None,
|
||||
node: str = None,
|
||||
vmid: int = None,
|
||||
lxcid: int = None
|
||||
) -> dict:
|
||||
"""Executes a raw Proxmox API call.
|
||||
|
||||
Args:
|
||||
path: API path (e.g., 'nodes/pve1/qemu/100/status/start')
|
||||
method: HTTP method (GET, POST, PUT, DELETE)
|
||||
data: Optional JSON payload for POST/PUT
|
||||
cluster: Cluster name (optional if only one cluster configured)
|
||||
node: Node name (for path substitution)
|
||||
vmid: VM ID (for path substitution)
|
||||
lxcid: LXC container ID (for path substitution)
|
||||
"""
|
||||
client, error = _get_cluster_or_error(cluster)
|
||||
if error:
|
||||
return error
|
||||
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
try:
|
||||
# Build path
|
||||
api_path = proxmox
|
||||
api_path = client
|
||||
for segment in path.strip('/').split('/'):
|
||||
if segment == "nodes" and node: api_path = api_path.nodes(node)
|
||||
elif segment == "qemu" and vmid: api_path = api_path.qemu(vmid)
|
||||
elif segment == "lxc" and lxcid: api_path = api_path.lxc(lxcid)
|
||||
elif segment: api_path = api_path(segment)
|
||||
if segment == "nodes" and node:
|
||||
api_path = api_path.nodes(node)
|
||||
elif segment == "qemu" and vmid:
|
||||
api_path = api_path.qemu(vmid)
|
||||
elif segment == "lxc" and lxcid:
|
||||
api_path = api_path.lxc(lxcid)
|
||||
elif segment:
|
||||
api_path = api_path(segment)
|
||||
|
||||
# Execute
|
||||
method_func = getattr(api_path, method.lower())
|
||||
if method.upper() in ["POST", "PUT"]:
|
||||
return {"result": method_func(**data)}
|
||||
result = method_func(**data)
|
||||
else:
|
||||
return {"result": method_func()}
|
||||
result = method_func()
|
||||
|
||||
return {"cluster": cluster or cluster_manager.list_names()[0], "result": result}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
# Use the exposed sse_app from FastMCP and run it with uvicorn
|
||||
uvicorn.run(mcp.sse_app, host="0.0.0.0", port=8000)
|
||||
Reference in New Issue
Block a user