Content
# comet-kvm-mcp
MCP server for the **GL.iNet Comet (GL-RM1)** remote KVM. Gives an MCP client
(Claude Code, Claude Desktop, ...) console-level control of the machine attached
to the Comet: see its screen, type on its keyboard, move its mouse, and press
its power button.
The Comet runs GL.iNet's fork of PiKVM's `kvmd`, so it serves the PiKVM HTTP
API on HTTPS 443 (self-signed cert). This server wraps that API directly —
no VNC, no H.264 decoding; screenshots come from the JPEG snapshot endpoint.
```
MCP client ──stdio──► comet_kvm_mcp.py ──HTTPS──► Comet GL-RM1 ──HDMI/USB──► target machine
```
## Tools
| Tool | Kind | Does |
|---|---|---|
| `get_screenshot` | read | Current frame as JPEG; `region=[x,y,w,h]` fractions crops for readable small text (needs Pillow) |
| `get_state` | read | Compact signal/HID/ATX projection; `full=true` for the raw API dumps |
| `type_text` | write | Type literal text (`slow=true` for BIOS; keymap defaults to `COMET_KEYMAP`) |
| `send_shortcut` | write | Key combo by MDN keycodes, e.g. `["ControlLeft","KeyC"]`; `repeat=N` for menu navigation |
| `mouse_move` | write | Absolute move; coords 0..1 = screen fraction, >1 = pixels (prefer fractions) |
| `mouse_click` | write | left/right/middle, optional move-first, `clicks=2` = double, `modifiers=["ShiftLeft"]` |
| `mouse_drag` | write | Press-move-release: window moving, sliders, selection, drag & drop |
| `mouse_wheel` | write | Scroll; negative = down, ~3 text lines per unit |
| `power` | **destructive** | ATX `power` (short press), `power_long` (forced off), `reset` |
`power` (and everything else) is annotated with MCP tool annotations
(`destructiveHint` etc.) — configure your MCP host to require per-call approval
for it. Every tool call is appended to `logs/audit.jsonl` (tool, args, outcome,
duration). The device password is never logged, and text typed into the target
is redacted by default (length + hash) since it may contain the target's own
passwords — set `COMET_AUDIT_TEXT=full` to log it verbatim for debugging.
All HID traffic is serialized through one lock (the Comet emulates a single
keyboard/mouse), and a failed sequence best-effort releases any still-pressed
button or modifier so the target is not left mid-drag.
## Setup
Requirements: Python 3.10+, `pip install -r requirements.txt`
(mcp, httpx, websocket-client).
1. Put the Comet on an isolated management VLAN without WAN egress (it phones
home to STUN/NTP otherwise), give it a static IP / DHCP reservation, set the
admin password on first browse to `https://<ip>`.
2. **Run the probe** (also after every firmware update — updates can change
auth modes and which API routes the nginx proxies):
```
python probe.py --url https://<ip> --user admin --password <pw>
```
It verifies TLS, all three auth modes, dumps the state JSON to `probe_out/`,
saves a snapshot, and reports the working auth mode and mouse transport.
It never types/clicks/powers anything on the target.
3. Register the server. Claude Code (user scope, adjust paths):
```json
"comet-kvm": {
"type": "stdio",
"command": "C:\\...\\python.exe",
"args": ["D:\\...\\comet-kvm-mcp\\comet_kvm_mcp.py"],
"env": {
"COMET_URL": "https://<ip>",
"COMET_USER": "admin",
"COMET_PASS": "<pw>",
"COMET_AUTH": "auto",
"COMET_TLS_VERIFY": "false"
}
}
```
See `.mcp.example.json` for a project-scope variant and `.env.example` for
every variable. Real credentials belong in the client's env block only —
nothing in this repo.
## Configuration
A `.env` file next to the scripts is also loaded (KEY=VALUE lines; real
environment variables win) — handy for running the server or probe outside an
MCP client.
| Variable | Default | Meaning |
|---|---|---|
| `COMET_URL` | `https://glkvm.local` | Device base URL; prefer the IP (mDNS is VLAN-flaky) |
| `COMET_USER` | `admin` | kvmd username |
| `COMET_PASS` | — | kvmd password (required) |
| `COMET_AUTH` | `auto` | `auto` probes xheaders → basic → session and caches; or pin one |
| `COMET_KEYMAP` | — | Default `type_text` keymap for the target's layout, e.g. `de` |
| `COMET_TLS_VERIFY` | `false` | The Comet ships a self-signed cert |
| `COMET_TIMEOUT` | `10` | HTTP timeout, seconds |
| `COMET_AUDIT_LOG` | `logs/audit.jsonl` | Audit log path |
| `COMET_AUDIT_TEXT` | `redact` | `full` logs typed text verbatim in the audit log |
## Device facts (probed on firmware as of 2026-07)
- All three auth modes work: `X-KVMD-User`/`X-KVMD-Passwd` headers, HTTP Basic,
and `POST /api/auth/login` session token. `auto` re-probes once on 401/403,
so an auth change after a firmware update does not need a server restart.
- The HTTP HID event endpoints (`/api/hid/events/send_mouse_move` etc.) are
proxied on 443, so mouse input does not need the WebSocket. If a future
firmware stops proxying them, the server falls back to `/api/ws` per call.
- Responses use the kvmd `{"ok": ..., "result": ...}` envelope.
- Absolute mouse coordinates map the screen to -32768..32767 per axis
(center = 0,0); the server converts from fractions/pixels using the capture
resolution at `result.streamer.source.resolution`.
- **No HDMI signal quirk:** with no target signal, `/api/streamer/snapshot`
returns HTTP 200 `image/jpeg` whose body is actually a tiny H.264 fragment.
The server checks the JPEG magic bytes and reports "no video signal" instead.
- `?preview=1` on the snapshot endpoint 500s on this firmware — don't.
- Validated against a live Ubuntu target: real JPEG snapshots, pixel-accurate
absolute clicks, window dragging, wheel sign as documented (negative = down),
and the fork ships kvmd keymaps — with `keymap='de'`, German umlauts (äöüß)
type natively.
- Mouse/shortcut events fall back to the kvmd WebSocket if a firmware stops
proxying the HTTP HID routes; `type_text` cannot (character-to-key mapping
happens on the device in `/api/hid/print`), so it is HTTP-only.
- **ATX is an optional accessory.** Without it kvmd reports `atx.enabled=false`
and the click endpoint 500s with a plain-text body; the `power` tool
pre-checks the flag and reports this readably.
## Troubleshooting
- **Auth errors after a firmware update** → run `probe.py`; updates can change
the accepted auth modes and revert any on-device tweaks.
- **404/405 on API routes** → the firmware's nginx may have stopped proxying
kvmd on 443. Workaround per [gl-inet/glkvm#13](https://github.com/gl-inet/glkvm/issues/13):
enable the full PiKVM UI server block in `/etc/kvmd/nginx-kvmd.conf` (device
terminal), reboot, then point `COMET_URL` at `https://<ip>:8888`.
- **"no video signal"** → target off, asleep, or HDMI not connected; check
`get_state` → `streamer.hdmi.signal`.
- **Typed text arrives scrambled** (y/z swapped, wrong symbols) → the target OS
uses a non-US keyboard layout; pass `keymap` to `type_text`, e.g. `'de'`.
- **Pointer lands wrong in BIOS/UEFI** → some firmwares only accept relative
mice; switch the Comet's HID mouse mode in its UI.
- **Test without an LLM** (MCP Inspector; this uses pnpm — `pnpx`, not `npx`):
```
pnpx @modelcontextprotocol/inspector <python.exe> D:\...\comet_kvm_mcp.py
```
- **Unit tests** (no device needed — coordinate math, auth state machine,
audit redaction, stuck-button cleanup):
```
python -m pytest
```
## Security notes
This hands an AI agent keyboard/mouse/power access to a physical machine.
Isolate the Comet's VLAN, use a dedicated credential, require human approval
for `power`, and remember that `get_screenshot` pipes whatever is on the
target's screen into the model's context — prompt injection via the target's
display is a real vector, so keep a human in the loop on untrusted targets.
## License
MIT