jetkvm-mcp
by shvartzj1
README.md
# jetkvm-mcp
**Give an AI eyes and hands on a physical computer.**
jetkvm-mcp is an [MCP](https://modelcontextprotocol.io) server that turns a
[JetKVM](https://jetkvm.com) — a small open-source KVM-over-IP device — into a machine that
Claude (or any MCP client) can see and operate directly: watch the screen, type, click, mount
boot media, and control power. Because the JetKVM sits on the HDMI and USB ports, the AI drives
the computer **below the OS** — BIOS screens, bootloaders, installers, headless boxes with no
network, machines that are wedged. No agent, no SSH, nothing installed on the target.
```
you: "Screenshot the machine. It's stuck — what's wrong?"
claude: → screenshot → "It's sitting at a GRUB rescue prompt. The root partition
UUID changed. Want me to boot it manually?" → type_text → enter → fixed
```
Works against **stock JetKVM firmware** — no modifications to the device.
## The two planes
| Plane | Tools | Nature |
|-------|-------|--------|
| **Screen control** (eyes + hands) | `screenshot`, `click`, `double_click`, `move_mouse`, `type_text`, `press_key`, `keyboard_layout`, `scroll` | vision loop — the AI looks, then acts |
| **Device control** | `mount_media_url`, `mount_media_storage`, `upload_media`, `upload_and_mount`, `unmount_media`, `list_storage`, `delete_storage_file`, `storage_space`, `virtual_media_state`, `power`, `power_state`, `dc_power`, `wake_host`, `wol`, `usb_emulation`, `video_state`, `reboot_device` | deterministic RPC |
Full parameter reference: [docs/tools.md](docs/tools.md).
## How it works
One WebRTC peer connection to the device drives everything:
```
Claude ──MCP/stdio──▶ server.py (this repo, runs on your workstation)
│
└──WebRTC over LAN──▶ JetKVM ──HDMI-in / USB-HID-out──▶ target machine
├─ H.264 video track ─▶ decoded locally (PyAV) ─▶ JPEG screenshots
└─ "rpc" data channel ─▶ keyboard / mouse / media / power JSON-RPC
```
- The device already streams its HDMI capture as an H.264 video track — the client decodes it
locally and hands the AI JPEG snapshots on demand. (JetKVM has no snapshot endpoint; it
doesn't need one.)
- A reliable `rpc` data channel carries every JSON-RPC method the device's own web UI uses:
`keyboardReport`, `absMouseReport` (absolute 0–32767, drift-free), `mountWithHTTP`,
`setATXPowerAction`, and friends.
- The server connects lazily on the first tool call and keeps the one connection alive.
Deep dive — handshake, codec negotiation, the keyframe/PLI story, coordinate mapping:
[docs/architecture.md](docs/architecture.md).
## Requirements
- A JetKVM attached to the target machine, reachable on your network
- Python 3.11+ on the machine that runs Claude
- `aiortc`/`av` wheels bundle FFmpeg on macOS/Linux; if a build from source is triggered,
install FFmpeg dev libraries first (`brew install ffmpeg` / `apt install libavdevice-dev`)
## Quickstart
```bash
git clone https://github.com/shvartzj1/jetkvm-mcp.git
cd jetkvm-mcp
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # set JETKVM_URL (+ JETKVM_PASSWORD if your device has one)
```
Prove the pipeline before wiring it into anything — this connects, holds the stream open, and
saves four screenshots:
```bash
set -a; source .env; set +a
python smoke_test.py
```
Expected output — sustained ~60 fps, snapshots in single-digit milliseconds after the first:
```
connected. video_state: {'ready': True, 'width': 1280, 'height': 1024, 'fps': 60}
snapshot 0: 1280x1024 179578 bytes (grab 3129 ms) frames_seen=1
snapshot 1: 1280x1024 178280 bytes (grab 11 ms) frames_seen=128
...
```
## Wire it into Claude
**Claude Code** (one command, available in every session):
```bash
claude mcp add jetkvm --scope user \
--env JETKVM_URL=http://192.168.1.50 \
--env JETKVM_VERIFY_TLS=false \
-- /abs/path/jetkvm-mcp/.venv/bin/python /abs/path/jetkvm-mcp/server.py
```
**Claude Desktop** (`claude_desktop_config.json`):
```json
{
"mcpServers": {
"jetkvm": {
"command": "/abs/path/jetkvm-mcp/.venv/bin/python",
"args": ["/abs/path/jetkvm-mcp/server.py"],
"env": {
"JETKVM_URL": "http://192.168.1.50",
"JETKVM_PASSWORD": "",
"JETKVM_VERIFY_TLS": "false"
}
}
}
}
```
Then just talk to it: *"Screenshot the machine, open a terminal, and check disk usage."*
The AI calls `screenshot` → reasons → `click` / `type_text` → repeats.
## Docker
If you'd rather not set up a local Python environment, you can run the server in a container.
**Build the image:**
```bash
git clone https://github.com/shvartzj1/jetkvm-mcp.git
cd jetkvm-mcp
docker build -t jetkvm-mcp .
```
The build is multi-stage — the compiler and FFmpeg headers stay in the builder, the runtime
carries only the shared libraries — and the server runs as an unprivileged `appuser`.
**Run (one-shot):**
```bash
docker run --rm \
-e JETKVM_URL=http://192.168.1.50 \
-e JETKVM_PASSWORD="" \
-e JETKVM_VERIFY_TLS=false \
jetkvm-mcp
```
**Check connectivity** — connects, holds the session open, and grabs a few frames:
```bash
docker run --rm -e JETKVM_URL=http://192.168.1.50 -e JETKVM_PASSWORD="" jetkvm-mcp python smoke_test.py
```
> **Networking note:** The container only needs to reach the JetKVM outbound — the default
> bridge network is fine, including Docker Desktop on macOS/Windows, where the container sits
> behind the VM's NAT. That works because the client speaks the device's websocket signaling
> and picks up its trickled ICE candidates, so it can dial the device directly instead of
> waiting to be dialed. (On firmware too old for websocket signaling the client falls back to
> the legacy `POST /webrtc/session`, whose answer carries no candidates — that path needs the
> container to be reachable *from* the device, so it wants `--network host` on Linux or a
> non-containerized run.) VPN split-tunnelling can still get in the way.
**Claude Desktop** — Docker-based config (`claude_desktop_config.json`):
```json
{
"mcpServers": {
"jetkvm": {
"command": "docker",
"args": [
"run", "--rm", "-i",
"-e", "JETKVM_URL=http://192.168.1.50",
"-e", "JETKVM_PASSWORD=",
"-e", "JETKVM_VERIFY_TLS=false",
"jetkvm-mcp"
]
}
}
}
```
The `-i` flag keeps stdin open so Claude Desktop can speak MCP over stdio to the container.
Replace `http://192.168.1.50` with your device's address and set `JETKVM_PASSWORD` if your
device has one.
## The killer workflow: hands-free bare-metal provisioning
Device control and screen control compose into something no in-OS agent can do — installing an
operating system on an empty machine:
```
mount_media_url("https://mirror.lan/rocky-9.iso", "CDROM") # host the ISO yourself
power("reset") # reboot into the installer
# screenshot → click → type_text … the AI walks through the installer by sight
unmount_media()
```
The device's storage partition is ~14 GB on current hardware, so `upload_media` /
`upload_and_mount` (streamed from disk, resumable, ~11 MB/s over the device's 100 Mbps port)
can hold a full OS ISO and the install then runs from local flash. `mount_media_url` instead
streams the image over HTTP with range requests — for when you'd rather host it yourself or
the partition is full.
## Gotchas (read this before filing a bug)
- **First screenshot takes ~3 s; the rest are instant.** The device only emits an H.264
keyframe when asked via RTCP PLI. Browsers request keyframes automatically; aiortc does not —
so this client sends PLI on connect and whenever frames go stale (`_request_keyframe` in
`client.py`). Without that, decode fails on every packet forever
(`avcodec_send_packet: Invalid data`). If you're building your own client: this is the trap.
- **Keyboard layout — tell it what the target is using.** A USB keyboard sends key
*positions*; the character that appears is decided by the layout the **target OS** has
active. Mismatch it and there is no error, just the wrong character — on a German target
`z` arrives as `y`, `&` as `/`, `@` as `"`, which reads like a typo, not a bug. Set it:
```
keyboard_layout("de") # or JETKVM_KEYBOARD_LAYOUT=de in the server env
type_text("Get-ChildItem C:\\", layout="de") # or per call
```
Built in: `us` (default), `uk`, `de`, `fr` — with aliases (`German`, `en-GB`, `azerty`).
AltGr and the ISO 102nd key are handled, so `\ | { } [ ] @ €` are reachable on the
European layouts, and dead keys (`^ ´ ¨ ~`) get their trailing space automatically.
Anything the layout genuinely can't produce comes back in `type_text`'s return value
instead of being dropped silently. Adding a layout is a dozen lines in
`jetkvm/keymap.py` — only the keys that differ from US.
- **Coordinates**: `click`/`move_mouse` take pixel coordinates on the most recent
`screenshot`; the client maps them to the HID absolute range using the live frame
dimensions, so there is no drift.
- **`getVideoState` may report `streaming: 0`** even while frames flow at 60 fps — cosmetic
quirk, ignore it.
- **TLS**: stock firmware serves plain HTTP on the LAN. The device supports optional TLS
(Settings → Advanced) — enable it and set `JETKVM_URL=https://…`, plus
`JETKVM_VERIFY_TLS=true` if the cert is trusted. WebRTC media/control is DTLS/SRTP-encrypted
peer-to-peer regardless of how the signaling travelled.
- **`power` needs the ATX extension board** wired to the motherboard header; without it the
tool is a no-op (`power_state` reads `power: false`).
## Safety
This lets a language model drive a real computer with real consequences. Recommendations:
- Point it at a test box or lab machine first, not your production NAS.
- The destructive tools are `power`, `reboot_device`, `dc_power`, `mount_*`,
`delete_storage_file`, and any `press_key` of a reboot chord — consider requiring per-call
confirmation for them in your MCP client's permission settings.
- Set a device password (and TLS) if the JetKVM is reachable by anyone but you.
## Development
```
jetkvm/client.py WebRTC + JSON-RPC client (connect, snapshot, HID input, uploads)
jetkvm/keymap.py per-layout character / key-combo → USB HID usage codes
server.py FastMCP server exposing the 25 tools
smoke_test.py live end-to-end check against a real device
keymap_test.py offline check of the layout tables (no device needed)
signaling_test.py offline check of the signaling websocket's TLS handling
docs/ architecture + tool reference
```
Validated end-to-end against a JetKVM v2 on firmware/app **0.5.8** (Jul 2026): sustained
60 fps decode, keyboard input, HTTP CDROM mount/unmount, ATX/DC state reads — including driving
it from a live Claude session. The RPC surface is verified against the
[jetkvm/kvm](https://github.com/jetkvm/kvm) source.
### Ideas / roadmap
- More keyboard layouts (es, it, nordics, dvorak) — the table format is in `jetkvm/keymap.py`
- Gate destructive tools behind an env flag
- Native `getSnapshot` RPC upstream in the firmware would remove the H.264 decode dependency
entirely (see jetkvm/kvm#1459)
## License
[MIT](LICENSE). Not affiliated with JetKVM/Improve Robotics — this is an independent client
of the device's public API.
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessSlow