From b0e93eb3cbbc1b203835d0972837e4778f67bd12 Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Wed, 29 Jul 2026 05:35:14 +0200 Subject: [PATCH 1/2] feat(proxmox): interactive VM panel via the MCP Apps extension proxmox_vm_panel carries _meta.ui.resourceUri pointing at a ui:// resource served as text/html;profile=mcp-app, which an Apps-capable client renders in a sandboxed iframe: live CPU/RAM/disk, start/stop/restart, and fields for core count and memory. Runs on mcp 1.x. The Apps class that wraps this lives in 2.0 and needs MCPServer, but the two knobs it sets -- meta= on the tool, mime_type= on the resource -- are already on FastMCP, so the panel does not wait on that migration. The panel holds no cluster access of its own. Its buttons issue ordinary tools/call requests for proxmox_vm_start / _stop / _restart / _config, so the client's approval prompt still stands in front of every action. Clients that skipped the extension ignore _meta.ui and get the same snapshot as data, which is why the tool returns the full state rather than a placeholder. Verified against a harness that speaks the host side of the protocol: handshake, initial render, power actions with refresh, config apply sending only changed keys, tool errors surfacing without wedging the controls, and the theme switch. --- docs/tools.md | 22 +- src/beaconmcp/proxmox/panel.py | 105 +++++++++ src/beaconmcp/proxmox/vm_panel.html | 335 ++++++++++++++++++++++++++++ src/beaconmcp/server.py | 2 + tests/test_mcp_apps_panel.py | 163 ++++++++++++++ 5 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 src/beaconmcp/proxmox/panel.py create mode 100644 src/beaconmcp/proxmox/vm_panel.html create mode 100644 tests/test_mcp_apps_panel.py diff --git a/docs/tools.md b/docs/tools.md index 87a3bd5..086cfdd 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -1,6 +1,6 @@ # MCP tools -44 tools across six modules. The infrastructure modules are only registered when the matching +45 tools across six modules. The infrastructure modules are only registered when the matching capability is configured, so an SSH-only deployment exposes the 2 SSH tools and nothing else from Proxmox or BMC. `security_end_session` is always registered, whatever the topology. @@ -50,6 +50,26 @@ tools below exist for when you already know what you're looking at. | `proxmox_backup_list` | List vzdump archives on a storage pool. | | `proxmox_backup_restore` | Restore from an archive. | +## Proxmox — interactive panel (1) + +| Tool | Description | +|------|-------------| +| `proxmox_vm_panel` | Control panel for one guest: live CPU/RAM/disk, power buttons, CPU and memory fields. | + +This one ships a UI. It uses the [MCP Apps extension](https://modelcontextprotocol.io/extensions/apps/overview) +(`io.modelcontextprotocol/ui`): the tool carries `_meta.ui.resourceUri` pointing at +`ui://beaconmcp/vm-panel.html`, a self-contained HTML document served as +`text/html;profile=mcp-app` that the client renders in a sandboxed iframe and talks to over +JSON-RPC on `postMessage`. + +The panel does not reach the cluster itself. Its buttons call the ordinary tools +(`proxmox_vm_start` / `_stop` / `_restart` / `_config`), so whatever approval your client applies +to a tool call applies here too — the panel is a nicer way to issue the call, not a way around +the prompt. + +Clients that did not negotiate the extension ignore `_meta.ui` and show the tool's return value, +which is the same VM snapshot as data. Nothing breaks; you just don't get the frame. + ## Proxmox — system and files (9) | Tool | Description | diff --git a/src/beaconmcp/proxmox/panel.py b/src/beaconmcp/proxmox/panel.py new file mode 100644 index 0000000..fee7fb7 --- /dev/null +++ b/src/beaconmcp/proxmox/panel.py @@ -0,0 +1,105 @@ +"""MCP Apps panel for a single VM/CT. + +The MCP Apps extension (`io.modelcontextprotocol/ui`) lets a tool carry a +reference to an interactive UI: `_meta.ui.resourceUri` points at a `ui://` +resource served as `text/html;profile=mcp-app`, which the host renders in a +sandboxed iframe and talks to over JSON-RPC on `postMessage`. + +The wire format is all this module needs, so it runs on mcp 1.x. The `Apps` +extension class that wraps it lives in mcp 2.0 and requires `MCPServer`; the +two knobs it sets -- `meta=` on the tool and `mime_type=` on the resource -- +are already on `FastMCP`. + +Hosts that did not negotiate Apps ignore `_meta.ui` and just show the tool's +return value, which is why `proxmox_vm_panel` returns the full snapshot as +data rather than a "see the panel" placeholder. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from .client import ProxmoxClient + +PANEL_URI = "ui://beaconmcp/vm-panel.html" +APP_MIME_TYPE = "text/html;profile=mcp-app" + +_PANEL_HTML = Path(__file__).parent / "vm_panel.html" + +_MB = 1048576 +_GB = 1073741824 + + +def _snapshot(client: ProxmoxClient, node: str, vmid: int) -> dict[str, Any]: + """Everything the panel renders, in one pass over both guest types.""" + for vm_type in ("qemu", "lxc"): + data = client.get(node, f"nodes/{node}/{vm_type}/{vmid}/status/current") + if isinstance(data, dict) and "error" in data: + if "does not exist" in str(data["error"]).lower(): + continue + return data + if not isinstance(data, dict) or not data.get("status"): + continue + + # QEMU reports disk=0 unless the guest agent is answering, so a zero + # here means "unknown", not "empty". LXC reports it for real. + disk_used = data.get("disk") or 0 + conf = client.get(node, f"nodes/{node}/{vm_type}/{vmid}/config") + if not isinstance(conf, dict) or "error" in conf: + conf = {} + + return { + "node": node, + "vmid": vmid, + "type": vm_type, + "name": data.get("name", ""), + "status": data.get("status"), + "cpu_pct": round(data.get("cpu", 0) * 100, 1), + "cpus": data.get("cpus"), + "mem_used_mb": round(data.get("mem", 0) / _MB), + "mem_max_mb": round(data.get("maxmem", 0) / _MB), + "disk_used_gb": round(disk_used / _GB, 1) if disk_used else None, + "disk_max_gb": round(data.get("maxdisk", 0) / _GB, 1), + "uptime_h": round(data.get("uptime", 0) / 3600, 1), + "cores": conf.get("cores"), + "memory_mb": conf.get("memory"), + } + + return {"error": f"VM/CT {vmid} not found on node '{node}'. Check the VMID and node name."} + + +def register_panel_tools(mcp: FastMCP, client: ProxmoxClient) -> None: + @mcp.tool( + meta={"ui": {"resourceUri": PANEL_URI, "visibility": ["model", "app"]}}, + ) + def proxmox_vm_panel(node: str, vmid: int) -> dict[str, Any]: + """Open an interactive control panel for one VM or container. + + Renders live CPU / RAM / disk state with buttons for start, stop and + restart, and fields to change the CPU core count and memory. Use this + instead of proxmox_vm_status when the user wants to *act* on a guest + rather than just read its numbers, or when they ask to "manage", + "control" or "open" a VM. + + The panel drives the ordinary tools (proxmox_vm_start / _stop / + _restart / _config), so every action it takes goes through the same + approval the client applies to any other tool call. + + Returns: {node, vmid, type, name, status, cpu_pct, cpus, mem_used_mb, + mem_max_mb, disk_used_gb, disk_max_gb, uptime_h, cores, memory_mb}. + ``disk_used_gb`` is null when the guest does not report it. + """ + return _snapshot(client, node, vmid) + + @mcp.resource( + PANEL_URI, + name="vm-panel", + title="VM control panel", + description="Interactive panel for a single Proxmox VM or container.", + mime_type=APP_MIME_TYPE, + ) + def vm_panel_app() -> str: + return _PANEL_HTML.read_text(encoding="utf-8") diff --git a/src/beaconmcp/proxmox/vm_panel.html b/src/beaconmcp/proxmox/vm_panel.html new file mode 100644 index 0000000..a300fee --- /dev/null +++ b/src/beaconmcp/proxmox/vm_panel.html @@ -0,0 +1,335 @@ + + +VM control panel + + +
Loading…
+ +
+
+
+

