Skip to main content
Glama
README.md
# mcp-server-misterfpga

An [MCP](https://modelcontextprotocol.io) server for [MiSTer FPGA](https://github.com/MiSTer-devel)
devices: find and launch games, see what's being played and what's been played most, take
screenshots, make NFC cards, change `MiSTer.ini` settings (globally or per core), back up saves,
and start Update All, from Claude Code or Claude Desktop. It talks to each MiSTer through
[Zaparoo Core](https://zaparoo.org)'s API and, for what Zaparoo doesn't cover, SSH. One server
drives several MiSTers.

A sibling of [mcp-server-harmony](https://github.com/SDNick484/mcp-server-harmony),
[mcp-server-sofabaton](https://github.com/SDNick484/mcp-server-sofabaton),
[mcp-server-webos](https://github.com/SDNick484/mcp-server-webos) and
[mcp-server-batocera](https://github.com/SDNick484/mcp-server-batocera), with the same
conventions, and sharing code with them through
[mcp-server-shared](https://github.com/SDNick484/mcp-server-shared).
[docs/DESIGN.md](docs/DESIGN.md) is the design, the research behind it, and the decisions.

**Status:** verified against the simulator only. [HARDWARE_VALIDATION.md](HARDWARE_VALIDATION.md)
is the checklist for a first session with real MiSTers.

## Set up your MiSTer

### 1. Zaparoo Core

Zaparoo isn't on a MiSTer by default. From Zaparoo's
[MiSTer page](https://zaparoo.org/docs/platforms/mister/):

1. Run **Update All**. During its countdown, press UP to open its settings, turn on **Zaparoo**
   under **Tools & Scripts**, choose **SAVE**, and let it run.
2. On the MiSTer, open **Scripts → zaparoo** and accept the prompt to start it as a service at
   boot.
3. From another machine, open `http://<mister-ip>:7497/app/`: that's Zaparoo's web UI. Let it
   build its media database (that's what `search_games` searches).
4. For accurate tracking of games launched from the MiSTer's own menu, Zaparoo asks for
   `recents=1` and `log_file_entry=1` in `[MiSTer]` of `MiSTer.ini`. Once SSH works (below),
   `set_ini` can do it; `doctor` tells you when they're off.

This server talks to Zaparoo without a key or pairing: on MiSTer, Zaparoo admits unauthenticated
clients from the LAN (`Z-LEGACY` below). Don't configure a Zaparoo API key for it: once any key is
configured, Zaparoo refuses every client without one, the Zaparoo phone app included.

### 2. SSH (for MiSTer.ini, save backups, attract-mode detection and the running core)

MiSTer's root password is `1`. Give it a key once, from the machine this server runs on:

```sh
ssh-keygen -t ed25519 -N "" -f ~/.ssh/mister_ed25519
ssh-copy-id -i ~/.ssh/mister_ed25519.pub root@<mister-ip>
```

SSH is optional per MiSTer; without it, `get_status` lists what's unavailable.

### 3. Tell the server about it

```sh
mcp-server-misterfpga check --host <mister-ip> --name "Family Room" --model "SuperStation One" \
    --ssh --ssh-key ~/.ssh/mister_ed25519
```

`check` confirms Zaparoo answers, shows the MiSTer's SSH host key fingerprint and **pins** it once
you say yes, tests the login, and saves the MiSTer to `config.json`. Name MiSTers by where they
are; `--model` says what each one is (a MiSTer Pi, a SuperStation One, a DE10-Nano), because
nothing on the network says so reliably. Then:

```sh
mcp-server-misterfpga doctor
```

## Install

```sh
git clone https://github.com/SDNick484/mcp-server-misterfpga.git && cd mcp-server-misterfpga
python -m venv .venv && . .venv/bin/activate && pip install -e .
```

Python 3.11 or newer. Every compiled dependency ships a musl wheel, so Alpine needs no compiler.

## Use it from Claude

```sh
claude mcp add misterfpga -- /path/to/.venv/bin/mcp-server-misterfpga     # Claude Code, stdio
```

For Claude Desktop, add it to `claude_desktop_config.json` under `mcpServers` with the same
command. This server is meant to stay on your LAN (see [Local only](#local-only-stdio-or-http-on-loopback)).

### Tools

| Tool | Does | Annotations |
| --- | --- | --- |
| `get_status` | each MiSTer: model, what's playing, running core, attract mode, library size, free space, capabilities and limitations | read-only |
| `list_systems` | systems each MiSTer can launch, with game counts and the core that runs each | read-only |
| `search_games` | find games by name, optionally in one system; each result has a `ref` | read-only |
| `play_history` | recently played, or most played by total time, from Zaparoo's own records | read-only |
| `get_ini` | `MiSTer.ini` (or an alternate) parsed into sections, as MiSTer reads it | read-only |
| `list_nfc` | NFC readers, card mappings, the last card scanned | read-only |
| `launch` | a game by `ref`, or a system's core with no game; confirmed running; refuses while anything runs | idempotent |
| `return_to_menu` | end what's running (unsaved progress is lost) | destructive |
| `screenshot` | what the MiSTer shows, as an image | — |
| `set_ini` | one `MiSTer.ini` key, global or per core, with a backup first; `dry_run` shows the diff | idempotent |
| `backup_saves` | saves and save states into a dated snapshot in your backup folder | idempotent |
| `run_update` | start Update All on the MiSTer's screen (it may reboot); needs `host` and `confirm` | destructive |
| `write_nfc_card` | write a game onto the card on the reader (overwrites the card) | destructive |
| `map_nfc_card` | make a card's UID launch a game, without writing to it | idempotent |

A **ref** is `<MiSTer>/<id>`, e.g. `Family Room/4812`: the same game on two MiSTers has two
refs, and the ref decides which TV it launches on. Actions return
`{"host", "outcome": "done" | "unchanged" | "dry_run", "detail", "warnings"}`; failures are tool
errors that say what to do ("Family Room is running Super Metroid. Call return_to_menu ...",
"Which MiSTer should open the core? Pass host: Office, Family Room.").

Resource `misterfpga://ini-keys`: every key `set_ini` accepts, with type and range, and the
refused ones with the reason. Prompt `make_nfc_cards`: one card at a time, for a list of games.

### What the model can't do

- **No paths, no ZapScript, no shell.** Games launch by `ref`; the server asks Zaparoo for that
  game's own launch script. A system launch is built from a validated system id. SSH runs five
  fixed commands, nothing else.
- **SFTP stays in its lane:** reads of `MiSTer*.ini` and `saves/`, `savestates/`; writes of
  `MiSTer*.ini` (with a backup); deletes of this server's own old INI backups only.
- **No Zaparoo settings, profiles, playtime limits, device power or Zaparoo updates:** the
  methods it may send are an allow-list, pinned by a test.
- **`set_ini` refuses** `main` (another Main binary), `bootcore`, `shared_folder` and `debug`,
  plus array keys (`player_1_controller`, ...); values are checked against MiSTer's own table.
- **Never guess a MiSTer.** An action that could mean either MiSTer is refused, naming both.
- **Never replace a running game.** `launch` refuses; ending a game is its own call.
- Rate limits per MiSTer: launches and returns to the menu (4, then 1 per 15 s), screenshots,
  INI writes, NFC, and Update All (once an hour).

### Dry run

`serve --dry-run` (or `MISTERFPGA_DRY_RUN=1`) reads the MiSTers but changes nothing: every action
returns `outcome: "dry_run"` with what it would do. `set_ini` and `backup_saves` also take a
per-call `dry_run`.

## config.json

`~/.config/mcp-server-misterfpga/config.json` (or `$MISTERFPGA_CONFIG_DIR`); `check` writes it.

```json
{
  "hosts": [
    {"host": "192.168.1.70", "name": "Office", "model": "MiSTer Pi",
     "ssh": {"key": "~/.ssh/mister_ed25519", "host_key": "ssh-ed25519 AAAA..."}},
    {"host": "192.168.1.71", "name": "Family Room", "model": "SuperStation One",
     "manufacturer": "Retro Remake"}
  ],
  "backup_dir": "/mnt/backups/mister-saves",
  "backup_keep": 30
}
```

- `ssh`: `key` (no passwords are stored), `host_key` (pinned by `check --ssh`; a host without one
  is never connected to), optional `user` (root) and `port` (22).
- `zaparoo_port`: 7497 unless you changed Zaparoo's `api_port`.
- `backup_dir` (or `$MISTERFPGA_BACKUP_DIR`) has **no default**: any folder this server can
  write to (a local disk, NFS, SMB, an OpenMediaVault share mounted into the LXC). Until it's set,
  `backup_saves` says how to set it.
- `MISTERFPGA_HOSTS` (comma-separated addresses) replaces the list, keeping names from the file.

## Backups

`backup_saves` reads `/media/fat/saves` and `/media/fat/savestates` over SFTP into
`<backup_dir>/<host>/<date_time>/`. Files unchanged since the last snapshot (same size and
modification time) are hard-linked to it, as `rsync --link-dest` does: every snapshot is
complete on its own, and only changes cost space. A snapshot is written as `.partial` and renamed
when complete. The newest `backup_keep` (30) are kept. The MiSTer is only read.

## Local only: stdio, or HTTP on loopback

**This server is meant to stay on your LAN.** Unlike its AV siblings (Onkyo, Shield TV, Harmony,
Sofabaton, webOS), it isn't published through a Cloudflare Tunnel, so it isn't reachable from
claude.ai or the Claude mobile app. That's on purpose: it can start an update that reboots a
MiSTer, and its SSH key is root on them.

There are two ways to run it:

- **stdio**, the usual way: Claude Code or Claude Desktop launches it on a machine on your LAN.
- **`serve --http`**: Streamable HTTP on `127.0.0.1:8716/misterfpga/mcp`, through the `remote.py`
  it shares with its siblings (Onkyo 8711 … webOS 8715). `deploy/alpine/` runs this as a service.
  The loopback bind keeps it inside that machine or container; nothing else on the LAN can reach it.

Because `serve --http` runs on `remote.py`, this repo keeps mcp-server-shared's `remote` module.
Batocera and Pixelcade exclude it, but they only ever speak stdio. Here, "LAN-only" means no
tunnel route, not no HTTP.

On any address other than loopback, `remote.py` refuses to start without Cloudflare Access, unless
you pass `--insecure-no-auth`. **Don't use that flag for this server.** It exists for short tests
on a trusted LAN, and here it would give anything that can reach the port reboot-capable,
root-over-SSH control of your MiSTers. Its Host and Origin checks block DNS-rebinding from a
browser, not a direct request.

`deploy/alpine/` installs it as an OpenRC service (in an Alpine LXC, for example), owned by its
own user, with the SSH key and `config.json` in `/var/lib/mcp-server-misterfpga` (0700):

```sh
sh deploy/alpine/install.sh        # prints the next steps: key, ssh-copy-id, check, doctor, start
```

## Logs

stderr only (stdout is the MCP stdio transport). Addresses and MACs are redacted unless
`--no-redact` (`MISTERFPGA_LOG_UNREDACTED=1`); keys and tokens always are. `-v` adds protocol
traffic, including SSH.

## Testing without a MiSTer

```sh
pip install -e ".[dev]" && pytest && ruff check . && ruff format --check . && mypy
mcp-server-misterfpga simulate                # two simulated MiSTers; prints what to run next, e.g.:
MISTERFPGA_CONFIG_DIR=~/.config/mcp-server-misterfpga-sim/config mcp-server-misterfpga call launch game="Office/101"
```

The simulator (`src/misterfpga_mcp/sim/`) runs, for each MiSTer, a fake Zaparoo Core speaking
JSON-RPC over a real WebSocket and a fake MiSTer SSH server (real SSH and SFTP, chrooted to a
folder standing in for the SD card), over one shared device state, so launching through Zaparoo
changes what `/tmp/CORENAME` says over SSH. The server's real clients connect to both. Two
MiSTers on 127.0.0.1 and 127.0.0.2: Office (a MiSTer Pi) and Family Room (a SuperStation One),
with *Super Metroid* on both on purpose.

`doctor --dump DIR` records a real MiSTer's read-only Zaparoo answers (redacted); put them in
`tests/fixtures/recorded/` and `test_recorded.py` replays them through the tools.

## Shared code

`src/misterfpga_mcp/remote.py`, `logsafe.py`, `tasks.py`, `ratelimit.py`, their tests and
`scripts/sync_shared.py` come from mcp-server-shared, pinned by `shared.lock`. Don't edit them
here: change them there, then `python scripts/sync_shared.py --update`. CI's `shared-drift` job
fails on a copy that differs.

## What has been confirmed on hardware

Every protocol detail this server relies on is in `src/misterfpga_mcp/assumptions.py`, with its
source and how confident that source is. `doctor` prints the unconfirmed ones;
HARDWARE_VALIDATION.md says which step confirms each.

| Assumption | Confidence | Status | Claim |
| --- | --- | --- | --- |
| `Z-ENDPOINT` | high | simulator-only | Zaparoo's API: JSON-RPC 2.0 over `ws://<host>:7497/api/v0.1`; `ping` → `pong` |
| `Z-LEGACY` | high | simulator-only | MiSTer admits unauthenticated LAN clients (no encryption by default) |
| `Z-LEGACY-CAPS` | high | simulator-only | such a client may launch, search, screenshot, use NFC; not device or update methods |
| `Z-RUN-WAITS` | high | simulator-only | `run` returns after the launch finishes, with categorized errors |
| `Z-STOP-MENU` | medium | simulator-only | `stop` returns an FPGA core to the menu |
| `Z-MEDIAID` | medium | simulator-only | `media.meta` by `mediaId` gives the launch script; ids last until a reindex |
| `Z-TRACKS-MENU` | medium | simulator-only | menu launches are tracked too (with `recents=1`, `log_file_entry=1`) |
| `Z-NOTIFY` | high | simulator-only | `media.started` / `media.stopped` payloads |
| `Z-HISTORY` | medium | simulator-only | play history includes menu launches |
| `Z-SCREENSHOT` | high | simulator-only | `screenshot` returns a base64 PNG and where it was saved |
| `Z-NFC` | medium | simulator-only | `readers.write` waits for a card; UIDs are normalized; scans show in `tokens` |
| `Z-SCRIPT` | low | simulator-only | `**mister.script:update_all.sh` runs Update All on screen |
| `M-INI-SECTIONS` | high | simulator-only | how MiSTer matches `[sections]` |
| `M-INI-KEYS` | high | simulator-only | the key table, from Main_MiSTer `cfg.cpp` |
| `M-INI-APPLY` | medium | simulator-only | INI changes apply at the next core load |
| `M-INI-RENAME` | low | simulator-only | write-then-rename works on the SD card over SFTP |
| `M-CMD` | high | simulator-only | `load_core` on `/dev/MiSTer_cmd` |
| `M-CORENAME` | high | simulator-only | `/tmp/CORENAME`, `MENU` in the menu |
| `M-PATHS` | medium | simulator-only | saves and save states locations |
| `M-SSH` | medium | simulator-only | root SSH with a key, and SFTP |
| `S-ACTIVE` | medium | simulator-only | attract mode ⇔ SAM's `loop_core` process |
| `S-HANDOFF` | medium | simulator-only | picking up a controller in attract mode reloads the game |
| `U-REBOOT` | high | simulator-only | Update All may reboot when it finishes |
| `H-SS1` | low | simulator-only | the SuperStation One behaves like any MiSTer here |

## License

MIT. Zaparoo Core, Main_MiSTer and mrext are GPL-3.0; this server uses their documented
protocols and cites facts read from their source (such as the INI key table), and copies none of
their code.