proxmox-mcp-server
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@proxmox-mcp-serverlist all VMs and containers"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
proxmox-mcp-server
A Cloudflare Worker that lets Claude manage a Proxmox VE host over MCP. It handles everything structured through the Proxmox REST API, and hands shell work to a companion service, proxmox-mcp-bridge, which runs on the Proxmox host itself.
Nothing is exposed to the internet directly: the Worker reaches Proxmox through a Cloudflare Tunnel, authenticating with a Cloudflare Access service token.
Claude (claude.ai connector)
| MCP over HTTPS, bearer token
proxmox-mcp-server (this repo, a Cloudflare Worker)
| + CF-Access service token headers
Cloudflare Access -> Cloudflare Tunnel -> Proxmox host
|- pveproxy :8006 REST API
'- exec bridge :5000 shell
(proxmox-mcp-bridge)Using it
Once the connector is added, you just talk to Claude normally. A few things worth knowing about how it behaves:
You rarely need to name the node. node and type are optional on every
guest tool — the Worker looks the vmid up in /cluster/resources and fills
them in. On a single-node install you never type them at all. On a cluster you
still can, to disambiguate.
Write operations return a task, not a result. Proxmox runs starts, stops
and snapshots asynchronously and hands back a UPID. Claude gets that UPID and
can poll it with task_status, which also returns the task log — so "did that
actually work?" is answerable rather than assumed.
Things you can ask for directly:
"What's running on Proxmox right now?" —
list_guests, one call, shows every VM and container with status and memory use."How's the host doing?" —
node_statusfor cpu, memory, swap, root filesystem, load average and uptime."Snapshot container 101 before I upgrade it." —
snapshot_guest, thenrollback_snapshotif the upgrade goes badly."Gracefully shut down VM 100, and tell me when it's actually down." —
shutdown_guestfollowed bytask_statuson the returned UPID."Anything failed on the host lately?" —
list_taskswitherrors: true."What storage do I have configured?" — no dedicated tool, so this falls to
pve_apiwithGET /nodes/<node>/storage.
Responses can be trimmed server-side. pve_api, list_guests,
guest_status, node_status and list_tasks all take fields and
omit_fields; the list-shaped ones also take limit, which keeps the most
recent entries. Trimming happens before the payload is serialised, so it is a
real saving rather than a display filter.
Where it matters most:
A week of
rrddatais ~330 points of ~20 metrics. Asking forfields: ["time","mem","maxmem"]cuts roughly 70%; addinglimittakes it past 90%.Guest configs from community install scripts carry large HTML blobs in
description.omit_fields: ["description"]removes ~96% of a config read.list_guestsalso takesstatus: "running"to skip stopped guests entirely.
Two things happen automatically, with no parameters:
task_statusfetches the task log only when the task failed or is still running. The log of a successful start is noise, and this is the most-called tool since every write returns a UPID. Passlog: trueto force it.Responses over 2000 characters drop JSON indentation, worth about a third of a large payload. Smaller ones stay pretty-printed.
run_scriptoutput is capped at 40000 characters (~10k tokens), cut from the middle so both ends survive, with a marker saying how much was dropped. Raise it per-call withmax_output— but filtering on the host withgrep,tail -norjournalctl -nis almost always better.
With the bridge installed you additionally get run_script:
"Check disk usage inside container 101." — runs
df -hviapct exec."Restart nginx in 101 and show me the last 20 log lines."
"What kernel is the host on?"
Tools
Tool | What it does |
| Every VM and container with id, name, node, status, usage |
| Runtime detail for one guest |
| Lifecycle. |
| Snapshot create, list and revert |
| Host health: cpu, memory, load, uptime |
| Task history, and polling the UPIDs the write tools return |
| Escape hatch to any other API endpoint, with guardrails |
| Bash on the host, in an LXC, or in a VM. Only registered once |
There is deliberately no tool for deleting a guest, a snapshot or a storage
volume. pve_api refuses DELETE outright, so destructive cleanup stays a
thing you do by hand in the Proxmox UI.
Prerequisites
Set these up before deploying:
A Cloudflare Tunnel with a public hostname pointing at
https://localhost:8006, withnoTLSVerify: true— pveproxy uses a self-signed cert, so the tunnel must not try to validate it.A Cloudflare Access application on that hostname with a service token only policy, and a service token issued for it.
A Proxmox API token (Datacenter → Permissions → API Tokens).
PVEVMAdminon/vmsplusPVEAuditoron/covers everything these tools do without letting the token touch users or realms. Copy the secret when it is shown — Proxmox displays it exactly once.
Phase 2 needs a second tunnel hostname pointing at http://localhost:5000,
with its own Access application and service token.
Deploy
This repo is wired for Cloudflare's git integration — pushing to main builds
and deploys automatically.
Create the KV namespace and put its id in
wrangler.toml:npx wrangler kv namespace create PROXMOX_KVDo this before connecting the repo; a placeholder id fails the build. The namespace does one job — stopping an OAuth authorization code from being redeemed twice. It holds nothing else, and entries expire after 5 minutes.
In the Cloudflare dashboard: Workers & Pages → Create → Workers → Import a repository. Defaults are correct: build
npm install, deploynpx wrangler deploy.Set the secrets (dashboard Settings → Variables and Secrets, or CLI):
npx wrangler secret put MCP_API_KEY # password you'll type when connecting npx wrangler secret put PVE_HOST # https://your-tunnel-hostname (no trailing slash) npx wrangler secret put PVE_TOKEN # user@realm!tokenid=uuid npx wrangler secret put CF_ACCESS_CLIENT_ID npx wrangler secret put CF_ACCESS_CLIENT_SECRETSecrets survive a git-triggered redeploy, which is why nothing lives in
[vars]and no real hostname is committed anywhere in this repo.Check it came up:
curl https://proxmox-mcp-server.<subdomain>.workers.dev/healthYou want
"pve_configured": true. If a/healthcall returns HTML rather than JSON, Access rejected the service token.
Connect it to Claude
In claude.ai: Settings → Connectors → Add custom connector, URL
https://proxmox-mcp-server.<subdomain>.workers.dev/mcp. You will be sent to an
authorize page — enter the MCP_API_KEY value. Only claude.ai and
claude.com redirect URIs are accepted.
Phase 2 secrets
Add these once proxmox-mcp-bridge
is running. run_script does not appear in the tool list until EXEC_HOST exists,
so Phase 1 deploys cleanly on its own.
npx wrangler secret put EXEC_HOST # https://your-exec-hostname
npx wrangler secret put EXEC_CF_ACCESS_CLIENT_ID # service token for the exec hostname
npx wrangler secret put EXEC_CF_ACCESS_CLIENT_SECRET
npx wrangler secret put EXEC_SHARED_SECRET # must match the bridge
npx wrangler secret put EXEC_SUDO_PASSWORD # OS password of the bridge user
npx wrangler secret put EXEC_SUDO_ENABLED # "true" or "false"EXEC_SUDO_ENABLED is the kill switch. Set it to anything other than
"true" and the Worker stops sending the sudo password at all, so scripts
targeting host run as the unprivileged bridge user. Claude is never told the
password and it never appears in a tool result. Flipping it takes effect on the
next request — no redeploy, no push.
Configuration reference
Everything is a Worker secret — there are no [vars]. Secrets are the only
binding type that survives wrangler deploy, so a value set in the dashboard is
never silently reverted by an unrelated code push. EXEC_SUDO_ENABLED is a
secret for that reason alone, not because "true"/"false" is sensitive.
Set any of them with npx wrangler secret put <NAME>, or in the dashboard under
Settings → Variables and Secrets.
Phase 1 — required
Secret | What it is | Where it comes from |
| Password you type on the authorize page when adding the connector. Also the HMAC key for the bearer tokens the Worker issues. | You invent it. |
| Origin of the tunnel hostname in front of pveproxy | Your Cloudflare Tunnel |
| Full Proxmox API token, | Datacenter → Permissions → API Tokens |
| Access service token id, ends in | Zero Trust → Access → Service Auth |
| The secret half. Shown once. | ditto |
Phase 2 — optional
run_script is not registered at all unless EXEC_HOST is set. Setting it
without the rest means Claude sees the tool and every call fails, so set them
together.
Secret | What it is | Where it comes from |
| Origin of the tunnel hostname in front of the bridge | Your Cloudflare Tunnel |
| Service token for that hostname. Service tokens are account-level, so the Phase 1 pair may be reused — the token just has to be in that application's policy. | Zero Trust → Access → Service Auth |
| ditto | ditto |
| Second check behind Access. Must match the bridge. |
|
|
| You choose |
| OS password of the bridge user. Only read when | The password set by |
Bindings
Binding | Purpose |
| Marks OAuth authorization codes as spent so one cannot be redeemed twice. Entries expire after 5 minutes. Optional — without it the connector still works, you just lose replay protection. |
Checking what is set
GET /health reports configuration state without exposing any value:
{"status":"ok","version":"1.0.0","pve_configured":true,"exec_configured":true,"sudo_enabled":false}pve_configured means PVE_HOST and PVE_TOKEN are both present, not that
they are correct. exec_configured reflects EXEC_HOST alone — it is what
decides whether run_script appears.
Local development
npm install
cp .dev.vars.example .dev.vars # fill in real values; it is gitignored
npm run dev
npm run typecheckThe tunnel hostname is publicly reachable, so wrangler dev can talk to the
real Proxmox API. To call /mcp by hand you need a bearer token; mint one with
the same HMAC the Worker uses:
node -e 'const{createHmac,randomBytes}=require("crypto");const b=x=>x.toString("base64").replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"");const p=`${Date.now()}.${b(randomBytes(12))}`;console.log(`${p}.${b(createHmac("sha256",process.argv[1]).update(`token:${p}`).digest())}`)' "$MCP_API_KEY"curl -s localhost:8787/mcp -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Privilege model
Worth reading once rather than inferring, because the parts interact in ways that are easy to get wrong.
What the tool layer blocks
Blocked | Where |
|
|
Non-GET to |
|
Writes to |
|
What it deliberately does not block
POSTandPUTto everything else. These reach Proxmox and are gated by the API token's role, not by this Worker. If you widen that role, the Worker will not be what stops a destructive write.Reads of
/etc/pve. Therun_scriptguard is write-scoped.lsandcatpass — which is harmless, because the files inside areroot:www-data 0640and the unprivileged bridge user cannot read them anyway.Arbitrary host commands. The deny-list is a small blocklist of obviously destructive patterns. It does not reason about whether a command is harmful.
rm -f somefileon the host runs and is stopped only by filesystem permissions.
How privilege actually works
This is the part most often stated incorrectly. The bridge account is not free of sudo rights:
claude ALL=(root) NOPASSWD: /usr/sbin/pct exec *
claude ALL=(root) NOPASSWD: /usr/sbin/qm guest exec *Those are NOPASSWD, so they apply regardless of EXEC_SUDO_ENABLED —
which is exactly why run_script gets root inside any container while
/health reports sudo_enabled: false. And pct exec into a privileged
container is a well-known route back to root on the host.
EXEC_SUDO_ENABLED governs one narrow thing: whether the Worker attaches a
password, which only affects target: "host". It is not a global privilege
switch, and it does not gate container access.
If the host was installed with --with-host-sudo, the account is additionally
in the sudo group with a password — full root, password-gated.
Check what is actually granted rather than assuming:
sudo -l -U claudeThe real boundaries
In rough order of how much they carry:
Cloudflare Access on both tunnel hostnames — nothing reaches Proxmox without the service token.
The PVE API token's role — the ceiling on every API tool, and the only thing gating non-DELETE writes.
The bridge user's sudoers file — the ceiling on
run_script. Broad by design, sincepct execis the whole point.The bearer token on
/mcp, HMAC-derived fromMCP_API_KEY.EXEC_SUDO_ENABLED— host-sudo only, per the caveat above.
Everything in "what the tool layer blocks" is a speed bump, not a boundary. Those are pattern matches; a determined encoding walks through them. Set the API token's role and the sudoers file as though the guardrails did not exist.
Nothing sensitive is committed here. Credentials live in Worker secrets, and
local values in .dev.vars, which is gitignored — .dev.vars.example shows
the shape with placeholders.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Latest Blog Posts
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/Leander-Andersen/proxmox-mcp-server'
If you have feedback or need assistance with the MCP directory API, please join our Discord server