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..3bef7a9
--- /dev/null
+++ b/src/beaconmcp/proxmox/vm_panel.html
@@ -0,0 +1,355 @@
+
+
+
VM control panel
+
+
+
Loading…
+
+
+
+
+
+
+
+
+
+
+
+
+
CPU
+
+
+
+
+
Memory
+
+
+
+
+
Disk
+
+
+
+
+
Uptime
+
+
+
+
+
+
+
+
+
+
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..5975db0
--- /dev/null
+++ b/tests/test_mcp_apps_panel.py
@@ -0,0 +1,183 @@
+"""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"
+
+
+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 -------------------------------------------------------
+
+
+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)