Skip to main content
Glama
mr-mister007

Proxmox MCP Server

by mr-mister007

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).

# 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.

Related MCP server: Proxmox MCP Server

Run (stdio)

.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):

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

python -m venv .venv && .venv/bin/pip install -r requirements.txt

Test

.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:

# 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
# 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:

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:

# 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):

.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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    C
    quality
    D
    maintenance
    Enables management of Proxmox VE infrastructure through natural language, providing 120+ tools to control virtual machines, containers, storage, cluster resources, users, and network configurations via the Proxmox API.
    100
    28 npm
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables comprehensive management of Proxmox virtualization environments, including VM and container lifecycle, snapshots, backups, monitoring, and OpenAPI integration.
    14
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    A PROXMOX implementation containing 152 tools covering QEMU VMs, LXC, unified guest power, storage admin, cluster/tasks, snapshots, backups (incl. scheduled jobs), migration, HA, firewall (incl. IPSet CIDRs), access control, replication, SDN (read), ACME (read), pools, and console tickets.
    1
    MIT