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. A button press resumes it briefly; `skip_frames` runs until at least the requested number of core frames have advanced and reports the measured count. Frame polling can overshoot the target. The emulator pauses again before returning a screenshot, so it does not keep running while an agent is thinking.
## 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 last captured frame as a PNG, without advancing the game. |
| `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 until at least the requested number of core frames has elapsed, then reports the actual count. |
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 ci
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.
Local ROM files larger than 128 MiB are rejected before loading. Direct `romBase64` input is limited to 6 MiB decoded so its Base64 and JSON-RPC message fit below the MCP SDK's default 10 MiB stdio limit. Symlinks in the ROM directory are not listed or loaded. Large disc images may need a future streaming path; this release keeps ROM transfer bounded in memory.
### 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>`, `--playwright-profile-dir <path>`, `--http-port <port>` (default 4173), `--ws-port <port>` (default 4174), `--headless`. The browser profile defaults to `.playwright-profile/` in this checkout.
The HTTP and WebSocket listeners bind to `127.0.0.1`. The Playwright browser receives a fresh bridge token at startup. The token is not served by the HTTP page and is not intended for other clients. Do not expose these ports through a proxy or port forward.
### 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
Originally tested against OpalEmu commit [`776874a`](https://github.com/thevalmarch/opalemu/commit/776874a), whose package version was 1.1.0. The published [v1.1.0 tag](https://github.com/thevalmarch/opalemu/releases/tag/v1.1.0) is the following commit, `800755a`. The v1.1.0 hardening tests also passed with a clean local OpalEmu v1.2.3 checkout at `3df680cd11b40f4590fb64c879e14ea253d5a0c1`.
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 small local discovery list determines what `list_roms` and `load_rom({name})` can find. OpalEmu's in-page detection still chooses the actual system. When OpalEmu adds formats, local discovery must be reviewed too.
- **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
npm run test:security # network, bridge, and ROM safety regression tests
npm run test:browser # boots the local OpalEmu build and checks the authenticated browser bridge
```
`test:e2e` and `test:mcp` need a ROM in `roms/`; they use whichever one they find first. `test:mcp-app` checks the UI resource without a ROM. Build first with `npm run build` before running `test:mcp-app`, since it spawns the compiled server. `test:security` does not need a ROM or browser. `test:browser` needs a built OpalEmu checkout and Playwright Chromium, but no ROM.
The emulator and MCP smoke scripts accept the server's `--http-port` and `--ws-port` flags. The two MCP smoke tests default to 4193/4194 so they do not 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`, or `.chd` 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.
- **Stale browser profile data.** Cached assets in an older profile may prevent a game from booting after an OpalEmu or EmulatorJS update. Use `--playwright-profile-dir <new-path>` to try a fresh profile without deleting the old one.
- **MCP client deadlines.** A first `load_rom` may need up to 100 seconds, while some MCP clients default to a shorter tool-call deadline. Increase the client's timeout for cold core downloads if it supports that setting.
- **One browser tab, one game at a time.** A second `load_rom` call replaces the current session; there's no multi-instance support.
- **Frame targets are approximate.** `skip_frames` measures core frames after each display callback and may advance beyond the requested count. Its result states the actual count; it fails if the target is not reached before timeout.
- **EmulatorJS assets are controlled by OpalEmu.** The separate OpalEmu checkout currently references the floating `stable/data` CDN path. This project cannot pin its loader and cores without changing OpalEmu or rewriting its built assets. Review that upstream dependency before deployments that require reproducible emulator assets.
## Security
See [SECURITY.md](SECURITY.md) for private vulnerability reporting guidance. The local HTTP and WebSocket ports are intended only for the Playwright browser started by this process.
TDQS
Scored across 6 tools
Each tool has a clearly distinct purpose: listing ROMs, loading, resetting, capturing the current screen, sending controller input, and advancing frames. The three tools that return screenshots (get_current_screen, control_emulator, skip_frames) are differentiated by whether they advance the game, and the descriptions make these boundaries explicit.
All tool names follow a consistent snake_case verb_noun pattern: list_roms, load_rom, reset_emulator, get_current_screen, control_emulator, skip_frames. There are no mixed conventions or vague standalone verbs.
Six tools is well-scoped for an emulator control server. Each tool earns its place, covering discovery, loading, reset, screen capture, input, and frame advancement without redundancy.
The core emulation loop is covered, but save/load state operations are absent, meaning agents cannot preserve progress before risky actions or reset. An explicit stop/unload tool and system selection for problematic disc images are also missing, creating notable gaps.