proxmox-ve-mcp
README.md
# proxmox-ve-mcp
An [MCP](https://modelcontextprotocol.io) server that exposes one or more
**Proxmox VE** hosts as tools an LLM client can call — inventory nodes, guests,
storage and network bridges, read live status, and create, clone, start, stop
and delete VMs and containers.
It speaks MCP over Streamable HTTP, so it runs as its own service on the
network rather than as a local subprocess of one client.
Built for [`universal-network-director`](https://github.com/anderson-jason573/universal-network-director),
a chat-driven multi-vendor network manager with a human approval gate on every
write — but it is a standalone MCP server and works with any MCP client.
> **Not affiliated with, endorsed by, or supported by Proxmox Server Solutions
> GmbH.** "Proxmox" and "Proxmox VE" are trademarks of their respective owners
> and are used here only to describe what this software talks to.
---
## Read this before you point it at production
**Twelve of the twenty-four tools change state, and this server does not ask
before executing them.** There is no confirmation and no dry-run. If a model
decides to call one, it happens.
| Tool | What it does | Risk |
|---|---|---|
| `write_set_vm_description` | Sets a guest's notes field | Cosmetic. Reversible. |
| `write_start_vm` | Powers a guest on | Low. |
| `write_shutdown_vm` | ACPI shutdown — the guest OS shuts itself down | **Takes a workload offline.** Clean. |
| `write_reboot_vm` | Clean guest restart | **Takes a workload offline** briefly. |
| `write_stop_vm` | Immediate power-off, like pulling the cord | **Takes a workload offline, uncleanly.** Risks filesystem damage. |
| `write_clone_vm` | Clones a guest to a new vmid | Consumes storage. Source untouched. |
| `write_create_vm_from_image` | Creates a VM from a staged disk image | Consumes storage and a vmid. |
| `write_create_vm_from_iso` | Creates a VM with an empty disk booting an installer ISO | Consumes storage and a vmid. |
| `write_download_image` | Pulls a disk image from a URL into `import` storage | Consumes storage and egress bandwidth. |
| `write_delete_image` | Deletes a staged image, ISO or template | **Destructive.** Refuses if a guest still has it attached. |
| `write_set_vm_nic_bridge` | Patches a guest NIC into a bridge, or unpatches it | **Can move a running guest onto the wrong segment** — or off the network. |
| `write_delete_vm` | Permanently deletes a guest **and its disks** | **Destructive and irreversible.** No snapshot, no undo. |
Three ways to handle that, in the order they actually help:
1. **Scope the Proxmox API token read-only.** This is the real control, and it
lives on Proxmox, not in this code. Give the token the built-in `PVEAuditor`
role at path `/` and every write tool fails at the API no matter what any
model decides. Do this unless you specifically intend the writes to work.
2. **Use the protected-guest denylist.** `config/protected-vms.json` lists
guests the write tools refuse to touch, checked locally *before* any backend
call — so it holds even if a human approves something by accident. **A
missing or unparseable file refuses every guest write** rather than silently
protecting nothing. See below.
3. **Gate the writes in your client.** Every state-changing tool is prefixed
`write_`. That prefix is a convention of this codebase precisely so a client
can match on it and route those calls through a human-approval step before
execution. This server deliberately doesn't do that itself — it has no user
to ask.
## The MCP endpoint has no authentication
This server exposes its tools to anyone who can reach its port. There is no
token, no client auth, no TLS on the MCP side.
`MCP_HOST` defaults to `127.0.0.1` for that reason. The container image sets
`0.0.0.0` because it has to, which means **publishing the container's port puts
an unauthenticated control plane for your hypervisors on that interface.** Keep
it on an internal network with the client, or terminate TLS and authentication
in front of it.
---
## Multi-host by design
Proxmox clusters share one API, but plenty of setups run several standalone
hosts on different subnets with no cluster between them. This server holds one
connection per host, keyed by a short freeform **label**, and every tool takes
that label to pick which host to talk to.
A host is defined by a pair of environment variables:
```bash
PROXMOX_SERVER1_URL=https://pve1.example.com:8006
PROXMOX_SERVER1_TOKEN='automation@pve!mcp=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
```
The `<LABEL>` in `PROXMOX_<LABEL>_URL`, lowercased, becomes the `host` value the
tools take (`server1` above). Add a third host by adding a third pair — no code
change. Name them for the site so the model and the logs read clearly.
The token is the whole `user@realm!tokenid=secret` string, shown **once** at
creation under *Datacenter → Permissions → API Tokens*. Auth is stateless: every
request carries an `Authorization: PVEAPIToken=...` header. There is no login
call and no CSRF token — that's the username/password *session* path, which this
deliberately does not use.
Set `PROXMOX_VERIFY_TLS=false` for hosts with self-signed certificates. It
defaults to on.
## The protected-guest denylist
`config/protected-vms.json` is mounted read-only into the container and holds
the guests write tools must never touch:
```json
{
"protected_vms": [
{
"host": "server1",
"vmid": 100,
"name": "example-mcp-host",
"reason": "EXAMPLE -- the VM this MCP server itself runs in"
}
]
}
```
`host` is the **label** from `list_hosts`, not the Proxmox node name. `reason`
is shown verbatim in the refusal, so write it for whoever hits it.
This file is meant to be tracked in git. It started life as an environment
variable in an untracked `.env`, which meant the protection did not survive a
fresh clone and an empty list looked exactly like a populated one. Now a missing
or unparseable file refuses **every** guest write; an empty list is allowed but
logs a loud warning at startup.
The entries shipped here are examples. **Replace them before pointing this at
anything you care about.**
---
## Running it
```bash
docker build -t proxmox-ve-mcp .
docker run --rm \
-e PROXMOX_SERVER1_URL=https://pve1.example.com:8006 \
-e PROXMOX_SERVER1_TOKEN='automation@pve!mcp=...' \
-e PROXMOX_VERIFY_TLS=false \
-v "$PWD/config/protected-vms.json:/app/config/protected-vms.json:ro" \
-p 127.0.0.1:8002:8002 \
proxmox-ve-mcp
```
Or point `pip install -r requirements.txt` at a virtualenv and run
`python server.py` directly.
| Variable | Default | Meaning |
|---|---|---|
| `PROXMOX_<LABEL>_URL` | — | API root of a host, e.g. `https://pve1.example.com:8006` |
| `PROXMOX_<LABEL>_TOKEN` | — | The full `user@realm!tokenid=secret` string |
| `PROXMOX_VERIFY_TLS` | `true` | Set `false` for self-signed certs (lab only) |
| `PROXMOX_PROTECTED_VMS` | unset | Escape hatch (`label:vmid,...`) that **adds** to the JSON denylist |
| `PROXMOX_PROTECTED_VMS_FILE` | `/app/config/protected-vms.json` | Denylist path |
| `MCP_HOST` | `127.0.0.1` | Bind address (the image sets `0.0.0.0`) |
| `MCP_PORT` | `8002` | Bind port |
## Tests
Standalone scripts, no pytest. Run them in the container so they have the
`PROXMOX_*` environment the client needs:
```bash
docker run --rm proxmox-ve-mcp python test_network_bridges.py
docker run --rm proxmox-ve-mcp python test_media_in_use.py
docker run --rm proxmox-ve-mcp python test_client.py
```
The offline sections use fabricated interface and guest lists and pass with no
hosts configured. The live sections read whatever your `PROXMOX_*` variables
point at, and **skip cleanly** when nothing is configured — point them at a real
host to exercise the one distinction that can't be faked: whether a bridge is
uplinked or isolated.
## Design notes
- **`/cluster/resources` is the inventory backbone.** One call returns every VM,
container, node and storage already tagged with its node, vmid and type. It
works on a standalone host too (it reports that one node), so this is used
instead of walking `/nodes` → `/nodes/{node}/qemu` per node.
- **`list_network_bridges` exists because a NIC on the wrong bridge is a guest
you can't reach.** It reports, per bridge, whether it has a member port (a way
off the box) or is an isolated segment — the distinction that decides whether
a new VM comes up reachable.
- **VLAN tags fail closed.** A `tag=` on a bridge that isn't `bridge_vlan_aware`
is accepted by Proxmox and then silently not carried — untagged traffic where
isolation was asked for. The write paths refuse that rather than warn, and
refuse if they can't read the bridge list to check.
- **Writes are asynchronous.** Most return a Proxmox UPID; poll it with
`get_task_status` rather than assuming completion.
- **Guest writes run behind one guard.** A single wrapper does the protected-VM
check and the vmid→node/kind resolution, so an individual tool cannot forget
the guard and cannot reach the backend for a protected guest.
## License
Apache-2.0. See [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues