proxmox-mcp-server
Enables Claude to manage a Proxmox VE host through natural language, including guest lifecycle, snapshots, host status monitoring, and shell execution via a companion bridge.
README
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 |
|---|---|
list_guests |
Every VM and container with id, name, node, status, usage |
guest_status |
Runtime detail for one guest |
start_guest / shutdown_guest / stop_guest |
Lifecycle. shutdown is graceful, stop is a hard power cut |
list_snapshots / snapshot_guest / rollback_snapshot |
Snapshot create, list and revert |
list_nodes / node_status |
Host health: cpu, memory, load, uptime |
list_tasks / task_status |
Task history, and polling the UPIDs the write tools return |
pve_api |
Escape hatch to any other API endpoint, with guardrails |
run_script |
Bash on the host, in an LXC, or in a VM. Only registered once EXEC_HOST is set |
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:
Do 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.npx wrangler kv namespace create PROXMOX_KV - 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):
Secrets survive a git-triggered redeploy, which is why nothing lives innpx 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_SECRET[vars]and no real hostname is committed anywhere in this repo. - Check it came up:
You wantcurl https://proxmox-mcp-server.<subdomain>.workers.dev/health"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 |
|---|---|---|
MCP_API_KEY |
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. openssl rand -hex 32 |
PVE_HOST |
Origin of the tunnel hostname in front of pveproxy :8006. No trailing slash, no /api2/json. |
Your Cloudflare Tunnel |
PVE_TOKEN |
Full Proxmox API token, user@realm!tokenid=uuid — all three parts, not just the secret. |
Datacenter → Permissions → API Tokens |
CF_ACCESS_CLIENT_ID |
Access service token id, ends in .access. |
Zero Trust → Access → Service Auth |
CF_ACCESS_CLIENT_SECRET |
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 |
|---|---|---|
EXEC_HOST |
Origin of the tunnel hostname in front of the bridge :5000. |
Your Cloudflare Tunnel |
EXEC_CF_ACCESS_CLIENT_ID |
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 |
EXEC_CF_ACCESS_CLIENT_SECRET |
ditto | ditto |
EXEC_SHARED_SECRET |
Second check behind Access. Must match the bridge. | cat /etc/claude-exec.env on the host |
EXEC_SUDO_ENABLED |
"true" lets target: "host" escalate with sudo. Any other value, or absent, disables it. |
You choose |
EXEC_SUDO_PASSWORD |
OS password of the bridge user. Only read when EXEC_SUDO_ENABLED is exactly "true"; never returned in a tool result. |
The password set by install.sh --with-host-sudo |
Bindings
| Binding | Purpose |
|---|---|
PROXMOX_KV |
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 typecheck
The 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 |
|---|---|
DELETE, every path |
pve_api — the only hard write-wall in the Worker |
Non-GET to /access/* and /cluster/config/* |
pve_api |
Writes to /etc/pve, rm -rf on system paths, mkfs, passwd, reboots |
run_script deny-list |
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 claude
The 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.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。