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. 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

A4.4/5.0

Scored across 6 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count5/5

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.

Completeness4/5

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.

Maintenance

ActivitySlowing
ResponsivenessNo issues