commit 658e2e27ce06359584730dd35090599fc44a70a8 Author: Ben Date: Mon Dec 15 00:53:43 2025 +0000 docs: Clean up README and IMPLEMENTATION for end users diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c525826 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# 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=proxmox-mcp.ext.ben.io,localhost:*,127.0.0.1:* diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..ca27cef --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,30 @@ +name: Build and Push Proxmox MCP Docker Image + +on: + push: + branches: + - main + - master + +jobs: + build: + runs-on: docker + container: + image: catthehacker/ubuntu:act-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Login to Gitea Container Registry + uses: docker/login-action@v2 + with: + registry: gitea.ext.ben.io + username: ${{ gitea.actor }} + password: ${{ secrets.CR_PAT }} # Using CR_PAT secret + + - name: Build and Push + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: gitea.ext.ben.io/${{ gitea.repository }}:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..021802f --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Environment files with credentials +.env +.env.local +.env.*.local + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..de348a7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Stage 1: Builder for dependencies +FROM python:3.11-slim-bullseye AS builder + +WORKDIR /app + +# Install uv +RUN pip install uv + +# Copy only dependency files first to leverage Docker cache +COPY pyproject.toml ./ + +# Install dependencies with uv +# Use 'uv pip install --system' to install directly into the system python environment +# This avoids creating a virtualenv inside the Docker build stage +RUN uv pip install --system . + +# Stage 2: Final image +FROM python:3.11-slim-bullseye + +WORKDIR /app + +# Copy installed dependencies from builder +# We copy the entire site-packages directory +COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/ +# Also copy uv itself (optional, but good for debugging) +COPY --from=builder /usr/local/bin/uv /usr/local/bin/uv +# And the bin directory for executables like 'uvicorn' +COPY --from=builder /usr/local/bin/ /usr/local/bin/ + +# Copy application code +COPY server.py ./ + +# Expose the port Uvicorn will listen on +EXPOSE 8000 + +# Run the Uvicorn server +CMD ["python", "server.py"] \ No newline at end of file diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..bf245e6 --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,100 @@ +# Implementation Details + +Technical documentation for the Proxmox MCP Server implementation. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ MCP Client (Gemini CLI) │ +└─────────────────────────┬───────────────────────────────┘ + │ SSE (Server-Sent Events) + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Reverse Proxy (Traefik) │ +│ proxmox-mcp.ext.ben.io:443 │ +└─────────────────────────┬───────────────────────────────┘ + │ HTTP + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Docker Container (proxmox-mcp) │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ FastMCP + uvicorn (:8000) │ │ +│ │ ┌─────────────────────────────────────────────┐ │ │ +│ │ │ TransportSecuritySettings │ │ │ +│ │ │ (DNS rebinding protection) │ │ │ +│ │ └─────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ proxmoxer │ │ +│ │ (Proxmox API client) │ │ +│ └───────────────────────┬───────────────────────────┘ │ +└──────────────────────────┼──────────────────────────────┘ + │ HTTPS + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Proxmox VE API │ +│ pve.local.ben.io:8006 │ +└─────────────────────────────────────────────────────────┘ +``` + +## Components + +### Transport Layer +- **Protocol:** SSE (Server-Sent Events) over HTTP +- **Framework:** `mcp.server.fastmcp.FastMCP` +- **Server:** `uvicorn` (ASGI) +- **Binding:** `0.0.0.0:8000` + +### Security +- **DNS Rebinding Protection:** `TransportSecuritySettings` validates Host headers +- **Allowed Hosts:** Configurable via `MCP_ALLOWED_HOSTS` environment variable +- **SSL:** Configurable verification for self-signed certificates + +### Proxmox Integration +- **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 + +Instead of wrapping every Proxmox API endpoint (hundreds exist), we expose two layers: + +### Layer 1: Curated Tools +High-frequency operations with simplified interfaces: +- `list_nodes()` - Cluster node status +- `get_cluster_resources()` - All VMs, LXCs, and storage + +### Layer 2: Raw API Access +- `proxmox_api_call(path, method, data)` - Direct access to any Proxmox API endpoint +- **Benefit:** Zero maintenance. New Proxmox features work immediately without code changes. + +## Build & CI/CD + +- **Build Tool:** `uv` (fast Python package manager) +- **Container:** Multi-stage Docker build +- **Registry:** Gitea Container Registry +- **CI/CD:** Gitea Actions (build & push on commit to main/master) +- **Orchestration:** Portainer (Docker Compose stack) + +## Key Implementation Notes + +### Token Authentication +The `proxmoxer` library constructs auth headers as: +``` +Authorization: PVEAPIToken={user}!{token_name}={token_value} +``` + +Therefore: +- `PROXMOX_USER` = Full user (`proxmox-mcp@pam`) +- `PROXMOX_TOKEN_ID` = Token name only (`token`) +- `PROXMOX_PASSWORD` = Token secret value + +### Host Header Validation +The MCP SDK enforces strict Host header validation. For reverse proxy access: +```python +transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=["proxmox-mcp.ext.ben.io", "localhost:*"], +) +``` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..64d8376 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +.PHONY: build dev logs stop test-sse clean + +# Build the Docker image locally +build: + docker compose -f docker-compose.dev.yml build + +# Start the development server +dev: + docker compose -f docker-compose.dev.yml up -d + @echo "Server starting at http://localhost:8001" + @echo "Use 'make logs' to view output" + +# View container logs +logs: + docker compose -f docker-compose.dev.yml logs -f + +# Stop the development server +stop: + docker compose -f docker-compose.dev.yml down + +# Test SSE endpoint (5 second timeout) +test-sse: + @echo "Testing SSE endpoint (5s timeout)..." + @curl -N --max-time 5 http://localhost:8001/sse 2>/dev/null || echo "\n[Timeout - this is expected for SSE]" + +# Test root endpoint +test-root: + @curl -s http://localhost:8001/ | head -20 + +# Full rebuild (no cache) +rebuild: + docker compose -f docker-compose.dev.yml build --no-cache + +# Clean up containers and images +clean: + docker compose -f docker-compose.dev.yml down --rmi local -v diff --git a/README.md b/README.md new file mode 100644 index 0000000..5702ae7 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Custom Proxmox MCP Server + +A robust, maintenance-free Model Context Protocol (MCP) server for Proxmox VE, built with Python and `proxmoxer`. + +## Philosophy: The Hybrid Approach + +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: + +1. **Core Tools:** A small set of high-value tools for discovery and context. +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 + +### `list_nodes` +Returns a list of all nodes in the cluster with their status. + +### `get_cluster_resources` +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 + +Environment Variables: + +| Variable | Required | Description | Example | +|----------|----------|-------------|---------| +| `PROXMOX_URL` | Yes | Proxmox host and port (no `https://`) | `pve.local.example.io:8006` | +| `PROXMOX_USER` | Yes | Full `user@realm` format | `proxmox-mcp@pam` | +| `PROXMOX_TOKEN_ID` | Yes* | Token name only (not full ID) | `token` | +| `PROXMOX_PASSWORD` | Yes | Token secret or password | `xxxxxxxx-xxxx-...` | +| `PROXMOX_VERIFY_SSL` | No | SSL verification (default: `false`) | `false` | +| `MCP_ALLOWED_HOSTS` | No | Allowed Host headers for reverse proxy | `mcp.example.io,localhost:*` | + +> **Note:** If your full token is `proxmox-mcp@pam!mytoken`, set `PROXMOX_USER=proxmox-mcp@pam` and `PROXMOX_TOKEN_ID=mytoken`. + +## Deployment + +### Docker Compose + +```yaml +services: + proxmox-mcp: + image: gitea.ext.ben.io/b3nw/proxmox-mcp-custom:latest + 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:* + ports: + - "8000:8000" + restart: unless-stopped +``` + +### MCP Client Configuration + +Connect via SSE endpoint: `https://your-host/sse` + +## Local Development + +```bash +# Setup +cp .env.example .env +# Edit .env with your Proxmox credentials + +# Build and run +make build +make dev + +# Test +make logs # View container logs +make test-sse # Test SSE endpoint + +# Cleanup +make stop +``` + +## Tech Stack + +* **Language:** Python 3.11+ +* **MCP SDK:** `mcp` with `FastMCP` +* **Proxmox Client:** `proxmoxer` +* **Transport:** SSE (Server-Sent Events) +* **Server:** `uvicorn` (ASGI) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..50fca25 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,11 @@ +services: + proxmox-mcp-dev: + build: + context: . + dockerfile: Dockerfile + container_name: proxmox-mcp-dev + env_file: + - .env + ports: + - "8001:8000" + restart: "no" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e069ad8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + proxmox-mcp: + image: gitea.ext.ben.io/b3nw/proxmox-mcp-custom:latest + container_name: proxmox-mcp + environment: + # --- Proxmox API Credentials --- + # Base URL of your Proxmox VE instance (host:port, no https://) + - PROXMOX_URL=pve.local.ben.io: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 --- + # Allowed Host headers (comma-separated, supports :* for wildcard ports) + - MCP_ALLOWED_HOSTS=proxmox-mcp.ext.ben.io,localhost:*,127.0.0.1:* + ports: + - "8000:8000" + restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6347992 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "proxmox-mcp-custom" +version = "0.1.0" +description = "Custom Proxmox MCP Server" +dependencies = [ + "mcp", + "proxmoxer", + "requests", # Required for proxmoxer https backend + "uvicorn", + "fastapi", + "python-dotenv", # For local development +] +requires-python = ">=3.11" + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[tool.uv] +python-version = "3.11" # Specify desired Python version for uv \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 0000000..b88a58e --- /dev/null +++ b/server.py @@ -0,0 +1,114 @@ +import os +import logging +from mcp.server.fastmcp import FastMCP +from mcp.server.transport_security import TransportSecuritySettings +from proxmoxer import ProxmoxAPI +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Logging +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:*") + +# --- 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 + ) + 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.") + +# --- 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) +) +mcp = FastMCP("Proxmox MCP", transport_security=transport_security) + +@mcp.tool() +def list_nodes() -> dict: + """Lists all Proxmox nodes.""" + if not proxmox: return {"error": "Proxmox API not configured"} + try: + nodes = proxmox.nodes.get() + return {"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"} + try: + resources = proxmox.cluster.resources.get() + return {"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"} + + try: + # Build path + api_path = proxmox + 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) + + # Execute + method_func = getattr(api_path, method.lower()) + if method.upper() in ["POST", "PUT"]: + return {"result": method_func(**data)} + else: + return {"result": method_func()} + 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) \ No newline at end of file