Skip to main content
Glama
README.md
<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

A4.4/5.0

Scored across 6 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count5/5

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.

Completeness3/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues