Proxmox MCP Server
by mr-mister007
README.md
# Proxmox MCP Server
MCP server that manages **any Proxmox VE cluster** through the official PVE
API — 22 tools for cluster status, VMs/CTs, storage, snapshots, and
provisioning. Works over stdio (Hermes / Claude / any MCP client) or HTTP
(StreamableHTTP) with optional OAuth for Gemini.
## Configuration
Point it at your server with **either** a YAML config file **or** environment
variables. Env vars always win. See `proxmox-mcp.example.yaml` for a fully
commented template (including multi-server).
```bash
# 1) config file — copy the example and fill it in
cp proxmox-mcp.example.yaml proxmox-mcp.yaml
# pve_url: "https://pve.yourhost:8006"
# pve_token: "root@pam!mcp=<secret>" # or pve_user + pve_password
# 2) …or env vars (equivalent)
export PVE_URL="https://pve.yourhost:8006"
export PVE_TOKEN="root@pam!mcp=<secret>" # preferred: API token
# export PVE_USER="root@pam" # fallback: password auth
# export PVE_PASSWORD="..."
```
Config file discovery order: `$PROXMOX_MCP_CONFIG` → `./proxmox-mcp.yaml` →
`~/.config/proxmox-mcp/config.yaml`.
| Key | Env | Notes |
|----------------|------------------------|-----------------------------------------|
| `pve_url` | `PVE_URL` / `PROXMOX_URL` | `https://host:8006` (**required**) |
| `pve_token` | `PVE_TOKEN` / `PROXMOX_TOKEN` | `user@realm!tokenid=secret` |
| `pve_user` / `pve_password` | `PVE_USER` / `PVE_PASSWORD` | fallback auth |
| `pve_verify_ssl` | `PVE_VERIFY_SSL` | `0` (default, self-signed) / `1` |
| `pve_readonly` | `PVE_READONLY` | `1` disables all mutating tools |
| `mcp_allowed_hosts` | `MCP_ALLOWED_HOSTS` | DNS-rebinding allowlist (ngrok) |
| `mcp_oauth` | `MCP_OAUTH` | `1` enables OAuth (Gemini) |
| `mcp_public_url` | `MCP_PUBLIC_URL` | external base URL (OAuth issuer) |
| `mcp_http_token` | `MCP_HTTP_TOKEN` | static token for `/health` |
| `mcp_http_host` / `mcp_http_port` | `MCP_HTTP_HOST` / `MCP_HTTP_PORT` | bind addr, default `127.0.0.1:8766` |
Multi-server: define `servers: {name: {…}, …}` + `default_server`, pick at
runtime with `PROXMOX_SERVER=name`. MCP-side keys (`mcp_*`) stay global.
## Run (stdio)
```bash
.venv/bin/python server.py # needs PVE_URL + creds from config or env
```
## Tools (22)
**Read-only**
- `cluster_status` — version + nodes + all VMs/CTs
- `list_nodes`, `node_stats` (cpu/mem/disk/net per node)
- `list_vms`, `list_containers`, `vm_status`, `vm_config`
- `list_storage`, `list_snapshots`, `list_pools`
**Control (safe)**
- `vm_start`, `vm_shutdown`, `vm_reboot`, `vm_suspend`, `vm_resume`
- `vm_snapshot`, `vm_snapshot_delete`
**Control (destructive — require explicit `require_confirm='YES'`)**
- `vm_stop` (hard kill), `vm_delete` (permanent + disks)
- `node_restart` (reboots a physical node)
**Provisioning**
- `vm_create_qemu` (vmid, name, node, cores, memory, disk_size, storage, iso, bridge, start)
- `vm_clone` (full clone to new VMID)
## Hermes / Claude registration
Point any stdio MCP client at `server.py`. Config goes in the client's env,
or in `proxmox-mcp.yaml` next to the repo. Hermes example
(`~/.hermes/config.yaml`):
```yaml
mcp_servers:
proxmox:
command: "/path/to/proxmox-mcp/.venv/bin/python"
args: ["/path/to/proxmox-mcp/server.py"]
env:
PVE_URL: "https://pve.yourhost:8006"
PVE_TOKEN: "user@realm!tokenid=secret"
timeout: 60
connect_timeout: 30
```
Requires a Hermes gateway restart to pick up new MCP servers (no hot-reload).
Claude Desktop: `claude_desktop_config.json`, same `command`/`args` shape.
## Install
```bash
python -m venv .venv && .venv/bin/pip install -r requirements.txt
```
## Test
```bash
.venv/bin/python test_client.py # stdio: lists tools, calls cluster_status/list_vms/list_storage/list_nodes
```
## HTTP mode (StreamableHTTP — for ngrok / LAN / remote clients)
Configure via `proxmox-mcp.yaml` (recommended) or env vars:
```yaml
# proxmox-mcp.yaml
pve_url: "https://pve.yourhost:8006"
pve_token: "root@pam!mcp=<secret>"
pve_readonly: true # public endpoint = inspection only
mcp_http_host: "127.0.0.1"
mcp_http_port: 8766
mcp_http_token: "change-me" # static bearer token for /health
```
```bash
# start the local HTTP endpoint
.venv/bin/python http_entry.py
# → http://127.0.0.1:8766/mcp (health: /health, bearer-token protected)
# tunnel it publicly
/snap/bin/ngrok http 8766 --log stdout > ngrok.log 2>&1
# public URL: https://<random>.ngrok-free.app (MCP endpoint: /mcp)
# IMPORTANT: restart http_entry.py AFTER the tunnel is up so the OAuth issuer
# and DNS-rebinding allowlist use the public host (env example):
MCP_ALLOWED_HOSTS=<ngrok-host> MCP_OAUTH=1 MCP_PUBLIC_URL=https://<ngrok-host>.ngrok-free.app \
.venv/bin/python http_entry.py
```
Security model:
- **`pve_readonly: true`** disables all 12 mutating tools server-side — the public
URL can only read. Flip to `false` only if you truly want remote control.
- **`mcp_http_token`** guards `/health` (ops-only, not the MCP endpoint).
- The Proxmox token itself never crosses the tunnel (lives server-side only).
Test:
```bash
MCP_HTTP_TOKEN=<token> .venv/bin/python http_test.py https://<host>.ngrok-free.app/mcp
```
## OAuth mode (required for Gemini)
Gemini only connects to MCP servers that support **standard OAuth**. Enable it
in the config file (`mcp_oauth: true`, `mcp_public_url`, `mcp_allowed_hosts`)
or with env vars:
```bash
# tunnel must already be up
MCP_OAUTH=1 \
MCP_PUBLIC_URL=https://<ngrok-host>.ngrok-free.app \
MCP_ALLOWED_HOSTS=<ngrok-host> \
PVE_READONLY=1 MCP_HTTP_TOKEN=<token> \
.venv/bin/python http_entry.py
```
The OAuth Authorization Server then serves:
- `/.well-known/oauth-authorization-server` (RFC 8414 metadata)
- `/authorize` — interactive HTML consent page for browsers (Google's
account-linking UI requires a rendered grant page)
- `/token` (authorization-code + PKCE, refresh tokens 30d, rotated)
- `/register` (dynamic client registration — Gemini registers itself, no
client id/secret to provision by hand)
- `/revoke`
Test the whole flow (register → PKCE authorize → token → MCP call → refresh):
```bash
.venv/bin/python oauth_test.py https://<host>.ngrok-free.app # SDK-style client
.venv/bin/python google_flow_test.py https://<host>.ngrok-free.app # Google/OpenAuth-exact (Basic-only auth)
.venv/bin/python browser_flow_test.py https://<host>.ngrok-free.app # browser consent-page flow
```
Notes:
- Everything is in-memory — server restart invalidates clients/tokens; clients
re-register automatically.
- `/health` stays behind the static `mcp_http_token` (ops-only, not MCP).
- Non-browser authorize requests still get a plain 302 (no consent HTML).
- The OAuth metadata is served by `http_entry.py`, not the SDK, because the SDK
hardcodes `token_endpoint_auth_methods_supported` without `"none"` (public
client / PKCE) — Gemini validates that list before registering.
- Consent is effectively auto-approve (no login); anyone who can reach
/authorize with a registered client_id gets a code, but codes require the
PKCE verifier. Fine for a personal tunnel; reconsider if shared.
## Pitfalls (for future edits)
- `mcp` SDK v2.x removed `mcp.server.fastmcp` — pin `mcp<2` (venv has 1.29.0).
- `proxmoxer` 2.x uses `service="PVE"` (not `"proxmox"`), needs `requests` installed.
- Token format for proxmoxer: `user='moritz@pve'`, `token_name='moritz'`, `token_value=secret`.
- mcp 1.29 HTTP client yields a **3-tuple** `(read, write, get_session_id)` — unpack 3, not 2.
- `streamable_http_app()` must be the **top-level ASGI app** — Mounting it inside another
Starlette app skips its lifespan and every request fails with
"Task group is not initialized".
- Behind ngrok you get **421 "Invalid Host header"** unless you pass
`MCP_ALLOWED_HOSTS=<host>` (DNS-rebinding protection).
- ngrok on this box is a snap: use the absolute path `/snap/bin/ngrok` in background shells
(PATH differs), and free-tier may need `ngrok-skip-browser-warning: true` + `http2=False`
on the client.
- `/auth/password-login`-style auth is NOT used here; this is bearer-token auth.
- OAuth `expires_at` must be an **int** (`int(time.time())`) — pydantic rejects floats.
- `authorize()` must never pass `scopes=None` into `AuthorizationCode` — Gemini
sends **no `scope` param**; fall back to the client's registered scope. Missing
this caused a 500 at account-link time (fixed in `oauth_provider.py`).
- **Google/OpenAuth sends `client_id` ONLY in the `Authorization: Basic` header
at `/token`** (RFC 6749 §2.3.1). The mcp SDK's `ClientAuthenticator` AND its
`TokenHandler` both require `client_id` in the form body → every Gemini token
exchange failed with `unauthorized_client: Missing client_id` (later
`invalid_request: authorization_code.client_id: Field required`).
Fixed in `http_entry.py`: patched `ClientAuthenticator.authenticate_request`
with a Basic-header fallback + custom `/token` endpoint (our Router handles
it; injects the resolved client_id into the form before model validation).
Re-verify both against the SDK on upgrade.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues