Proxmox MCP fixes: P1 snapshot tools, P4 error wrapping

This commit is contained in:
root
2026-07-04 20:15:58 +00:00
parent 2e371b2709
commit 4c62d830a7
2 changed files with 248 additions and 128 deletions

View File

@@ -12,81 +12,111 @@ def register(mcp: FastMCP) -> None:
@mcp.tool() @mcp.tool()
def proxmox_list_qemu(node: str, target: str = "") -> str: def proxmox_list_qemu(node: str, target: str = "") -> str:
"""List QEMU VMs on a node.""" """List QEMU VMs on a node."""
client = get_client(target=target) try:
return json.dumps(client.get(f"nodes/{node}/qemu"), indent=2, default=str) client = get_client(target=target)
return json.dumps(client.get(f"nodes/{node}/qemu"), indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool() @mcp.tool()
def proxmox_list_lxc(node: str, target: str = "") -> str: def proxmox_list_lxc(node: str, target: str = "") -> str:
"""List LXC containers on a node.""" """List LXC containers on a node."""
client = get_client(target=target) try:
return json.dumps(client.get(f"nodes/{node}/lxc"), indent=2, default=str) client = get_client(target=target)
return json.dumps(client.get(f"nodes/{node}/lxc"), indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool() @mcp.tool()
def proxmox_get_qemu_status(node: str, vmid: int, target: str = "") -> str: def proxmox_get_qemu_status(node: str, vmid: int, target: str = "") -> str:
client = get_client(target=target) try:
return json.dumps(client.get(f"nodes/{node}/qemu/{vmid}/status/current"), indent=2, default=str) client = get_client(target=target)
return json.dumps(client.get(f"nodes/{node}/qemu/{vmid}/status/current"), indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool() @mcp.tool()
def proxmox_get_lxc_status(node: str, vmid: int, target: str = "") -> str: def proxmox_get_lxc_status(node: str, vmid: int, target: str = "") -> str:
client = get_client(target=target) try:
return json.dumps(client.get(f"nodes/{node}/lxc/{vmid}/status/current"), indent=2, default=str) client = get_client(target=target)
return json.dumps(client.get(f"nodes/{node}/lxc/{vmid}/status/current"), indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool() @mcp.tool()
def proxmox_get_qemu_config(node: str, vmid: int, target: str = "") -> str: def proxmox_get_qemu_config(node: str, vmid: int, target: str = "") -> str:
client = get_client(target=target) try:
return json.dumps(client.get(f"nodes/{node}/qemu/{vmid}/config"), indent=2, default=str) client = get_client(target=target)
return json.dumps(client.get(f"nodes/{node}/qemu/{vmid}/config"), indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool() @mcp.tool()
def proxmox_get_lxc_config(node: str, vmid: int, target: str = "") -> str: def proxmox_get_lxc_config(node: str, vmid: int, target: str = "") -> str:
"""Get LXC container configuration.""" """Get LXC container configuration."""
client = get_client(target=target) try:
return json.dumps(client.get(f"nodes/{node}/lxc/{vmid}/config"), indent=2, default=str) client = get_client(target=target)
return json.dumps(client.get(f"nodes/{node}/lxc/{vmid}/config"), indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
# ── Lifecycle tools ────────────────────────────────────────── # ── Lifecycle tools ──────────────────────────────────────────
@mcp.tool() @mcp.tool()
def proxmox_start_vm(node: str, vmid: int, target: str = "") -> str: def proxmox_start_vm(node: str, vmid: int, target: str = "") -> str:
"""Start a stopped VM or LXC container.""" """Start a stopped VM or LXC container."""
require_writes()
client = get_client(target=target)
try: try:
result = client.post(f"nodes/{node}/qemu/{vmid}/status/start") require_writes()
except ProxmoxAPIError: client = get_client(target=target)
result = client.post(f"nodes/{node}/lxc/{vmid}/status/start") try:
return json.dumps(result, indent=2, default=str) 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)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool() @mcp.tool()
def proxmox_stop_vm(node: str, vmid: int, target: str = "") -> str: def proxmox_stop_vm(node: str, vmid: int, target: str = "") -> str:
"""Stop a VM or LXC container (immediate stop).""" """Stop a VM or LXC container (immediate stop)."""
require_writes()
client = get_client(target=target)
try: try:
result = client.post(f"nodes/{node}/qemu/{vmid}/status/stop") require_writes()
except ProxmoxAPIError: client = get_client(target=target)
result = client.post(f"nodes/{node}/lxc/{vmid}/status/stop") try:
return json.dumps(result, indent=2, default=str) 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)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool() @mcp.tool()
def proxmox_shutdown_vm(node: str, vmid: int, timeout: int = 60, target: str = "") -> str: 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.""" """Gracefully shutdown a VM or LXC (ACPI shutdown). Timeout in seconds."""
require_writes()
client = get_client(target=target)
try: try:
result = client.post(f"nodes/{node}/qemu/{vmid}/status/shutdown", data={"timeout": timeout}) require_writes()
except ProxmoxAPIError: client = get_client(target=target)
result = client.post(f"nodes/{node}/lxc/{vmid}/status/shutdown", data={"timeout": timeout}) try:
return json.dumps(result, indent=2, default=str) 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)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool() @mcp.tool()
def proxmox_reboot_vm(node: str, vmid: int, target: str = "") -> str: def proxmox_restart_vm(node: str, vmid: int, target: str = "") -> str:
"""Reboot a VM or LXC container.""" """Restart a VM or LXC container."""
require_writes()
client = get_client(target=target)
try: try:
result = client.post(f"nodes/{node}/qemu/{vmid}/status/reboot") require_writes()
except ProxmoxAPIError: client = get_client(target=target)
result = client.post(f"nodes/{node}/lxc/{vmid}/status/reboot") try:
return json.dumps(result, indent=2, default=str) 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)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
# ── Guest exec tools ───────────────────────────────────────── # ── Guest exec tools ─────────────────────────────────────────
@@ -99,13 +129,16 @@ def register(mcp: FastMCP) -> None:
target: str = "", target: str = "",
) -> str: ) -> str:
"""Execute a command inside an LXC (requires root@pam permissions).""" """Execute a command inside an LXC (requires root@pam permissions)."""
require_writes() try:
client = get_client(target=target) require_writes()
data: dict[str, str] = {"command": command} client = get_client(target=target)
if args.strip(): data: dict[str, str] = {"command": command}
data["extra-args"] = args if args.strip():
result = client.post(f"nodes/{node}/lxc/{vmid}/exec", data=data) data["extra-args"] = args
return json.dumps(result, indent=2, default=str) result = client.post(f"nodes/{node}/lxc/{vmid}/exec", data=data)
return json.dumps(result, indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool() @mcp.tool()
def proxmox_qemu_agent_exec( def proxmox_qemu_agent_exec(
@@ -116,11 +149,14 @@ def register(mcp: FastMCP) -> None:
target: str = "", target: str = "",
) -> str: ) -> str:
"""Execute via QEMU guest agent (VM must have agent enabled).""" """Execute via QEMU guest agent (VM must have agent enabled)."""
require_writes() try:
client = get_client(target=target) require_writes()
payload = {"command": [command] + (args or [])} client = get_client(target=target)
result = client.post(f"nodes/{node}/qemu/{vmid}/agent/exec", data={"command": json.dumps(payload["command"])}) payload = {"command": [command] + (args or [])}
return json.dumps(result, indent=2, default=str) result = client.post(f"nodes/{node}/qemu/{vmid}/agent/exec", data={"command": json.dumps(payload["command"])})
return json.dumps(result, indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
# ── Create tools ───────────────────────────────────────────── # ── Create tools ─────────────────────────────────────────────
@@ -148,28 +184,31 @@ def register(mcp: FastMCP) -> None:
net0: e.g. 'name=eth0,bridge=vmbr0,ip=10.77.30.100/24,gw=10.77.30.1' net0: e.g. 'name=eth0,bridge=vmbr0,ip=10.77.30.100/24,gw=10.77.30.1'
features: e.g. 'nesting=1' features: e.g. 'nesting=1'
""" """
require_writes() try:
client = get_client(target=target) require_writes()
data: dict = { client = get_client(target=target)
"vmid": vmid, data: dict = {
"ostemplate": ostemplate, "vmid": vmid,
"storage": storage, "ostemplate": ostemplate,
"hostname": hostname, "storage": storage,
"cores": cores, "hostname": hostname,
"memory": memory, "cores": cores,
"swap": swap, "memory": memory,
"rootfs": f"{storage}:{disk}", "swap": swap,
"unprivileged": int(unprivileged), "rootfs": f"{storage}:{disk}",
"start": int(start), "unprivileged": int(unprivileged),
} "start": int(start),
if password: }
data["password"] = password if password:
if net0: data["password"] = password
data["net0"] = net0 if net0:
if features: data["net0"] = net0
data["features"] = features if features:
result = client.post(f"nodes/{node}/lxc", data=data) data["features"] = features
return json.dumps(result, indent=2, default=str) result = client.post(f"nodes/{node}/lxc", data=data)
return json.dumps(result, indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool() @mcp.tool()
def proxmox_create_qemu( def proxmox_create_qemu(
@@ -192,25 +231,28 @@ def register(mcp: FastMCP) -> None:
ide2: e.g. 'local:iso/debian-12-netinst.iso,media=cdrom' ide2: e.g. 'local:iso/debian-12-netinst.iso,media=cdrom'
net0: e.g. 'virtio,bridge=vmbr0' net0: e.g. 'virtio,bridge=vmbr0'
""" """
require_writes() try:
client = get_client(target=target) require_writes()
data: dict = { client = get_client(target=target)
"vmid": vmid, data: dict = {
"name": name, "vmid": vmid,
"ostype": ostype, "name": name,
"cores": cores, "ostype": ostype,
"memory": memory, "cores": cores,
"scsihw": scsihw, "memory": memory,
"start": int(start), "scsihw": scsihw,
} "start": int(start),
if storage and disk: }
data["scsi0"] = f"{storage}:{disk}" if storage and disk:
if net0: data["scsi0"] = f"{storage}:{disk}"
data["net0"] = net0 if net0:
if ide2: data["net0"] = net0
data["ide2"] = ide2 if ide2:
result = client.post(f"nodes/{node}/qemu", data=data) data["ide2"] = ide2
return json.dumps(result, indent=2, default=str) result = client.post(f"nodes/{node}/qemu", data=data)
return json.dumps(result, indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
# ── Delete / clone tools ───────────────────────────────────── # ── Delete / clone tools ─────────────────────────────────────
@@ -227,26 +269,29 @@ def register(mcp: FastMCP) -> None:
Set purge=true to remove from disk (destroys all data). Set purge=true to remove from disk (destroys all data).
Set destroy_unreferenced=true to also remove unreferenced disks. Set destroy_unreferenced=true to also remove unreferenced disks.
""" """
require_writes()
client = get_client(target=target)
# Determine type — try QEMU first
try: try:
client.get(f"nodes/{node}/qemu/{vmid}/status/current") require_writes()
endpoint = f"nodes/{node}/qemu/{vmid}" client = get_client(target=target)
except ProxmoxAPIError: # Determine type — try QEMU first
endpoint = f"nodes/{node}/lxc/{vmid}" 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 = {} params = {}
if purge: if purge:
params["purge"] = 1 params["purge"] = 1
if destroy_unreferenced: if destroy_unreferenced:
params["destroy-unreferenced-disks"] = 1 params["destroy-unreferenced-disks"] = 1
# Append query params to endpoint # Append query params to endpoint
if params: if params:
qs = "&".join(f"{k}={v}" for k, v in params.items()) qs = "&".join(f"{k}={v}" for k, v in params.items())
endpoint = f"{endpoint}?{qs}" endpoint = f"{endpoint}?{qs}"
result = client.delete(endpoint) result = client.delete(endpoint)
return json.dumps(result, indent=2, default=str) return json.dumps(result, indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool() @mcp.tool()
def proxmox_clone_vm( def proxmox_clone_vm(
@@ -258,18 +303,84 @@ def register(mcp: FastMCP) -> None:
target: str = "", target: str = "",
) -> str: ) -> str:
"""Clone a VM or LXC to a new VMID.""" """Clone a VM or LXC to a new VMID."""
require_writes()
client = get_client(target=target)
try: try:
client.get(f"nodes/{node}/qemu/{vmid}/status/current") require_writes()
endpoint = f"nodes/{node}/qemu/{vmid}/clone" client = get_client(target=target)
except ProxmoxAPIError: try:
endpoint = f"nodes/{node}/lxc/{vmid}/clone" 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} data: dict = {"newid": newid}
if name: if name:
data["name"] = name data["name"] = name
if storage: if storage:
data["storage"] = storage data["storage"] = storage
result = client.post(endpoint, data=data) result = client.post(endpoint, data=data)
return json.dumps(result, indent=2, default=str) return json.dumps(result, indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
# ── Snapshot tools ───────────────────────────────────────────
@mcp.tool()
def proxmox_create_snapshot(
node: str,
vmid: int,
snapshot_name: str,
description: str = "",
target: str = "",
) -> str:
"""Create a VM snapshot. VM must be running or stopped."""
try:
require_writes()
client = get_client(target=target)
data: dict = {"snapname": snapshot_name}
if description:
data["description"] = description
result = client.post(f"nodes/{node}/qemu/{vmid}/snapshot", data=data)
return json.dumps(result, indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool()
def proxmox_list_snapshots(node: str, vmid: int, target: str = "") -> str:
"""List all snapshots for a VM."""
try:
client = get_client(target=target)
return json.dumps(client.get(f"nodes/{node}/qemu/{vmid}/snapshot"), indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool()
def proxmox_rollback_vm(
node: str,
vmid: int,
snapshot_name: str,
target: str = "",
) -> str:
"""Rollback a VM to a snapshot. VM must be stopped."""
try:
require_writes()
client = get_client(target=target)
result = client.post(f"nodes/{node}/qemu/{vmid}/snapshot/{snapshot_name}/rollback")
return json.dumps(result, indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool()
def proxmox_delete_snapshot(
node: str,
vmid: int,
snapshot_name: str,
target: str = "",
) -> str:
"""Delete a VM snapshot."""
try:
require_writes()
client = get_client(target=target)
result = client.delete(f"nodes/{node}/qemu/{vmid}/snapshot/{snapshot_name}")
return json.dumps(result, indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)

View File

@@ -26,16 +26,25 @@ def register(mcp: FastMCP) -> None:
@mcp.tool() @mcp.tool()
def proxmox_list_nodes(target: str = "") -> str: def proxmox_list_nodes(target: str = "") -> str:
client = get_client(target=target) try:
return json.dumps(client.get("nodes"), indent=2, default=str) client = get_client(target=target)
return json.dumps(client.get("nodes"), indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool() @mcp.tool()
def proxmox_get_node_status(node: str, target: str = "") -> str: def proxmox_get_node_status(node: str, target: str = "") -> str:
client = get_client(target=target) try:
return json.dumps(client.get(f"nodes/{node}/status"), indent=2, default=str) client = get_client(target=target)
return json.dumps(client.get(f"nodes/{node}/status"), indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)
@mcp.tool() @mcp.tool()
def proxmox_get_next_vmid(target: str = "") -> str: def proxmox_get_next_vmid(target: str = "") -> str:
"""Get the next available VMID from the cluster.""" """Get the next available VMID from the cluster."""
client = get_client(target=target) try:
return json.dumps(client.get("cluster/nextid"), indent=2, default=str) client = get_client(target=target)
return json.dumps(client.get("cluster/nextid"), indent=2, default=str)
except (GuardError, ProxmoxAPIError) as exc:
return json.dumps({"status": "error", "message": str(exc)}, indent=2)