+
+
+ +
+ +
+
+
CPU
+
+
+
+
+
Memory
+
+
+
+
+
Disk
+
+
+
+
+
Uptime
+
+
+
+ +
+
Power
+
+ + + + +
+ +
+ +
Resources
+
+
+ + +
+
+ + +
+ +
+
+ + +
+ + diff --git a/src/beaconmcp/server.py b/src/beaconmcp/server.py index 0265178..4503cb0 100644 --- a/src/beaconmcp/server.py +++ b/src/beaconmcp/server.py @@ -15,6 +15,7 @@ from .proxmox.aggregators import register_aggregator_tools from .proxmox.client import ProxmoxClient from .proxmox.monitoring import register_monitoring_tools +from .proxmox.panel import register_panel_tools from .proxmox.system import register_system_tools from .proxmox.vms import register_vm_tools from .security.tools import register_security_tools @@ -281,6 +282,7 @@ def beaconmcp_context() -> str: if config.pve_nodes: register_monitoring_tools(mcp, proxmox_client) register_vm_tools(mcp, proxmox_client) + register_panel_tools(mcp, proxmox_client) register_system_tools(mcp, proxmox_client, ssh_client if config.ssh and config.ssh.hosts else None) # Aggregators ride on top of the Proxmox client and opportunistically # pull BMC facts when the registry is non-empty. diff --git a/tests/test_mcp_apps_panel.py b/tests/test_mcp_apps_panel.py new file mode 100644 index 0000000..d4bafac --- /dev/null +++ b/tests/test_mcp_apps_panel.py @@ -0,0 +1,163 @@ +"""Tests for the MCP Apps VM panel. + +The value here is the wire format: a host only renders the panel if the tool +carries `_meta.ui.resourceUri` and the resource comes back under the mcp-app +MIME type, so these assert what goes out on the wire rather than the Python +objects behind it. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +from mcp.server.fastmcp import FastMCP + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from beaconmcp.proxmox.panel import APP_MIME_TYPE, PANEL_URI, _snapshot, register_panel_tools + +_NOT_FOUND = {"error": "Configuration file 'nodes/pve1/qemu/100.conf' does not exist"} + +_QEMU_STATUS = { + "status": "running", + "name": "web-101", + "cpu": 0.1234, + "cpus": 4, + "mem": 2 * 1048576 * 1024, + "maxmem": 4 * 1048576 * 1024, + # QEMU reports 0 unless the guest agent answers. + "disk": 0, + "maxdisk": 32 * 1073741824, + "uptime": 7200, +} + + +class _Client: + """Minimal ProxmoxClient stand-in keyed on the path suffix.""" + + def __init__(self, responses: dict[str, Any]) -> None: + self.responses = responses + self.calls: list[str] = [] + + def get(self, node: str, path: str) -> Any: + self.calls.append(path) + for suffix, value in self.responses.items(): + if path.endswith(suffix): + return value + return _NOT_FOUND + + +def _server(client: Any) -> FastMCP: + mcp = FastMCP("test") + register_panel_tools(mcp, client) + return mcp + + +# --- wire format ------------------------------------------------------------ + + +async def test_tool_points_at_the_panel_resource() -> None: + tools = await _server(_Client({})).list_tools() + panel = next(t for t in tools if t.name == "proxmox_vm_panel") + assert panel.meta == {"ui": {"resourceUri": PANEL_URI, "visibility": ["model", "app"]}} + + +async def test_resource_is_served_as_an_mcp_app() -> None: + resources = await _server(_Client({})).list_resources() + panel = next(r for r in resources if str(r.uri) == PANEL_URI) + assert panel.mimeType == APP_MIME_TYPE + + +async def test_advertised_resource_uri_actually_resolves() -> None: + """A tool whose resourceUri 404s on resources/read renders as a blank frame.""" + mcp = _server(_Client({})) + tools = await mcp.list_tools() + uri = next(t for t in tools if t.name == "proxmox_vm_panel").meta["ui"]["resourceUri"] + + contents = list(await mcp.read_resource(uri)) + + assert contents, f"{uri} is advertised but reads back empty" + assert "ui/initialize" in contents[0].content, "panel HTML is missing the host handshake" + + +# --- snapshot mapping ------------------------------------------------------- + + +def test_qemu_disk_usage_is_null_not_zero() -> None: + """0 from QEMU means "no guest agent", which must not render as an empty bar.""" + client = _Client({"qemu/100/status/current": _QEMU_STATUS, "qemu/100/config": {"cores": 4, "memory": 4096}}) + + snap = _snapshot(client, "pve1", 100) + + assert snap["disk_used_gb"] is None + assert snap["disk_max_gb"] == 32.0 + + +def test_qemu_snapshot_maps_status_and_config() -> None: + client = _Client({"qemu/100/status/current": _QEMU_STATUS, "qemu/100/config": {"cores": 4, "memory": 4096}}) + + snap = _snapshot(client, "pve1", 100) + + assert snap == { + "node": "pve1", + "vmid": 100, + "type": "qemu", + "name": "web-101", + "status": "running", + "cpu_pct": 12.3, + "cpus": 4, + "mem_used_mb": 2048, + "mem_max_mb": 4096, + "disk_used_gb": None, + "disk_max_gb": 32.0, + "uptime_h": 2.0, + "cores": 4, + "memory_mb": 4096, + } + + +def test_falls_through_to_lxc_when_no_qemu_guest() -> None: + client = _Client({ + "lxc/200/status/current": { + "status": "running", "name": "ct", "cpu": 0, "cpus": 1, + "mem": 0, "maxmem": 536870912, + "disk": 5 * 1073741824, "maxdisk": 10 * 1073741824, "uptime": 0, + }, + "lxc/200/config": {"cores": 1, "memory": 512}, + }) + + snap = _snapshot(client, "pve1", 200) + + assert snap["type"] == "lxc" + assert snap["disk_used_gb"] == 5.0 + + +def test_missing_guest_reports_an_error() -> None: + snap = _snapshot(_Client({}), "pve1", 999) + + assert "not found" in snap["error"] + + +def test_unreadable_config_still_renders_live_state() -> None: + """A config read can fail on its own; the panel should degrade, not blank out.""" + client = _Client({ + "qemu/100/status/current": _QEMU_STATUS, + "qemu/100/config": {"error": "connection refused"}, + }) + + snap = _snapshot(client, "pve1", 100) + + assert snap["status"] == "running" + assert snap["cores"] is None + + +def test_transport_error_is_not_mistaken_for_a_missing_guest() -> None: + """Only a "does not exist" error means "try the other guest type".""" + client = _Client({"qemu/100/status/current": {"error": "connection refused"}}) + + snap = _snapshot(client, "pve1", 100) + + assert snap["error"] == "connection refused" + assert not any("lxc" in call for call in client.calls) From 9f9e987be1bd072331e7c8c524829108aa19503d Mon Sep 17 00:00:00 2001 From: Showdown76py Date: Wed, 29 Jul 2026 05:55:29 +0200 Subject: [PATCH 2/2] fix(panel): send ui/initialize params flat, as the host expects The handshake nested appCapabilities under a `capabilities` key and sent `appInfo` as `clientInfo`. The real shape, per the ext-apps App.connect() implementation, is flat: appInfo / appCapabilities / protocolVersion. A rejected handshake is silent -- the host simply does not reply. So the promise never settled, `ui/notifications/initialized` never went out, the host never delivered `ui/notifications/tool-result`, and the panel sat on "Loading..." with an empty frame and nothing in the console. That is what showed up in Claude: the host reported the widget as rendered while the iframe stayed blank. Also surface the failure instead of hanging on it. A handshake that goes unanswered for 5s now replaces the spinner with the reason, so the next protocol mismatch is one glance rather than an afternoon. The browser harness this was first tested against replied to any ui/initialize it received, which is why the bad shape passed. It now validates the params like a host does, and the new test pins the flat shape against the shipped HTML -- it fails on the old file. --- src/beaconmcp/proxmox/vm_panel.html | 28 ++++++++++++++++++++++++---- tests/test_mcp_apps_panel.py | 20 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/beaconmcp/proxmox/vm_panel.html b/src/beaconmcp/proxmox/vm_panel.html index a300fee..3bef7a9 100644 --- a/src/beaconmcp/proxmox/vm_panel.html +++ b/src/beaconmcp/proxmox/vm_panel.html @@ -70,7 +70,7 @@ margin-top: 12px; padding: 8px 10px; border-radius: 6px; font-size: 12px; border: 1px solid var(--line); } - .note[data-kind="error"] { color: var(--danger); border-color: currentColor; } + .note[data-kind="error"], #loading[data-kind="error"] { color: var(--danger); border-color: currentColor; } .note[data-kind="ok"] { color: var(--ok); border-color: currentColor; } [hidden] { display: none !important; } @@ -182,6 +182,14 @@

} } + function fail(err) { + const loading = $("loading"); + loading.hidden = false; + loading.textContent = err.message || String(err); + loading.dataset.kind = "error"; + reportSize(); + } + function setNote(text, kind) { const note = $("note"); note.textContent = text || ""; @@ -322,14 +330,26 @@

} }); + // Params are flat: appInfo / appCapabilities / protocolVersion. Nesting the + // capabilities or sending clientInfo instead of appInfo fails the host's + // schema check, and a rejected handshake is silent -- the host simply never + // answers, so the app never sends `initialized` and the host never delivers + // the tool result. See @modelcontextprotocol/ext-apps App.connect(). request("ui/initialize", { + appInfo: { name: "beaconmcp-vm-panel", version: "1.0.0" }, + appCapabilities: { availableDisplayModes: ["inline"] }, protocolVersion: PROTOCOL_VERSION, - capabilities: { appCapabilities: { availableDisplayModes: ["inline"] } }, - clientInfo: { name: "beaconmcp-vm-panel", version: "1.0.0" }, }).then((result) => { applyHostContext(result && result.hostContext); notify("ui/notifications/initialized", {}); reportSize(); - }); + }).catch(fail); + + // Without this, a handshake the host declines to answer leaves the panel on + // "Loading…" forever, which reads as "the server is slow" rather than "this + // app is broken". + setTimeout(() => { + if (!vm) fail(new Error("The host did not complete the ui/initialize handshake.")); + }, 5000); })(); diff --git a/tests/test_mcp_apps_panel.py b/tests/test_mcp_apps_panel.py index d4bafac..5975db0 100644 --- a/tests/test_mcp_apps_panel.py +++ b/tests/test_mcp_apps_panel.py @@ -82,6 +82,26 @@ async def test_advertised_resource_uri_actually_resolves() -> None: assert "ui/initialize" in contents[0].content, "panel HTML is missing the host handshake" +async def test_handshake_params_are_flat() -> None: + """ui/initialize takes appInfo / appCapabilities / protocolVersion, flat. + + Nesting them under `capabilities`, or sending `clientInfo` instead of + `appInfo`, fails the host's schema check -- and a rejected handshake is + silent: the host just never replies, so the app never sends `initialized` + and never receives the tool result. The panel then sits on "Loading..." + with nothing in the console to explain it. + """ + mcp = _server(_Client({})) + html = list(await mcp.read_resource(PANEL_URI))[0].content + params = html.split('request("ui/initialize", {', 1)[1].split("})", 1)[0] + + assert "appInfo:" in params + assert "appCapabilities:" in params + assert "protocolVersion:" in params + assert "clientInfo" not in params + assert "capabilities: {" not in params + + # --- snapshot mapping -------------------------------------------------------