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:
30
.env.example
30
.env.example
@@ -1,30 +0,0 @@
|
|||||||
# Proxmox MCP Server - Environment Variables
|
|
||||||
# Copy this file to .env and fill in your values
|
|
||||||
|
|
||||||
# --- Proxmox API Configuration ---
|
|
||||||
# Base URL of your Proxmox VE instance (without https://)
|
|
||||||
PROXMOX_URL=pve.local.example.io:8006
|
|
||||||
|
|
||||||
# Proxmox API User (e.g., "root@pam", "user@pve", "proxmox-mcp@pam")
|
|
||||||
PROXMOX_USER=proxmox-mcp@pam
|
|
||||||
|
|
||||||
# --- Token Authentication (Recommended) ---
|
|
||||||
# IMPORTANT: Token ID is JUST the token name, NOT the full identifier!
|
|
||||||
# If your full token is "proxmox-mcp@pam!mytoken", use only "mytoken"
|
|
||||||
PROXMOX_TOKEN_ID=token
|
|
||||||
|
|
||||||
# Token Secret Value (the long UUID-like string)
|
|
||||||
PROXMOX_PASSWORD=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
||||||
|
|
||||||
# --- OR Password Authentication ---
|
|
||||||
# If not using tokens, set PROXMOX_PASSWORD to the user's password
|
|
||||||
# and leave PROXMOX_TOKEN_ID empty
|
|
||||||
|
|
||||||
# --- SSL Verification ---
|
|
||||||
# Set to 'true' in production, 'false' for self-signed certs in homelabs
|
|
||||||
PROXMOX_VERIFY_SSL=false
|
|
||||||
|
|
||||||
# --- MCP Transport Security ---
|
|
||||||
# Comma-separated list of allowed Host header values (for reverse proxy access)
|
|
||||||
# Supports wildcard ports with :* suffix (e.g., localhost:*)
|
|
||||||
MCP_ALLOWED_HOSTS=hostname,localhost:*,127.0.0.1:*
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
clusters.json
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Implementation Details
|
# Implementation Details
|
||||||
|
|
||||||
Technical documentation for the Proxmox MCP Server implementation.
|
Technical documentation for the Proxmox MCP Server.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -8,93 +8,79 @@ Technical documentation for the Proxmox MCP Server implementation.
|
|||||||
┌─────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
│ MCP Client (Gemini CLI) │
|
│ MCP Client (Gemini CLI) │
|
||||||
└─────────────────────────┬───────────────────────────────┘
|
└─────────────────────────┬───────────────────────────────┘
|
||||||
│ SSE (Server-Sent Events)
|
│ SSE
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ Reverse Proxy (Traefik) │
|
|
||||||
│ proxmox-mcp.ext.ben.io:443 │
|
|
||||||
└─────────────────────────┬───────────────────────────────┘
|
|
||||||
│ HTTP
|
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
│ Docker Container (proxmox-mcp) │
|
│ Docker Container (proxmox-mcp) │
|
||||||
│ ┌───────────────────────────────────────────────────┐ │
|
│ ┌───────────────────────────────────────────────────┐ │
|
||||||
│ │ FastMCP + uvicorn (:8000) │ │
|
│ │ FastMCP + uvicorn (:8000) │ │
|
||||||
│ │ ┌─────────────────────────────────────────────┐ │ │
|
|
||||||
│ │ │ TransportSecuritySettings │ │ │
|
|
||||||
│ │ │ (DNS rebinding protection) │ │ │
|
|
||||||
│ │ └─────────────────────────────────────────────┘ │ │
|
|
||||||
│ └───────────────────────────────────────────────────┘ │
|
│ └───────────────────────────────────────────────────┘ │
|
||||||
│ ┌───────────────────────────────────────────────────┐ │
|
│ ┌───────────────────────────────────────────────────┐ │
|
||||||
│ │ proxmoxer │ │
|
│ │ ClusterManager │ │
|
||||||
│ │ (Proxmox API client) │ │
|
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
|
||||||
│ └───────────────────────┬───────────────────────────┘ │
|
│ │ │ prod │ │ homelab │ │ ... │ │ │
|
||||||
└──────────────────────────┼──────────────────────────────┘
|
│ │ └────┬────┘ └────┬────┘ └────┬────┘ │ │
|
||||||
│ HTTPS
|
│ └─────────┼────────────┼────────────┼──────────────┘ │
|
||||||
▼
|
└────────────┼────────────┼────────────┼──────────────────┘
|
||||||
┌─────────────────────────────────────────────────────────┐
|
│ │ │
|
||||||
│ Proxmox VE API │
|
▼ ▼ ▼
|
||||||
│ pve.local.ben.io:8006 │
|
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
└─────────────────────────────────────────────────────────┘
|
│ Proxmox │ │ Proxmox │ │ Proxmox │
|
||||||
|
│ Cluster 1│ │ Cluster 2│ │ Cluster N│
|
||||||
|
└──────────┘ └──────────┘ └──────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
### Transport Layer
|
### ClusterManager
|
||||||
- **Protocol:** SSE (Server-Sent Events) over HTTP
|
- Loads cluster configs from `clusters.json`
|
||||||
- **Framework:** `mcp.server.fastmcp.FastMCP`
|
- Maintains `ProxmoxAPI` connections for each cluster
|
||||||
- **Server:** `uvicorn` (ASGI)
|
- Handles cluster selection logic (default if single cluster)
|
||||||
- **Binding:** `0.0.0.0:8000`
|
|
||||||
|
|
||||||
### Security
|
### Transport Security
|
||||||
- **DNS Rebinding Protection:** `TransportSecuritySettings` validates Host headers
|
- `TransportSecuritySettings` validates Host headers
|
||||||
- **Allowed Hosts:** Configurable via `MCP_ALLOWED_HOSTS` environment variable
|
- Configurable via `MCP_ALLOWED_HOSTS`
|
||||||
- **SSL:** Configurable verification for self-signed certificates
|
|
||||||
|
|
||||||
### Proxmox Integration
|
### Tool Strategy
|
||||||
- **Client:** `proxmoxer.ProxmoxAPI`
|
|
||||||
- **Authentication:** API Token (recommended) or Username/Password
|
|
||||||
- **Token Format:** User (`user@realm`) + Token Name (not full ID) + Token Secret
|
|
||||||
|
|
||||||
## The Hybrid Tool Strategy
|
**Layer 1: Curated Tools**
|
||||||
|
- `list_clusters()` - Discovery
|
||||||
|
- `list_nodes(cluster)` - Node status
|
||||||
|
- `get_cluster_resources(cluster)` - Resource summary
|
||||||
|
|
||||||
Instead of wrapping every Proxmox API endpoint (hundreds exist), we expose two layers:
|
**Layer 2: Raw Access**
|
||||||
|
- `proxmox_api_call(cluster, path, method, data)` - Any API endpoint
|
||||||
|
|
||||||
### Layer 1: Curated Tools
|
## Configuration Format
|
||||||
High-frequency operations with simplified interfaces:
|
|
||||||
- `list_nodes()` - Cluster node status
|
|
||||||
- `get_cluster_resources()` - All VMs, LXCs, and storage
|
|
||||||
|
|
||||||
### Layer 2: Raw API Access
|
```json
|
||||||
- `proxmox_api_call(path, method, data)` - Direct access to any Proxmox API endpoint
|
{
|
||||||
- **Benefit:** Zero maintenance. New Proxmox features work immediately without code changes.
|
"clusters": {
|
||||||
|
"<name>": {
|
||||||
## Build & CI/CD
|
"url": "host:port",
|
||||||
|
"user": "user@realm",
|
||||||
- **Build Tool:** `uv` (fast Python package manager)
|
"token_id": "token_name",
|
||||||
- **Container:** Multi-stage Docker build
|
"token_secret": "secret",
|
||||||
- **Registry:** Gitea Container Registry
|
"verify_ssl": false
|
||||||
- **CI/CD:** Gitea Actions (build & push on commit to main/master)
|
}
|
||||||
- **Orchestration:** Portainer (Docker Compose stack)
|
}
|
||||||
|
}
|
||||||
## Key Implementation Notes
|
```
|
||||||
|
|
||||||
### Token Authentication
|
### Token Authentication
|
||||||
The `proxmoxer` library constructs auth headers as:
|
|
||||||
|
The `proxmoxer` library uses:
|
||||||
```
|
```
|
||||||
Authorization: PVEAPIToken={user}!{token_name}={token_value}
|
Authorization: PVEAPIToken={user}!{token_id}={token_secret}
|
||||||
```
|
```
|
||||||
|
|
||||||
Therefore:
|
So for token `mcp@pam!mytoken`:
|
||||||
- `PROXMOX_USER` = Full user (`proxmox-mcp@pam`)
|
- `user` = `mcp@pam`
|
||||||
- `PROXMOX_TOKEN_ID` = Token name only (`token`)
|
- `token_id` = `mytoken`
|
||||||
- `PROXMOX_PASSWORD` = Token secret value
|
|
||||||
|
|
||||||
### Host Header Validation
|
## Build & Deploy
|
||||||
The MCP SDK enforces strict Host header validation. For reverse proxy access:
|
|
||||||
```python
|
- **Build:** `uv` + multi-stage Docker
|
||||||
transport_security = TransportSecuritySettings(
|
- **Registry:** Gitea Container Registry
|
||||||
enable_dns_rebinding_protection=True,
|
- **CI/CD:** Gitea Actions
|
||||||
allowed_hosts=["proxmox-mcp.ext.ben.io", "localhost:*"],
|
- **Deploy:** Docker Compose / Portainer
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|||||||
100
README.md
100
README.md
@@ -1,90 +1,96 @@
|
|||||||
# Custom Proxmox MCP Server
|
# Custom Proxmox MCP Server
|
||||||
|
|
||||||
A robust, maintenance-free Model Context Protocol (MCP) server for Proxmox VE, built with Python and `proxmoxer`.
|
A robust, maintenance-free Model Context Protocol (MCP) server for Proxmox VE with **multi-cluster support**.
|
||||||
|
|
||||||
## Philosophy: The Hybrid Approach
|
## Features
|
||||||
|
|
||||||
Most MCP servers suffer from "feature rot" where the author implements 10 tools (`start_vm`, `stop_vm`) but misses 500 others. This project takes a hybrid approach:
|
- **Multi-Cluster:** Manage multiple Proxmox clusters from a single container
|
||||||
|
- **Hybrid Approach:** Curated high-level tools + raw API access for 100% coverage
|
||||||
1. **Core Tools:** A small set of high-value tools for discovery and context.
|
- **Zero Maintenance:** Raw API tool works with any Proxmox feature without code changes
|
||||||
2. **Raw API Access:** A single powerful tool `proxmox_api` that allows the LLM to call *any* Proxmox API endpoint dynamically. This ensures 100% API coverage without writing wrappers for every function.
|
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
### `list_nodes`
|
| Tool | Description |
|
||||||
Returns a list of all nodes in the cluster with their status.
|
|------|-------------|
|
||||||
|
| `list_clusters` | Lists all configured Proxmox clusters |
|
||||||
|
| `list_nodes` | Lists nodes in a cluster |
|
||||||
|
| `get_cluster_resources` | Gets VMs, LXCs, and storage summary |
|
||||||
|
| `proxmox_api_call` | Execute any Proxmox API call directly |
|
||||||
|
|
||||||
### `get_cluster_resources`
|
All tools accept a `cluster` parameter. If only one cluster is configured, it's optional.
|
||||||
Returns a summary of all resources (VMs, LXC containers, storage) across the cluster.
|
|
||||||
|
|
||||||
### `proxmox_api_call`
|
|
||||||
Executes a raw API call to Proxmox.
|
|
||||||
* `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 requests.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Environment Variables:
|
### 1. Create `clusters.json`
|
||||||
|
|
||||||
| Variable | Required | Description | Example |
|
```json
|
||||||
|----------|----------|-------------|---------|
|
{
|
||||||
| `PROXMOX_URL` | Yes | Proxmox host and port (no `https://`) | `pve.local.example.io:8006` |
|
"clusters": {
|
||||||
| `PROXMOX_USER` | Yes | Full `user@realm` format | `proxmox-mcp@pam` |
|
"production": {
|
||||||
| `PROXMOX_TOKEN_ID` | Yes* | Token name only (not full ID) | `token` |
|
"url": "pve-prod.example.io:8006",
|
||||||
| `PROXMOX_PASSWORD` | Yes | Token secret or password | `xxxxxxxx-xxxx-...` |
|
"user": "mcp@pam",
|
||||||
| `PROXMOX_VERIFY_SSL` | No | SSL verification (default: `false`) | `false` |
|
"token_id": "token",
|
||||||
| `MCP_ALLOWED_HOSTS` | No | Allowed Host headers for reverse proxy | `mcp.example.io,localhost:*` |
|
"token_secret": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"verify_ssl": false
|
||||||
|
},
|
||||||
|
"homelab": {
|
||||||
|
"url": "pve-home.local:8006",
|
||||||
|
"user": "root@pam",
|
||||||
|
"token_id": "mcp",
|
||||||
|
"token_secret": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
|
||||||
|
"verify_ssl": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
> **Note:** If your full token is `proxmox-mcp@pam!mytoken`, set `PROXMOX_USER=proxmox-mcp@pam` and `PROXMOX_TOKEN_ID=mytoken`.
|
> **Token Format:** If your full token is `user@pam!mytoken`, set `user: "user@pam"` and `token_id: "mytoken"`
|
||||||
|
|
||||||
## Deployment
|
### 2. Deploy with Docker Compose
|
||||||
|
|
||||||
### Docker Compose
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
proxmox-mcp:
|
proxmox-mcp:
|
||||||
image: gitea.ext.ben.io/b3nw/proxmox-mcp-custom:latest
|
image: gitea.ext.ben.io/b3nw/proxmox-mcp-custom:latest
|
||||||
environment:
|
environment:
|
||||||
- PROXMOX_URL=pve.local.example.io:8006
|
|
||||||
- PROXMOX_USER=proxmox-mcp@pam
|
|
||||||
- PROXMOX_TOKEN_ID=token
|
|
||||||
- PROXMOX_PASSWORD=your-token-secret
|
|
||||||
- PROXMOX_VERIFY_SSL=false
|
|
||||||
- MCP_ALLOWED_HOSTS=proxmox-mcp.example.io,localhost:*
|
- MCP_ALLOWED_HOSTS=proxmox-mcp.example.io,localhost:*
|
||||||
|
volumes:
|
||||||
|
- ./clusters.json:/app/clusters.json:ro
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
### MCP Client Configuration
|
### 3. Connect MCP Client
|
||||||
|
|
||||||
Connect via SSE endpoint: `https://your-host/sse`
|
SSE endpoint: `https://your-host/sse`
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `MCP_ALLOWED_HOSTS` | `localhost:*,127.0.0.1:*` | Allowed Host headers |
|
||||||
|
| `CLUSTERS_CONFIG_PATH` | `/app/clusters.json` | Path to clusters config |
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Setup
|
# Setup
|
||||||
cp .env.example .env
|
cp clusters.json.example clusters.json
|
||||||
# Edit .env with your Proxmox credentials
|
# Edit clusters.json with your credentials
|
||||||
|
|
||||||
# Build and run
|
# Build and run
|
||||||
make build
|
make build
|
||||||
make dev
|
make dev
|
||||||
|
|
||||||
# Test
|
# Test
|
||||||
make logs # View container logs
|
make logs
|
||||||
make test-sse # Test SSE endpoint
|
make test-sse
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
make stop
|
make stop
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
* **Language:** Python 3.11+
|
- Python 3.11+ / FastMCP / proxmoxer
|
||||||
* **MCP SDK:** `mcp` with `FastMCP`
|
- SSE transport / uvicorn
|
||||||
* **Proxmox Client:** `proxmoxer`
|
- Docker with multi-stage build
|
||||||
* **Transport:** SSE (Server-Sent Events)
|
|
||||||
* **Server:** `uvicorn` (ASGI)
|
|
||||||
|
|||||||
18
clusters.json.example
Normal file
18
clusters.json.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"clusters": {
|
||||||
|
"production": {
|
||||||
|
"url": "pve-prod.example.io:8006",
|
||||||
|
"user": "mcp@pam",
|
||||||
|
"token_id": "token",
|
||||||
|
"token_secret": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"verify_ssl": false
|
||||||
|
},
|
||||||
|
"homelab": {
|
||||||
|
"url": "pve-home.local:8006",
|
||||||
|
"user": "root@pam",
|
||||||
|
"token_id": "mcp",
|
||||||
|
"token_secret": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
|
||||||
|
"verify_ssl": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: proxmox-mcp-dev
|
container_name: proxmox-mcp-dev
|
||||||
env_file:
|
environment:
|
||||||
- .env
|
- MCP_ALLOWED_HOSTS=localhost:*,127.0.0.1:*
|
||||||
|
volumes:
|
||||||
|
- ./clusters.json:/app/clusters.json:ro
|
||||||
ports:
|
ports:
|
||||||
- "8001:8000"
|
- "8001:8000"
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|||||||
@@ -3,27 +3,12 @@ services:
|
|||||||
image: gitea.ext.ben.io/b3nw/proxmox-mcp-custom:latest
|
image: gitea.ext.ben.io/b3nw/proxmox-mcp-custom:latest
|
||||||
container_name: proxmox-mcp
|
container_name: proxmox-mcp
|
||||||
environment:
|
environment:
|
||||||
# --- Proxmox API Credentials ---
|
|
||||||
# Base URL of your Proxmox VE instance (host:port, no https://)
|
|
||||||
- PROXMOX_URL=hostname:8006
|
|
||||||
|
|
||||||
# Proxmox API User - full "user@realm" format
|
|
||||||
# Example: root@pam, admin@pve, proxmox-mcp@pam
|
|
||||||
- PROXMOX_USER=proxmox-mcp@pam
|
|
||||||
|
|
||||||
# Proxmox API Token ID - JUST the token name, NOT the full ID
|
|
||||||
# If your full token is "proxmox-mcp@pam!mytoken", use only "mytoken"
|
|
||||||
- PROXMOX_TOKEN_ID=token
|
|
||||||
|
|
||||||
# Proxmox API Token Secret (the UUID-like value)
|
|
||||||
- PROXMOX_PASSWORD=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
||||||
|
|
||||||
# SSL verification - 'false' for self-signed certs in homelab
|
|
||||||
- PROXMOX_VERIFY_SSL=false
|
|
||||||
|
|
||||||
# --- MCP Transport Security ---
|
# --- MCP Transport Security ---
|
||||||
# Allowed Host headers (comma-separated, supports :* for wildcard ports)
|
# Allowed Host headers (comma-separated, supports :* for wildcard ports)
|
||||||
- MCP_ALLOWED_HOSTS=hostname,localhost:*,127.0.0.1:*
|
- MCP_ALLOWED_HOSTS=proxmox-mcp.example.io,localhost:*,127.0.0.1:*
|
||||||
|
volumes:
|
||||||
|
# Mount your clusters.json configuration file
|
||||||
|
- ./clusters.json:/app/clusters.json:ro
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
244
server.py
244
server.py
@@ -1,5 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
from mcp.server.transport_security import TransportSecuritySettings
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
from proxmoxer import ProxmoxAPI
|
from proxmoxer import ProxmoxAPI
|
||||||
@@ -12,103 +14,221 @@ load_dotenv()
|
|||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
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 ---
|
# --- MCP Transport Security ---
|
||||||
# Allow external access via reverse proxy and local development
|
MCP_ALLOWED_HOSTS = os.getenv("MCP_ALLOWED_HOSTS", "localhost:*,127.0.0.1:*")
|
||||||
MCP_ALLOWED_HOSTS = os.getenv("MCP_ALLOWED_HOSTS", "proxmox-mcp.ext.ben.io,localhost:*,127.0.0.1:*")
|
|
||||||
|
# --- 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
|
||||||
|
|
||||||
# --- Initialize Proxmox Client ---
|
|
||||||
proxmox = None
|
|
||||||
if PROXMOX_URL and PROXMOX_USER:
|
|
||||||
try:
|
try:
|
||||||
if PROXMOX_TOKEN_ID and PROXMOX_PASSWORD:
|
with open(path) as f:
|
||||||
# Token-based authentication
|
self.config = json.load(f)
|
||||||
# PROXMOX_USER should be like "user@pam" or "user@pve"
|
|
||||||
# PROXMOX_TOKEN_ID is the token name (e.g., "mcp-token")
|
clusters_config = self.config.get("clusters", {})
|
||||||
# PROXMOX_PASSWORD is the token secret value
|
for name, cfg in clusters_config.items():
|
||||||
proxmox = ProxmoxAPI(
|
self._connect_cluster(name, cfg)
|
||||||
PROXMOX_URL,
|
|
||||||
user=PROXMOX_USER,
|
except json.JSONDecodeError as e:
|
||||||
token_name=PROXMOX_TOKEN_ID,
|
logger.error(f"Invalid JSON in clusters config: {e}")
|
||||||
token_value=PROXMOX_PASSWORD,
|
|
||||||
verify_ssl=PROXMOX_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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to configure Proxmox API: {e}")
|
logger.error(f"Failed to load clusters config: {e}")
|
||||||
else:
|
|
||||||
logger.warning("PROXMOX_URL or PROXMOX_USER not set. Tools may fail.")
|
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
|
||||||
|
)
|
||||||
|
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 ---
|
# --- FastMCP Server ---
|
||||||
# Configure transport security to allow external access via reverse proxy
|
|
||||||
transport_security = TransportSecuritySettings(
|
transport_security = TransportSecuritySettings(
|
||||||
enable_dns_rebinding_protection=True,
|
enable_dns_rebinding_protection=True,
|
||||||
allowed_hosts=[h.strip() for h in MCP_ALLOWED_HOSTS.split(",")],
|
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)
|
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()
|
@mcp.tool()
|
||||||
def list_nodes() -> dict:
|
def list_clusters() -> dict:
|
||||||
"""Lists all Proxmox nodes."""
|
"""Lists all configured Proxmox clusters."""
|
||||||
if not proxmox: return {"error": "Proxmox API not configured"}
|
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:
|
try:
|
||||||
nodes = proxmox.nodes.get()
|
nodes = client.nodes.get()
|
||||||
return {"nodes": nodes}
|
return {"cluster": cluster or cluster_manager.list_names()[0], "nodes": nodes}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_cluster_resources() -> dict:
|
def get_cluster_resources(cluster: str = None) -> dict:
|
||||||
"""Gets a summary of all cluster resources."""
|
"""Gets a summary of all resources in a Proxmox cluster.
|
||||||
if not proxmox: return {"error": "Proxmox API not configured"}
|
|
||||||
|
Args:
|
||||||
|
cluster: Cluster name (optional if only one cluster configured)
|
||||||
|
"""
|
||||||
|
client, error = _get_cluster_or_error(cluster)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resources = proxmox.cluster.resources.get()
|
resources = client.cluster.resources.get()
|
||||||
return {"resources": resources}
|
return {"cluster": cluster or cluster_manager.list_names()[0], "resources": resources}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def proxmox_api_call(path: str, method: str = "GET", data: dict = {}, node: str = None, vmid: int = None, lxcid: int = None) -> dict:
|
def proxmox_api_call(
|
||||||
"""Executes a raw Proxmox API call."""
|
path: str,
|
||||||
if not proxmox: return {"error": "Proxmox API not configured"}
|
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:
|
try:
|
||||||
# Build path
|
# Build path
|
||||||
api_path = proxmox
|
api_path = client
|
||||||
for segment in path.strip('/').split('/'):
|
for segment in path.strip('/').split('/'):
|
||||||
if segment == "nodes" and node: api_path = api_path.nodes(node)
|
if segment == "nodes" and node:
|
||||||
elif segment == "qemu" and vmid: api_path = api_path.qemu(vmid)
|
api_path = api_path.nodes(node)
|
||||||
elif segment == "lxc" and lxcid: api_path = api_path.lxc(lxcid)
|
elif segment == "qemu" and vmid:
|
||||||
elif segment: api_path = api_path(segment)
|
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
|
# Execute
|
||||||
method_func = getattr(api_path, method.lower())
|
method_func = getattr(api_path, method.lower())
|
||||||
if method.upper() in ["POST", "PUT"]:
|
if method.upper() in ["POST", "PUT"]:
|
||||||
return {"result": method_func(**data)}
|
result = method_func(**data)
|
||||||
else:
|
else:
|
||||||
return {"result": method_func()}
|
result = method_func()
|
||||||
|
|
||||||
|
return {"cluster": cluster or cluster_manager.list_names()[0], "result": result}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
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)
|
uvicorn.run(mcp.sse_app, host="0.0.0.0", port=8000)
|
||||||
Reference in New Issue
Block a user