Opal Emu MCP
<p align="center">
<img src="public/logo.svg" alt="" width="88" height="88">
</p>
<h1 align="center">Opal Emu MCP</h1>
<p align="center"><em>Let LLM agents play retro games.</em></p>
An [MCP](https://modelcontextprotocol.io) server that lets LLM agents (Claude, GPT, etc.) autonomously play retro games, by wrapping [OpalEmu](https://opalemu.com), a browser-based, EmulatorJS-powered retro emulator supporting 18 systems, with a Node.js bridge.
This project does not modify OpalEmu. It drives a real, unmodified OpalEmu page in a Playwright-controlled browser and exposes it to agents as 6 MCP tools, plus (if the client supports it) a live, interactive [MCP Apps](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/#mcp-apps-server-rendered-ui) viewer.
## Credit
All emulation is [OpalEmu](https://opalemu.com) ([source](https://github.com/thevalmarch/opalemu)) running [EmulatorJS](https://emulatorjs.org) cores. This project only adds the MCP bridge around it; it contributes no emulation code of its own.
## License
AGPL-3.0, the same license as OpalEmu (see [LICENSE](LICENSE)).
## Architecture
```
LLM Agent (Claude, GPT, ...)
│ MCP protocol (stdio)
▼
Node.js MCP server (this project)
│ │
│ WebSocket │ HTML resource (MCP Apps, optional)
▼ ▼
Playwright-controlled Chromium MCP client's own sandboxed iframe
│ runs the real OpalEmu page (client/mcp-app.ts, a *separate*
│ (served by this project's browser context; talks back to this
│ own Express server) server's tools via the App Bridge,
▼ never touches the emulator directly)
window.EJS_emulator
(EmulatorJS instance, the game
actually runs here)
```
Two independent browser contexts are involved, and it's worth being explicit about why:
- **The Playwright tab** (`client/agent.ts` injected into it) is where the game actually runs. It drives `window.EJS_emulator` directly and is the only place OpalEmu's real, stateful emulator instance exists.
- **The MCP Apps viewer** (`client/mcp-app.ts`), if the connecting MCP client supports it, renders in the *client's own* sandboxed iframe, a completely different, unrelated browser context. It never touches the emulator directly; every button press or screenshot request goes through `App.callServerTool()`, which the host proxies to this server's real tools, the same tools the LLM calls. It's a control surface, not a second embed of the OpalEmu page (embedding the raw page there would boot a second, disconnected, unloaded emulator instance).
### Auto-pause
The emulator is paused whenever no tool call is in flight. Each tool resumes play for exactly as long as it needs (a couple of frames for a button press, an exact frame count for `skip_frames`, however long a fresh core download takes for `load_rom`), then pauses again before returning a screenshot. This is what lets an agent "think" between moves without the game running unattended and missing its own inputs.
## Tools
| Tool | Description |
|---|---|
| `list_roms` | Lists ROM files available on the server (from `--roms-dir`). |
| `load_rom` | Loads a ROM by `name` (from `list_roms`) or by `romBase64` + `fileName`. Returns the first screenshot once booted. |
| `reset_emulator` | Hard-resets the current game. |
| `get_current_screen` | Returns the current frame as a PNG, without changing emulator state. |
| `control_emulator` | Presses or releases a button (`a`, `b`, `up`, `start`, `l2`, etc.). Hold a direction across multiple `skip_frames` calls by sending `state: "down"` once and `state: "up"` later. |
| `skip_frames` | Advances by an exact number of core-internal frames (not wall-clock), for letting animations or held inputs play out. |
All tools except `list_roms` are also registered as [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) app tools, so a supporting client can render the live viewer regardless of which one is called first.
## Setup
**Prerequisites:**
- Node.js 20+
- A built checkout of [OpalEmu](https://github.com/thevalmarch/opalemu). By default this project looks for it as a sibling directory (`../OpalEmu/dist`); use `--opalemu-dist <path>` for any other layout. It only ever reads from there, never writes.
```bash
# 1. Build OpalEmu itself (the emulator this wraps)
git clone https://github.com/thevalmarch/opalemu ../OpalEmu
cd ../OpalEmu && npm install && npm run build && cd -
# 2. Build this project
npm install
npx playwright install chromium
npm run build
```
If OpalEmu's build isn't found, the server says so explicitly at startup, including the path it looked in.
### ROMs
**No ROMs are included, and none are downloaded.** You supply your own game files, which you should already legally own. Drop them into `roms/` (or point elsewhere with `--roms-dir <path>`) and `list_roms` will pick up anything with a recognized extension. Nothing in `roms/` is committed to git.
### Run standalone
```bash
npm start # headed browser by default, so you can watch it play
npm start -- --headless # for CI / headless environments
```
CLI flags (all optional): `--opalemu-dist <path>`, `--roms-dir <path>`, `--http-port <port>` (default 4173), `--ws-port <port>` (default 4174), `--headless`.
### Connect to Claude Desktop
Add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"opalemu": {
"command": "node",
"args": ["/absolute/path/to/opalemu-mcp/dist/index.js"]
}
}
}
```
Use absolute paths: Claude Desktop spawns MCP servers without a working directory set.
## Compatibility with OpalEmu
**Tested against OpalEmu v1.1.0** ([`776874a`](https://github.com/thevalmarch/opalemu/commit/776874a)).
OpalEmu and this project are separate repos with independent versions on purpose: different concerns, different release cadences. In practice, most of what this project depends on isn't OpalEmu-specific at all: button indices, frame counting, screenshot capture, and `gameManager.restart()` all come from **EmulatorJS's stable CDN bundle**, a third-party dependency OpalEmu itself just configures. OpalEmu releases mostly don't touch any of that.
The coupling that *is* real, and worth knowing about before bumping the sibling checkout:
- **`dist/index.html`'s structure.** `src/http/static.ts` injects `agent.js` with a literal `</body>` string-replace. Breaks if OpalEmu's build output changes shape.
- **The `drop`-event loading contract.** `load_rom` works by dispatching a synthetic drag-drop event that OpalEmu's `useDragDrop.ts` listens for on `window`. Breaks if OpalEmu changes how files get loaded (e.g. drops the drag-drop path in favor of file-input-only).
- **The extension→system list in `src/roms/store.ts`.** A ported *copy* of OpalEmu's own list, used only for `list_roms`'s display labels, never for actually loading a ROM (OpalEmu's own in-page detection is authoritative there). Drifts silently, not a breakage, if OpalEmu adds systems.
- **COOP/COEP headers.** Mirrored from OpalEmu's own vite/vercel config. Breaks threaded cores (n64, psx, etc.) if OpalEmu's requirements change.
When you update the OpalEmu checkout: rerun `npm run test:e2e` and `npm run test:mcp` against it (they boot a real OpalEmu build and exercise the full tool path), then bump the version line above if they pass.
## Development
```bash
npm run dev # run from source via tsx, no build step
npm run test:e2e # scripted check against a real ROM, no LLM/MCP client needed
npm run test:mcp # spawns the real server and talks real MCP stdio JSON-RPC to it
npm run test:mcp-app # verifies the MCP Apps ui:// resource is registered and well-formed
```
All three need a ROM in `roms/`; they use whichever one they find first, so any test file works. `test:mcp` and `test:mcp-app` build first (`npm run build`) since they spawn the compiled server.
Every script takes the same `--http-port` / `--ws-port` flags as the server. The two smoke tests already default to 4193/4194 so they don't collide with a running instance; pass the flags explicitly if you need something else:
```bash
npm run test:mcp -- --http-port 5000 --ws-port 5001
```
## Known limitations
- **Ambiguous disc formats.** `.bin`/`.iso` files that OpalEmu can't confidently identify (e.g. it can't tell PSX from Sega CD) normally prompt the user with a picker dialog. There's no automated path through that dialog here, so `load_rom` will time out on such a file. Unambiguous formats (cartridge-based systems, clearly-identified discs) are unaffected.
- **First load per system downloads a core.** EmulatorJS cores (5-30MB) download from `cdn.emulatorjs.org` on first use per system; `load_rom` accounts for this with a generous timeout, and a persistent browser profile (`.playwright-profile/`) means it only happens once.
- **One browser tab, one game at a time.** A second `load_rom` call replaces the current session; there's no multi-instance support.
TDQS
Scored across 6 tools
Each tool has a clearly distinct purpose: listing, loading, resetting, capturing, controlling, and advancing frames. There is no overlap or ambiguity between any pair of tools.
All tool names follow the verb_noun pattern (list_roms, load_rom, reset_emulator, get_current_screen, control_emulator, skip_frames). The style is completely consistent across the set.
6 tools is well-scoped for an emulator MCP server, providing the essential operations without bloat. The count aligns with the domain's core workflow.
The core emulator loop is covered: loading ROMs, controlling input, advancing frames, resetting, and capturing screens. However, save/load state functionality is absent, which is a common and useful feature for emulator workflows, though not strictly required for basic operation.