From 2e371b2709cf7a1c87109a0c87d75eb461270934 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 17 Jun 2026 06:37:41 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20v1.2.0=20=E2=80=94=20deploy,=20lifecycl?= =?UTF-8?q?e,=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New: proxmox_create_lxc, proxmox_create_qemu (deploy) - New: proxmox_start_vm, stop_vm, shutdown_vm, reboot_vm (lifecycle) - New: proxmox_delete_vm, proxmox_clone_vm - New: proxmox_list_storage, proxmox_list_templates (discovery) - New: proxmox_get_next_vmid, proxmox_get_lxc_config - Fix: LXC config parity with QEMU (get_lxc_config) - Bump: 1.0.0 → 1.2.0 (29 tools, up from 17) --- README.md | 105 +++++++++++---- pyproject.toml | 4 +- src/proxmox_mcp/__init__.py | 2 +- src/proxmox_mcp/tools/__init__.py | 3 +- src/proxmox_mcp/tools/guests.py | 208 +++++++++++++++++++++++++++++- src/proxmox_mcp/tools/nodes.py | 6 + src/proxmox_mcp/tools/storage.py | 21 +++ 7 files changed, 319 insertions(+), 30 deletions(-) create mode 100644 src/proxmox_mcp/tools/storage.py diff --git a/README.md b/README.md index 01f6585..0d74ff6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# proxmox-mcp +# proxmox-mcp v1.2.0 -MCP server for **Proxmox VE 9** — nodes, VMs, LXCs, status, exec. Sibling of `pfsense-mcp`. +MCP server for **Proxmox VE** — nodes, VMs, LXCs, status, exec, **deploy, lifecycle, and discovery**. Sibling of `pfsense-mcp`. ## Quick start @@ -9,48 +9,103 @@ cd ~/Projects/proxmox-mcp python3 -m venv .venv && .venv/bin/pip install -e . ``` -### Cursor (`~/.cursor/mcp.json`) +### Hermes (`~/.hermes/config.yaml`) -```json -"proxmox": { - "command": "/Users/nearxos/Projects/proxmox-mcp/.venv/bin/proxmox-mcp", - "env": { - "PROXMOX_VERIFY_SSL": "false", - "PROXMOX_ALLOW_WRITES": "false", - "PROXMOX_TARGETS_PATH": "/Users/nearxos/.local/share/proxmox-mcp/targets.json" - } -} +```yaml +mcp_servers: + proxmox: + command: /usr/local/lib/hermes-agent/venv/bin/proxmox-mcp + timeout: 120 + enabled: true + env: + PROXMOX_VERIFY_SSL: 'false' + PROXMOX_ALLOW_WRITES: 'true' ``` ### Connect -```text -proxmox_connect( - url="https://10.20.30.254:8006", - token_id="root@pam!cursor", - token_secret="...", - name="golden-proxmox" -) +``` +proxmox_connect(url="https://10.77.30.5:8006", token_id="root@pam!tokenname", token_secret="...", name="dell") proxmox_test_connection() -proxmox_list_qemu(node="pve") +proxmox_list_qemu(node="dell") ``` -## Tools +## Tools (30 total) +### Connection & Discovery | Tool | Description | |------|-------------| | `proxmox_connect` / `proxmox_use_target` | Multi-target registry (persisted) | +| `proxmox_list_targets` / `proxmox_disconnect_target` | Target management | +| `proxmox_get_config` | Server config and active target | | `proxmox_test_connection` | Version + nodes | + +### Nodes & Cluster +| Tool | Description | +|------|-------------| | `proxmox_list_nodes` | Cluster nodes | +| `proxmox_get_node_status` | Node resource status | +| `proxmox_get_next_vmid` | Next available VMID from cluster | + +### Storage & Templates +| Tool | Description | +|------|-------------| +| `proxmox_list_storage` | Storage pools with usage stats | +| `proxmox_list_templates` | Templates and ISOs on a storage pool | + +### Guests — Read +| Tool | Description | +|------|-------------| | `proxmox_list_qemu` / `proxmox_list_lxc` | Guest inventory | | `proxmox_get_qemu_status` / `proxmox_get_lxc_status` | Live status | -| `proxmox_get_qemu_config` | VM config | -| `proxmox_lxc_exec` / `proxmox_qemu_agent_exec` | Guest exec (writes) | -| `proxmox_get_task_log` | Async task output | +| `proxmox_get_qemu_config` / `proxmox_get_lxc_config` | Full config | + +### Guests — Lifecycle (requires `PROXMOX_ALLOW_WRITES=true`) +| Tool | Description | +|------|-------------| +| `proxmox_start_vm` | Start a stopped VM/LXC | +| `proxmox_stop_vm` | Force stop a VM/LXC | +| `proxmox_shutdown_vm` | Graceful ACPI shutdown | +| `proxmox_reboot_vm` | Reboot a VM/LXC | +| `proxmox_lxc_exec` | Execute command inside an LXC | +| `proxmox_qemu_agent_exec` | Execute via QEMU guest agent | + +### Guests — Deploy (requires `PROXMOX_ALLOW_WRITES=true`) +| Tool | Description | +|------|-------------| +| `proxmox_create_lxc` | Create a new LXC container | +| `proxmox_create_qemu` | Create a new QEMU VM | +| `proxmox_delete_vm` | Delete a VM/LXC (purge option) | +| `proxmox_clone_vm` | Clone a VM/LXC to new VMID | + +### Tasks +| Tool | Description | +|------|-------------| +| `proxmox_get_task_status` | Async task status | +| `proxmox_get_task_log` | Async task log output | ## Golden target -Saved as `golden-proxmox` in `~/.local/share/proxmox-mcp/targets.json` (add API token locally). +Saved as `dell` in `~/.local/share/proxmox-mcp/targets.json` — Dell Proxmox at `10.77.30.5:8006`. + +## Safety + +- `PROXMOX_ALLOW_WRITES=false` by default +- All lifecycle/deploy tools require writes enabled +- Delete requires explicit `purge=true` to destroy data +- Token secrets stored with `chmod 600` in targets.json + +## Changelog + +### v1.2.0 +- **New:** LXC create, QEMU create, delete, clone +- **New:** Start, stop, shutdown, reboot lifecycle tools +- **New:** Storage listing, template/ISO discovery +- **New:** `proxmox_get_next_vmid`, `proxmox_get_lxc_config` +- **Fix:** Config parity — LXC config now matches QEMU + +### v1.0.0 +- Initial release — read-only Proxmox API + exec ## License diff --git a/pyproject.toml b/pyproject.toml index 94434eb..82a6df2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "proxmox-mcp" -version = "1.0.0" -description = "Model Context Protocol server for Proxmox VE 9 management" +version = "1.2.0" +description = "Model Context Protocol server for Proxmox VE management — deploy, manage, and monitor VMs and LXCs" readme = "README.md" requires-python = ">=3.10" license = "MIT" diff --git a/src/proxmox_mcp/__init__.py b/src/proxmox_mcp/__init__.py index 5becc17..c68196d 100644 --- a/src/proxmox_mcp/__init__.py +++ b/src/proxmox_mcp/__init__.py @@ -1 +1 @@ -__version__ = "1.0.0" +__version__ = "1.2.0" diff --git a/src/proxmox_mcp/tools/__init__.py b/src/proxmox_mcp/tools/__init__.py index 14628e1..2a978ef 100644 --- a/src/proxmox_mcp/tools/__init__.py +++ b/src/proxmox_mcp/tools/__init__.py @@ -1,10 +1,11 @@ from mcp.server.fastmcp import FastMCP -from proxmox_mcp.tools import guests, nodes, targets, tasks +from proxmox_mcp.tools import guests, nodes, storage, targets, tasks def register_all(mcp: FastMCP) -> None: targets.register(mcp) nodes.register(mcp) guests.register(mcp) + storage.register(mcp) tasks.register(mcp) diff --git a/src/proxmox_mcp/tools/guests.py b/src/proxmox_mcp/tools/guests.py index 120655f..44880c8 100644 --- a/src/proxmox_mcp/tools/guests.py +++ b/src/proxmox_mcp/tools/guests.py @@ -4,7 +4,7 @@ import json from mcp.server.fastmcp import FastMCP -from proxmox_mcp.client import get_client +from proxmox_mcp.client import ProxmoxAPIError, get_client from proxmox_mcp.guards import GuardError, require_writes @@ -36,6 +36,60 @@ def register(mcp: FastMCP) -> None: client = get_client(target=target) return json.dumps(client.get(f"nodes/{node}/qemu/{vmid}/config"), indent=2, default=str) + @mcp.tool() + def proxmox_get_lxc_config(node: str, vmid: int, target: str = "") -> str: + """Get LXC container configuration.""" + client = get_client(target=target) + return json.dumps(client.get(f"nodes/{node}/lxc/{vmid}/config"), indent=2, default=str) + + # ── Lifecycle tools ────────────────────────────────────────── + + @mcp.tool() + def proxmox_start_vm(node: str, vmid: int, target: str = "") -> str: + """Start a stopped VM or LXC container.""" + require_writes() + client = get_client(target=target) + try: + result = client.post(f"nodes/{node}/qemu/{vmid}/status/start") + except ProxmoxAPIError: + result = client.post(f"nodes/{node}/lxc/{vmid}/status/start") + return json.dumps(result, indent=2, default=str) + + @mcp.tool() + def proxmox_stop_vm(node: str, vmid: int, target: str = "") -> str: + """Stop a VM or LXC container (immediate stop).""" + require_writes() + client = get_client(target=target) + try: + result = client.post(f"nodes/{node}/qemu/{vmid}/status/stop") + except ProxmoxAPIError: + result = client.post(f"nodes/{node}/lxc/{vmid}/status/stop") + return json.dumps(result, indent=2, default=str) + + @mcp.tool() + def proxmox_shutdown_vm(node: str, vmid: int, timeout: int = 60, target: str = "") -> str: + """Gracefully shutdown a VM or LXC (ACPI shutdown). Timeout in seconds.""" + require_writes() + client = get_client(target=target) + try: + result = client.post(f"nodes/{node}/qemu/{vmid}/status/shutdown", data={"timeout": timeout}) + except ProxmoxAPIError: + result = client.post(f"nodes/{node}/lxc/{vmid}/status/shutdown", data={"timeout": timeout}) + return json.dumps(result, indent=2, default=str) + + @mcp.tool() + def proxmox_reboot_vm(node: str, vmid: int, target: str = "") -> str: + """Reboot a VM or LXC container.""" + require_writes() + client = get_client(target=target) + try: + result = client.post(f"nodes/{node}/qemu/{vmid}/status/reboot") + except ProxmoxAPIError: + result = client.post(f"nodes/{node}/lxc/{vmid}/status/reboot") + return json.dumps(result, indent=2, default=str) + + # ── Guest exec tools ───────────────────────────────────────── + @mcp.tool() def proxmox_lxc_exec( node: str, @@ -67,3 +121,155 @@ def register(mcp: FastMCP) -> None: payload = {"command": [command] + (args or [])} result = client.post(f"nodes/{node}/qemu/{vmid}/agent/exec", data={"command": json.dumps(payload["command"])}) return json.dumps(result, indent=2, default=str) + + # ── Create tools ───────────────────────────────────────────── + + @mcp.tool() + def proxmox_create_lxc( + node: str, + vmid: int, + ostemplate: str, + storage: str, + hostname: str, + password: str = "", + cores: int = 1, + memory: int = 512, + swap: int = 512, + disk: int = 8, + net0: str = "", + unprivileged: bool = True, + features: str = "", + start: bool = False, + target: str = "", + ) -> str: + """Create a new LXC container. + + ostemplate: e.g. 'local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst' + net0: e.g. 'name=eth0,bridge=vmbr0,ip=10.77.30.100/24,gw=10.77.30.1' + features: e.g. 'nesting=1' + """ + require_writes() + client = get_client(target=target) + data: dict = { + "vmid": vmid, + "ostemplate": ostemplate, + "storage": storage, + "hostname": hostname, + "cores": cores, + "memory": memory, + "swap": swap, + "rootfs": f"{storage}:{disk}", + "unprivileged": int(unprivileged), + "start": int(start), + } + if password: + data["password"] = password + if net0: + data["net0"] = net0 + if features: + data["features"] = features + result = client.post(f"nodes/{node}/lxc", data=data) + return json.dumps(result, indent=2, default=str) + + @mcp.tool() + def proxmox_create_qemu( + node: str, + vmid: int, + name: str, + storage: str = "", + ostype: str = "l26", + cores: int = 1, + memory: int = 1024, + disk: int = 32, + net0: str = "", + scsihw: str = "virtio-scsi-single", + ide2: str = "", + start: bool = False, + target: str = "", + ) -> str: + """Create a new QEMU VM. + + ide2: e.g. 'local:iso/debian-12-netinst.iso,media=cdrom' + net0: e.g. 'virtio,bridge=vmbr0' + """ + require_writes() + client = get_client(target=target) + data: dict = { + "vmid": vmid, + "name": name, + "ostype": ostype, + "cores": cores, + "memory": memory, + "scsihw": scsihw, + "start": int(start), + } + if storage and disk: + data["scsi0"] = f"{storage}:{disk}" + if net0: + data["net0"] = net0 + if ide2: + data["ide2"] = ide2 + result = client.post(f"nodes/{node}/qemu", data=data) + return json.dumps(result, indent=2, default=str) + + # ── Delete / clone tools ───────────────────────────────────── + + @mcp.tool() + def proxmox_delete_vm( + node: str, + vmid: int, + purge: bool = False, + destroy_unreferenced: bool = False, + target: str = "", + ) -> str: + """Delete a VM or LXC container. + + Set purge=true to remove from disk (destroys all data). + Set destroy_unreferenced=true to also remove unreferenced disks. + """ + require_writes() + client = get_client(target=target) + # Determine type — try QEMU first + try: + client.get(f"nodes/{node}/qemu/{vmid}/status/current") + endpoint = f"nodes/{node}/qemu/{vmid}" + except ProxmoxAPIError: + endpoint = f"nodes/{node}/lxc/{vmid}" + + params = {} + if purge: + params["purge"] = 1 + if destroy_unreferenced: + params["destroy-unreferenced-disks"] = 1 + # Append query params to endpoint + if params: + qs = "&".join(f"{k}={v}" for k, v in params.items()) + endpoint = f"{endpoint}?{qs}" + result = client.delete(endpoint) + return json.dumps(result, indent=2, default=str) + + @mcp.tool() + def proxmox_clone_vm( + node: str, + vmid: int, + newid: int, + name: str = "", + storage: str = "", + target: str = "", + ) -> str: + """Clone a VM or LXC to a new VMID.""" + require_writes() + client = get_client(target=target) + try: + client.get(f"nodes/{node}/qemu/{vmid}/status/current") + endpoint = f"nodes/{node}/qemu/{vmid}/clone" + except ProxmoxAPIError: + endpoint = f"nodes/{node}/lxc/{vmid}/clone" + + data: dict = {"newid": newid} + if name: + data["name"] = name + if storage: + data["storage"] = storage + result = client.post(endpoint, data=data) + return json.dumps(result, indent=2, default=str) diff --git a/src/proxmox_mcp/tools/nodes.py b/src/proxmox_mcp/tools/nodes.py index 50aacd1..66b9a0a 100644 --- a/src/proxmox_mcp/tools/nodes.py +++ b/src/proxmox_mcp/tools/nodes.py @@ -33,3 +33,9 @@ def register(mcp: FastMCP) -> None: def proxmox_get_node_status(node: str, target: str = "") -> str: client = get_client(target=target) return json.dumps(client.get(f"nodes/{node}/status"), indent=2, default=str) + + @mcp.tool() + def proxmox_get_next_vmid(target: str = "") -> str: + """Get the next available VMID from the cluster.""" + client = get_client(target=target) + return json.dumps(client.get("cluster/nextid"), indent=2, default=str) diff --git a/src/proxmox_mcp/tools/storage.py b/src/proxmox_mcp/tools/storage.py new file mode 100644 index 0000000..4d4ba19 --- /dev/null +++ b/src/proxmox_mcp/tools/storage.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import json + +from mcp.server.fastmcp import FastMCP + +from proxmox_mcp.client import get_client + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def proxmox_list_storage(node: str, target: str = "") -> str: + """List storage pools on a node with usage stats.""" + client = get_client(target=target) + return json.dumps(client.get(f"nodes/{node}/storage"), indent=2, default=str) + + @mcp.tool() + def proxmox_list_templates(node: str, storage: str, target: str = "") -> str: + """List available templates and ISOs on a storage pool.""" + client = get_client(target=target) + return json.dumps(client.get(f"nodes/{node}/storage/{storage}/content"), indent=2, default=str)