Skip to main content
Glama
mr-mister007

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.