From 1c02d5cf6dffddc5ba6fac5b2ef9ce3f18a76076 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 14 Dec 2025 18:07:57 +0000 Subject: [PATCH] Initial commit: Custom Proxmox MCP with SSE wrapper --- .env.example | 29 +++++++++ .gitea/workflows/build.yaml | 30 ++++++++++ .gitignore | 19 ++++++ Dockerfile | 37 ++++++++++++ IMPLEMENTATION.md | 71 ++++++++++++++++++++++ Makefile | 36 ++++++++++++ README.md | 69 ++++++++++++++++++++++ docker-compose.dev.yml | 11 ++++ docker-compose.yml | 22 +++++++ pyproject.toml | 20 +++++++ server.py | 114 ++++++++++++++++++++++++++++++++++++ 11 files changed, 458 insertions(+) create mode 100644 .env.example create mode 100644 .gitea/workflows/build.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 IMPLEMENTATION.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 server.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..05ab30f --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# 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) --- +# Token ID (the name after the !, e.g., if "user@pam!mytoken" then use "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..f543899 --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,71 @@ +# Custom Proxmox MCP Server Implementation + +## Architecture Overview + +This project implements a **Model Context Protocol (MCP)** server for Proxmox VE using a "Hybrid" design pattern. It combines specific, high-value tools with raw API access to ensure both usability and comprehensive coverage. + +### Core Components + +1. **Transport:** + * **Protocol:** Server-Sent Events (SSE) over HTTP. + * **Framework:** `mcp` (official Python SDK) using `FastMCP`. + * **Server:** `uvicorn` (ASGI server). + * **Host Binding:** `0.0.0.0:8000` (exposed for Docker). + +2. **Proxmox Integration:** + * **Client Library:** `proxmoxer` (Python wrapper for Proxmox API). + * **Authentication:** API Token (`user@pam!token_id`) or Username/Password. + * **SSL:** Configurable verification (default `false` for homelabs). + +3. **Deployment:** + * **Container:** Docker (multi-stage build with `uv`). + * **Registry:** Gitea Container Registry (`gitea.ext.ben.io`). + * **Orchestration:** Portainer (Docker Compose stack). + * **CI/CD:** Gitea Actions (build & push on commit). + +## The Hybrid Tool Strategy + +Instead of wrapping every Proxmox API endpoint (of which there are hundreds), we expose two layers of tools: + +### Layer 1: Curated Tools (High Frequency) +These tools simplify common tasks for the LLM. +* `list_nodes()`: Returns cluster node status. +* `get_cluster_resources()`: Returns all VMs, LXCs, and storage. + +### Layer 2: Raw Access (The "Escape Hatch") +* `proxmox_api_call(path, method, data)`: A generic tool that allows the LLM to construct *any* API call supported by `proxmoxer`. + * Example: `path="nodes/pve1/qemu/100/status/start", method="POST"` + * **Benefit:** Zero maintenance. If Proxmox adds a feature, the LLM can use it immediately without code changes. + +## Current Implementation Details + +### `server.py` +* Uses `mcp.server.fastmcp.FastMCP` to define the server and tools. +* Connects to Proxmox using `proxmoxer.ProxmoxAPI`. +* Exposes `mcp.sse_app` to `uvicorn` for execution. + +### Challenges & Workarounds +* **Host Header Validation:** The `mcp` SDK enforces strict Host header checks by default. We are currently configuring `FastMCP` settings to allow external access (e.g., `proxmox-mcp.ext.ben.io`). +* **Dependency Management:** We use `uv` for fast, reliable builds in Docker. +* **API Client:** `proxmoxer` requires `requests` (added to `pyproject.toml`). + +## Next Steps +1. Resolve the `Invalid Host header` error by correctly configuring `mcp.settings.allowed_hosts` or middleware. +2. Deploy the final image to Portainer. +3. Connect Gemini CLI via `https://proxmox-mcp.ext.ben.io/sse`. + +## Local Testing Challenges + +A significant challenge in this development environment is the **lack of direct local testing capabilities**. Due to the nature of operating within a sandboxed agent and the separation between the development VM (where this agent runs) and the target Docker/Portainer host, the standard iterative development loop (code -> test -> debug) is heavily impacted: + +* **No Direct `uvicorn` Execution:** I cannot directly run the Python server locally to quickly test changes. +* **Remote Docker Environment:** Each code change requires: + 1. Committing and pushing to Gitea. + 2. Waiting for Gitea Actions to build and push the Docker image. + 3. Redeploying the Docker stack on the Portainer host. + 4. Analyzing remote container logs for feedback. +* **Limited Debugging:** The inability to attach a debugger or inspect live execution locally forces a heavy reliance on log analysis. +* **Slow Feedback Loop:** This multi-step remote process introduces significant delays, prolonging the time it takes to identify and fix issues (as evidenced by the numerous iterations for `portainer-mcp` and `proxmox-mcp-custom`). + +This necessitates careful reasoning, documentation review, and a systematic approach to debugging, often leading to more turns than would be typical in a local development setup. + 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..1c78b0a --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# 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 (e.g., `list_nodes`, `get_cluster_resources`). +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. + +## Tech Stack + +* **Language:** Python 3.11+ +* **MCP SDK:** `mcp` (Official Python SDK) with `FastMCP` (if available) or standard server implementation. +* **Proxmox Client:** `proxmoxer` (Community standard, stable). +* **Transport:** SSE (Server-Sent Events) for Docker/Remote compatibility. +* **Deployment:** Docker (built via Gitea Actions). + +## Tools + +### `list_nodes` +Returns a list of all nodes in the cluster with their status. + +### `get_resources` +Returns a summary of all resources (VMs, LXC containers, storage) across the cluster. + +### `proxmox_api` +Executes a raw API call to Proxmox. +* `service`: Service path (e.g., `nodes/pve1/qemu`). +* `method`: HTTP Method (GET, POST, PUT, DELETE). +* `data`: Optional JSON payload. + +## Configuration + +Required Environment Variables: +* `PROXMOX_URL`: Base URL (e.g., `https://192.168.1.1:8006`). +* `PROXMOX_USER`: User (e.g., `root@pam`). +* `PROXMOX_PASSWORD`: Password or Token Secret. +* `PROXMOX_TOKEN_ID`: (Optional) Token ID if using tokens (e.g., `mcp-token`). +* `PROXMOX_VERIFY_SSL`: `true` or `false` (Default: `false` for homelabs). + +## Local Development + +Prerequisites: Docker + +```bash +# 1. Copy and configure credentials +cp .env.example .env +# Edit .env with your Proxmox credentials + +# 2. Build and start the server +make build +make dev + +# 3. View logs +make logs + +# 4. Test SSE endpoint (5s timeout) +make test-sse + +# 5. Stop when done +make stop +``` + +## Build & Deploy + +This project is built using Gitea Actions and pushed to the internal registry. +Deploy using Portainer. 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..0a191df --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +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 (e.g., pve.local.ben.io:8006) + - PROXMOX_URL=your_proxmox_host:8006 + # Proxmox API User (e.g., root@pam or user@pve) + - PROXMOX_USER=your_proxmox_user + # Proxmox API Token Secret (usually starts with "pve_mcp!") + - PROXMOX_PASSWORD=your_proxmox_token_secret + # Proxmox API Token ID (e.g., "mcp-token") + - PROXMOX_TOKEN_ID=your_proxmox_token_id + # Set to 'true' to skip SSL verification (e.g., for self-signed certs in homelab) + - PROXMOX_VERIFY_SSL=false + + # --- MCP Server Settings --- + - PORT=8000 # Internal container port + ports: + - "8000:8000" # Host Port : Container Port + 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