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
+
+
+
+ +
+
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..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)