Opal Emu MCP
An MCP server that lets LLM agents autonomously play retro games by driving a real OpalEmu emulator in a Playwright-controlled browser, exposing tools for loading ROMs, controlling buttons, and capturing screenshots.
README
<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 server that lets LLM agents (Claude, GPT, etc.) autonomously play retro games, by wrapping OpalEmu, 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 viewer.
Credit
All emulation is OpalEmu (source) running EmulatorJS 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).
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.tsinjected into it) is where the game actually runs. It driveswindow.EJS_emulatordirectly 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 throughApp.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 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. 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.
# 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
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:
{
"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).
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.tsinjectsagent.jswith a literal</body>string-replace. Breaks if OpalEmu's build output changes shape.- The
drop-event loading contract.load_romworks by dispatching a synthetic drag-drop event that OpalEmu'suseDragDrop.tslistens for onwindow. 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 forlist_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
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:
npm run test:mcp -- --http-port 5000 --ws-port 5001
Known limitations
- Ambiguous disc formats.
.bin/.isofiles 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, soload_romwill 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.orgon first use per system;load_romaccounts 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_romcall replaces the current session; there's no multi-instance support.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.
E2B
Using MCP to run code via e2b.