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.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。