# Directory Structure
```
├── .env.example
├── .gitignore
├── Dockerfile
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── roms
│ └── dangan.gb
├── smithery.yaml
├── src
│ ├── emulatorService.ts
│ ├── gameboy.ts
│ ├── index.ts
│ ├── server
│ │ ├── server.ts
│ │ ├── sse.ts
│ │ └── stdio.ts
│ ├── tools.ts
│ ├── types.ts
│ ├── ui.ts
│ └── utils
│ └── logger.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
```
# Server configuration
SERVER_PORT=3001
# ROM path for stdio mode
ROM_PATH=./roms/dangan.gb
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
# Dependencies
node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log
# Build output
dist/
build/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Editor directories and files
.idea/
.vscode/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
mcp-gameboy.log
roms/example.gb
roms/Pokemon Red.gb
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# MCP GameBoy Server
[](https://smithery.ai/server/@mario-andreschak/mcp-gameboy)
<a href="https://glama.ai/mcp/servers/@mario-andreschak/mcp-gameboy">
<img width="380" height="200" src="https://glama.ai/mcp/servers/@mario-andreschak/mcp-gameboy/badge" alt="GameBoy Server MCP server" />
</a>
## Overview
A Model Context Protocol (MCP) server for serverboy, allowing LLMs to interact with a GameBoy emulator.
Your LLM can...
- Load ROMS
- Press Keys
- Look at the Gameboy Screen
- skip frames
You can...
- control the gameboy emulator using the @modelcontextprotocol/inspector
- control the gameboy emulator (and upload ROMs) using a web-interface at http://localhost:3001/emulator
- install the gameboy emulator in your favorite MCP-Client



## Features
- Supports both stdio and SSE transports
- Provides tools for GameBoy controls (up, down, left, right, A, B, start, select)
- Provides tools to load different ROMs
- Provides tools to get the current screen
- All tools return an ImageContent with the latest screen frame
## Installation
### Installing via Smithery
To install GameBoy Emulator Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@mario-andreschak/mcp-gameboy):
```bash
npx -y @smithery/cli install @mario-andreschak/mcp-gameboy --client claude
```
### Installing in [FLUJO](https://github.com/mario-andreschak/FLUJO/)
1. Click Add Server
2. Copy & Paste Github URL into FLUJO
3. Click Parse, Clone, Install, Build and Save.
### Manual Installation
```bash
# Clone the repository
git clone https://github.com/yourusername/mcp-gameboy.git
cd mcp-gameboy
# Install dependencies
npm install
# Build the project
npm run build
```
### Installing via Configuration Files
!! **ATTENTION** : Many MCP Clients require to specify the ROM-Path in the .env vars as an **absolute path**
To integrate this MCP server with Cline or other MCP clients via configuration files:
1. Open your Cline settings:
- In VS Code, go to File -> Preferences -> Settings
- Search for "Cline MCP Settings"
- Click "Edit in settings.json"
2. Add the server configuration to the `mcpServers` object:
```json
{
"mcpServers": {
"mcp-gameboy": {
"command": "node",
"args": [
"/path/to/mcp-gameboy/dist/index.js"
],
"disabled": false,
"autoApprove": []
}
}
}
```
3. Replace `/path/to/mcp-gameboy/dist/index.js` with the actual path to the `index.js` file in your project directory. Use forward slashes (/) or double backslashes (\\\\) for the path on Windows.
4. Save the settings file. Cline should automatically connect to the server.
## Usage
### Environment Variables
!! **ATTENTION** : Many MCP Clients require to specify the ROM-Path in the .env vars as an **absolute path**
Create a `.env` file in the root directory with the following variables:
```
# Server configuration
PORT=3001
# ROM path for stdio mode
ROM_PATH=./roms/dangan.gb
```
### Running in stdio Mode
In stdio mode, the server uses the ROM path specified in the `ROM_PATH` environment variable. It will open a browser window to display the GameBoy screen.
```bash
npm run start
```
### Running in SSE Mode
In SSE mode, the server starts an Express server that serves a web page for ROM selection.
```bash
npm run start-sse
```
Then open your browser to `http://localhost:3001` to select a ROM.
## Tools
The server provides the following tools:
- `press_up`: Press the UP button on the GameBoy
- `press_down`: Press the DOWN button on the GameBoy
- `press_left`: Press the LEFT button on the GameBoy
- `press_right`: Press the RIGHT button on the GameBoy
- `press_a`: Press the A button on the GameBoy
- `press_b`: Press the B button on the GameBoy
- `press_start`: Press the START button on the GameBoy
- `press_select`: Press the SELECT button on the GameBoy
- `load_rom`: Load a GameBoy ROM file
- `get_screen`: Get the current GameBoy screen
All tools return an ImageContent with the latest screen frame.
## Implementation Details
This server is built using the Model Context Protocol (MCP) TypeScript SDK. It uses:
- `McpServer` from `@modelcontextprotocol/sdk/server/mcp.js` for the server implementation
- `StdioServerTransport` from `@modelcontextprotocol/sdk/server/stdio.js` for stdio transport
- `SSEServerTransport` from `@modelcontextprotocol/sdk/server/sse.js` for SSE transport
- `serverboy` for the GameBoy emulation
- `express` for the web server in SSE mode
- `canvas` for rendering the GameBoy screen
## License
MIT
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"noImplicitAny": true,
"outDir": "dist",
"sourceMap": true,
"declaration": true,
"resolveJsonModule": true,
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
# syntax=docker/dockerfile:1
FROM node:lts-alpine
# Install build dependencies for canvas
RUN apk add --no-cache python3 make g++ cairo-dev pango-dev jpeg-dev giflib-dev
# Set working directory
WORKDIR /app
# Copy package files first for better caching
COPY package.json package-lock.json ./
# Install all dependencies (including dev dependencies needed for build)
RUN npm ci
# Copy source code and ROM files
COPY . .
# Make sure the ROM directory exists
RUN mkdir -p roms
# Build TypeScript
RUN npm run build
# Create .env file with default configuration
RUN echo "SERVER_PORT=3001\nROM_PATH=./roms/dangan.gb" > .env
# Default command uses stdio transport
CMD ["node", "dist/index.js"]
```
--------------------------------------------------------------------------------
/src/server/server.ts:
--------------------------------------------------------------------------------
```typescript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { EmulatorService } from '../emulatorService'; // Import EmulatorService
import { registerGameBoyTools } from '../tools';
/**
* Create a GameBoy MCP server
* @param emulatorService Emulator service instance
* @returns MCP server instance
*/
export function createGameBoyServer(emulatorService: EmulatorService): McpServer {
// Create the server
const server = new McpServer(
{
name: 'serverboy',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
// Register GameBoy tools
registerGameBoyTools(server, emulatorService); // Pass emulatorService
return server;
}
```
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
```typescript
import {
Tool,
Resource,
ImageContent,
TextContent
} from '@modelcontextprotocol/sdk/types.js';
// GameBoy button types
export enum GameBoyButton {
UP = 'UP',
DOWN = 'DOWN',
LEFT = 'LEFT',
RIGHT = 'RIGHT',
A = 'A',
B = 'B',
START = 'START',
SELECT = 'SELECT'
}
// Tool schemas
export interface PressButtonToolSchema {
button: GameBoyButton;
duration_frames?: number;
}
export interface WaitFramesToolSchema {
duration_frames: number;
}
export interface LoadRomToolSchema {
romPath: string;
}
export interface GetScreenToolSchema {
// No parameters needed
}
// Tool response types
export interface GameBoyToolResponse {
screen: ImageContent;
}
// Server configuration
export interface GameBoyServerConfig {
romPath?: string;
port?: number;
}
// Session state
export interface GameBoySession {
romLoaded: boolean;
romPath?: string;
}
```
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
```yaml
# Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
startCommand:
type: stdio
configSchema:
# JSON Schema defining the configuration options for the MCP.
type: object
required:
- romPath
properties:
romPath:
type: string
default: ./roms/dangan.gb
description: Path to the GameBoy ROM file
mode:
type: string
default: stdio
description: "Transport mode: stdio or sse"
port:
type: number
default: 3000
description: Port for SSE mode
commandFunction:
# A JS function that produces the CLI command based on the given config to start the MCP on stdio.
|-
(config) => {
const args = [];
if (config.mode === 'sse') {
args.push('--sse');
}
return {
command: 'node',
args: ['dist/index.js', ...args],
env: {
ROM_PATH: config.romPath,
PORT: String(config.port)
}
};
}
exampleConfig:
romPath: ./roms/dangan.gb
mode: stdio
port: 3000
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
import { startStdioServer } from './server/stdio';
import { startSseServer } from './server/sse';
import dotenv from 'dotenv';
import { log } from './utils/logger';
// Load environment variables from .env file
dotenv.config();
/**
* Main entry point
*/
async function main(): Promise<void> {
// Parse command-line arguments
const args = process.argv.slice(2);
const isStdio = args.includes('--stdio');
const isSse = args.includes('--sse');
// Get the SSE port from environment variable or use default
const ssePort = process.env.SERVER_PORT ? parseInt(process.env.SERVER_PORT, 10) : 3001;
// Start the appropriate server
if (isStdio) {
log.info('Starting GameBoy MCP server in stdio mode');
await startStdioServer();
} else if (isSse) {
log.info(`Starting GameBoy MCP server in SSE mode on port ${ssePort}`);
await startSseServer(ssePort);
} else {
// Default to stdio mode
log.info('No mode specified, defaulting to stdio mode');
await startStdioServer();
}
}
// Run the main function
main().catch(error => {
log.error(`Error: ${error}`);
process.exit(1);
});
```
--------------------------------------------------------------------------------
/src/gameboy.ts:
--------------------------------------------------------------------------------
```typescript
import { GameBoyButton } from './types';
import * as fs from 'fs';
import * as path from 'path';
import { createCanvas, Canvas } from 'canvas';
import { log } from './utils/logger';
// Import the serverboy library
const Gameboy = require('serverboy');
export class GameBoyEmulator {
private gameboy: any;
private canvas: Canvas;
private romLoaded: boolean = false;
private romPath?: string;
constructor() {
this.gameboy = new Gameboy();
// Create a canvas for rendering the screen
this.canvas = createCanvas(160, 144);
}
/**
* Load a ROM file
* @param romPath Path to the ROM file
*/
public loadRom(romPath: string): void {
try {
const rom = fs.readFileSync(romPath);
this.gameboy.loadRom(rom);
this.romLoaded = true;
this.romPath = romPath;
log.info(`ROM loaded: ${path.basename(romPath)}`);
} catch (error) {
log.error(`Error loading ROM: ${error}`);
throw new Error(`Failed to load ROM: ${error}`);
}
}
/**
* Press a button on the GameBoy
* @param button Button to press
*/
public pressButton(button: GameBoyButton, durationFrames: number = 1): void {
if (!this.romLoaded) {
throw new Error('No ROM loaded');
}
// Map our button enum to serverboy's keymap
const buttonMap: Record<GameBoyButton, number> = {
[GameBoyButton.UP]: Gameboy.KEYMAP.UP,
[GameBoyButton.DOWN]: Gameboy.KEYMAP.DOWN,
[GameBoyButton.LEFT]: Gameboy.KEYMAP.LEFT,
[GameBoyButton.RIGHT]: Gameboy.KEYMAP.RIGHT,
[GameBoyButton.A]: Gameboy.KEYMAP.A,
[GameBoyButton.B]: Gameboy.KEYMAP.B,
[GameBoyButton.START]: Gameboy.KEYMAP.START,
[GameBoyButton.SELECT]: Gameboy.KEYMAP.SELECT
};
for (let i=0; i < durationFrames; i++) {
this.gameboy.pressKeys([buttonMap[button]]);
this.gameboy.doFrame();
}
// for now: advance one frame so we dont "hold" the button all the time.
this.gameboy.doFrame();
}
/**
* Advance the emulation by one frame
*/
public doFrame(): void {
if (!this.romLoaded) {
throw new Error('No ROM loaded');
}
this.gameboy.doFrame();
}
/**
* Get the current screen as a base64 encoded PNG
* @returns Base64 encoded PNG image
*/
public getScreenAsBase64(): string {
if (!this.romLoaded) {
throw new Error('No ROM loaded');
}
// Get the raw screen data
const screenData = this.gameboy.getScreen();
// Draw to canvas
const ctx = this.canvas.getContext('2d');
const imageData = ctx.createImageData(160, 144);
for (let i = 0; i < screenData.length; i++) {
imageData.data[i] = screenData[i];
}
ctx.putImageData(imageData, 0, 0);
// Convert to base64 PNG
return this.canvas.toDataURL('image/png').split(',')[1];
}
/**
* Get the current ROM path
* @returns Current ROM path or undefined if no ROM is loaded
*/
public getRomPath(): string | undefined {
return this.romPath;
}
/**
* Check if a ROM is loaded
* @returns True if a ROM is loaded, false otherwise
*/
public isRomLoaded(): boolean {
return this.romLoaded;
}
}
```
--------------------------------------------------------------------------------
/src/utils/logger.ts:
--------------------------------------------------------------------------------
```typescript
import * as fs from 'fs';
import * as path from 'path';
import { string } from 'zod';
const logFilePath = './mcp-gameboy.log';
// const logFilePath = path.join(process.cwd(), 'mcp-gameboy.log');
// Ensure the log file exists
try {
fs.appendFileSync(logFilePath, ''); // Create file if it doesn't exist, or just touch it
} catch (err) {
console.error('CRITICAL FAILURE: Failed to ensure log file exists:', err); // Use console here as logger isn't ready
}
type LogLevel = 'INFO' | 'ERROR' | 'WARN' | 'DEBUG' | 'VERBOSE';
function writeLog(level: LogLevel, message: string, ...optionalParams: any[]) {
const timestamp = new Date().toISOString();
let logEntry = `${timestamp} [${level}] ${message}`;
if (optionalParams.length > 0) {
// Handle additional parameters, ensuring objects are stringified for VERBOSE
const formattedParams = optionalParams.map(param => {
if (level === 'VERBOSE' && typeof param === 'object' && param !== null) {
try {
// Use JSON.stringify for verbose objects as per requirement
return JSON.stringify(param);
} catch (e) {
return '[Unserializable Object]';
}
} else if (typeof param === 'object' && param !== null) {
// For other levels, use a simpler representation or toString()
return param instanceof Error ? param.stack || param.message : JSON.stringify(param); // Show stack for errors
} else {
return String(param);
}
});
logEntry += ` ${formattedParams.join(' ')}`;
}
logEntry += '\n'; // Add newline for separation
try {
fs.appendFileSync(logFilePath, logEntry);
} catch (err) {
// Fallback to console if file logging fails
console.error(`Failed to write to log file: ${logFilePath}`, err);
}
}
/**
* Logger utility for MCP-GameBoy
* Writes logs to a file instead of console output
*/
export const log = {
/**
* Log informational messages
* @param message The message to log
* @param optionalParams Additional parameters to log
*/
info: (message: string, ...optionalParams: any[]) => {
writeLog('INFO', message, ...optionalParams);
},
/**
* Log error messages
* @param message The message to log
* @param optionalParams Additional parameters to log
*/
error: (message: string, ...optionalParams: any[]) => {
console.error(message)
writeLog('ERROR', message, ...optionalParams);
},
/**
* Log warning messages
* @param message The message to log
* @param optionalParams Additional parameters to log
*/
warn: (message: string, ...optionalParams: any[]) => {
writeLog('WARN', message, ...optionalParams);
},
/**
* Log debug messages
* @param message The message to log
* @param optionalParams Additional parameters to log
*/
debug: (message: string, ...optionalParams: any[]) => {
writeLog('DEBUG', message, ...optionalParams);
},
/**
* Log verbose messages
* Objects passed to this method will be serialized with JSON.stringify
* @param message The message to log
* @param optionalParams Additional parameters to log (objects will be JSON.stringify'd)
*/
verbose: (message: string, ...optionalParams: any[]) => {
writeLog('VERBOSE', message, ...optionalParams);
}
};
```
--------------------------------------------------------------------------------
/src/emulatorService.ts:
--------------------------------------------------------------------------------
```typescript
import { GameBoyEmulator } from './gameboy';
import { GameBoyButton } from './types';
import { ImageContent } from '@modelcontextprotocol/sdk/types.js';
import * as fs from 'fs';
import * as path from 'path';
import { log } from './utils/logger';
/**
* Service class to encapsulate GameBoyEmulator interactions.
*/
export class EmulatorService {
private emulator: GameBoyEmulator;
constructor(emulator: GameBoyEmulator) {
this.emulator = emulator;
log.info('EmulatorService initialized');
}
/**
* Checks if a ROM is currently loaded.
* @returns True if a ROM is loaded, false otherwise.
*/
isRomLoaded(): boolean {
return this.emulator.isRomLoaded();
}
/**
* Gets the path of the currently loaded ROM.
* @returns The ROM path or undefined if no ROM is loaded.
*/
getRomPath(): string | undefined {
return this.emulator.getRomPath();
}
/**
* Loads a GameBoy ROM file.
* @param romPath Path to the ROM file.
* @returns The initial screen content after loading.
* @throws Error if the ROM file doesn't exist or fails to load.
*/
loadRom(romPath: string): ImageContent {
log.info(`Attempting to load ROM: ${romPath}`);
if (!fs.existsSync(romPath)) {
log.error(`ROM file not found: ${romPath}`);
throw new Error(`ROM file not found: ${romPath}`);
}
try {
this.emulator.loadRom(romPath);
log.info(`ROM loaded successfully: ${path.basename(romPath)}`);
// Advance a few frames to initialize the screen
for (let i = 0; i < 5; i++) {
this.emulator.doFrame();
}
log.verbose('Advanced initial frames after ROM load');
return this.getScreen();
} catch (error) {
log.error(`Error loading ROM: ${romPath}`, error instanceof Error ? error.message : String(error));
throw new Error(`Failed to load ROM: ${romPath}. Reason: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Presses a GameBoy button for a single frame.
* @param button The button to press.
* @param durationFrames The number of frames to press the button.
* @returns The screen content after pressing the button.
* @throws Error if no ROM is loaded.
*/
pressButton(button: GameBoyButton, durationFrames: number): ImageContent {
log.debug(`Pressing button: ${button}`);
if (!this.isRomLoaded()) {
log.warn('Attempted to press button with no ROM loaded');
throw new Error('No ROM loaded');
}
this.emulator.pressButton(button, durationFrames); // This advances one frame
return this.getScreen();
}
/**
* Waits (advances) for a specified number of frames.
* @param durationFrames The number of frames to wait.
* @returns The screen content after waiting.
* @throws Error if no ROM is loaded.
*/
waitFrames(durationFrames: number): ImageContent {
log.debug(`Waiting for ${durationFrames} frames`);
if (!this.isRomLoaded()) {
log.warn('Attempted to wait frames with no ROM loaded');
throw new Error('No ROM loaded');
}
for (let i = 0; i < durationFrames; i++) {
this.emulator.doFrame();
}
log.verbose(`Waited ${durationFrames} frames`, JSON.stringify({ frames: durationFrames }));
return this.getScreen();
}
/**
* Gets the current GameBoy screen as base64 PNG data.
* Does NOT advance a frame.
* @returns The screen content.
* @throws Error if no ROM is loaded.
*/
getScreen(): ImageContent {
log.verbose('Getting current screen');
if (!this.isRomLoaded()) {
log.warn('Attempted to get screen with no ROM loaded');
throw new Error('No ROM loaded');
}
const screenBase64 = this.emulator.getScreenAsBase64();
const screen: ImageContent = {
type: 'image',
data: screenBase64,
mimeType: 'image/png'
};
log.verbose('Screen data retrieved', JSON.stringify({ mimeType: screen.mimeType, dataLength: screen.data.length }));
return screen;
}
/**
* Advances the emulator by one frame and returns the new screen.
* @returns The screen content after advancing one frame.
* @throws Error if no ROM is loaded.
*/
advanceFrameAndGetScreen(): ImageContent {
log.verbose('Advancing one frame and getting screen');
if (!this.isRomLoaded()) {
log.warn('Attempted to advance frame with no ROM loaded');
throw new Error('No ROM loaded');
}
this.emulator.doFrame();
return this.getScreen();
}
}
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "mcp-gameboy",
"version": "1.0.0",
"description": "A gameboy emulator for LLM's",
"main": "index.js",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@types/multer": "^1.4.12",
"accepts": "^2.0.0",
"acorn": "^8.14.1",
"acorn-walk": "^8.3.4",
"agent-base": "^7.1.3",
"arg": "^4.1.3",
"body-parser": "^2.2.0",
"bytes": "^3.1.2",
"call-bind-apply-helpers": "^1.0.2",
"call-bound": "^1.0.4",
"canvas": "^3.1.0",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.2",
"cookie-signature": "^1.2.2",
"cors": "^2.8.5",
"create-require": "^1.1.1",
"cross-spawn": "^7.0.6",
"cssstyle": "^4.3.1",
"data-urls": "^5.0.0",
"debug": "^4.4.0",
"decimal.js": "^10.5.0",
"depd": "^2.0.0",
"diff": "^4.0.2",
"dotenv": "^16.5.0",
"dunder-proto": "^1.0.1",
"ee-first": "^1.1.1",
"encodeurl": "^2.0.0",
"entities": "^6.0.0",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"eventsource": "^3.0.6",
"eventsource-parser": "^3.0.1",
"express": "^5.1.0",
"express-rate-limit": "^7.5.0",
"finalhandler": "^2.1.0",
"forwarded": "^0.2.0",
"fresh": "^2.0.0",
"function-bind": "^1.1.2",
"gameboy-emulator": "^1.1.2",
"get-intrinsic": "^1.3.0",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"html-encoding-sniffer": "^4.0.0",
"http-errors": "^2.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"iconv-lite": "^0.6.3",
"inherits": "^2.0.4",
"ipaddr.js": "^1.9.1",
"is-potential-custom-element-name": "^1.0.1",
"is-promise": "^4.0.0",
"isexe": "^2.0.0",
"jsdom": "^26.1.0",
"lru-cache": "^10.4.3",
"make-error": "^1.3.6",
"math-intrinsics": "^1.1.0",
"media-typer": "^1.1.0",
"merge-descriptors": "^2.0.0",
"mime-db": "^1.54.0",
"mime-types": "^3.0.1",
"ms": "^2.1.3",
"multer": "^1.4.5-lts.2",
"negotiator": "^1.0.0",
"nwsapi": "^2.2.20",
"object-assign": "^4.1.1",
"object-inspect": "^1.13.4",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"open": "^10.1.1",
"parse5": "^7.3.0",
"parseurl": "^1.3.3",
"path-key": "^3.1.1",
"path-to-regexp": "^8.2.0",
"pkce-challenge": "^5.0.0",
"proxy-addr": "^2.0.7",
"punycode": "^2.3.1",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"raw-body": "^3.0.0",
"router": "^2.2.0",
"rrweb-cssom": "^0.8.0",
"safe-buffer": "^5.2.1",
"safer-buffer": "^2.1.2",
"saxes": "^6.0.0",
"send": "^1.2.0",
"serve-static": "^2.2.0",
"serverboy": "^0.0.7",
"setprototypeof": "^1.2.0",
"shebang-command": "^2.0.0",
"shebang-regex": "^3.0.0",
"side-channel": "^1.1.0",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2",
"statuses": "^2.0.1",
"symbol-tree": "^3.2.4",
"tldts": "^6.1.86",
"tldts-core": "^6.1.86",
"toidentifier": "^1.0.1",
"tough-cookie": "^5.1.2",
"tr46": "^5.1.1",
"ts-node": "^10.9.2",
"type-is": "^2.0.1",
"typescript": "^5.8.3",
"undici-types": "^6.21.0",
"unpipe": "^1.0.0",
"v8-compile-cache-lib": "^3.0.1",
"vary": "^1.1.2",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.2.0",
"which": "^2.0.2",
"wrappy": "^1.0.2",
"ws": "^8.18.1",
"xml-name-validator": "^5.0.0",
"xmlchars": "^2.2.0",
"yn": "^3.1.1",
"zod": "^3.24.3",
"zod-to-json-schema": "^3.24.5"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc",
"start": "node dist/index.js",
"start-sse": "node dist/index.js --sse",
"dev": "ts-node src/index.ts",
"debug": "tsc && npx @modelcontextprotocol/inspector node dist/index.js --stdio",
"debug-sse": "tsc && @modelcontextprotocol/inspector node dist/index.js --sse"
},
"repository": {
"type": "git",
"url": "git+https://github.com/mario-andreschak/mcp-gameboy.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"bugs": {
"url": "https://github.com/mario-andreschak/mcp-gameboy/issues"
},
"homepage": "https://github.com/mario-andreschak/mcp-gameboy#readme",
"devDependencies": {
"@types/express": "^5.0.1"
}
}
```
--------------------------------------------------------------------------------
/src/tools.ts:
--------------------------------------------------------------------------------
```typescript
import {
CallToolResult,
ImageContent,
TextContent
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { GameBoyButton } from './types';
import { EmulatorService } from './emulatorService'; // Import EmulatorService
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as fs from 'fs';
import * as path from 'path';
import { log } from './utils/logger';
/**
* Register GameBoy tools with the MCP server
* @param server MCP server instance
* @param emulatorService Emulator service instance
*/
export function registerGameBoyTools(server: McpServer, emulatorService: EmulatorService): void {
// Register button press tools
Object.values(GameBoyButton).forEach(button => {
server.tool(
`press_${button.toLowerCase()}`,
`Press the ${button} button on the GameBoy`,
{
duration_frames: z.number().int().positive().optional().default(1).describe('Number of frames to hold the button').default(25)
},
async ({ duration_frames }): Promise<CallToolResult> => {
// Press the button using the service (advances one frame)
emulatorService.pressButton(button, duration_frames);
// Return the current screen using the service
const screen = emulatorService.getScreen();
return { content: [screen] };
}
);
});
// Register wait_frames tool
server.tool(
'wait_frames',
'Wait for a specified number of frames',
{
duration_frames: z.number().int().positive().describe('Number of frames to wait').default(100)
},
async ({ duration_frames }): Promise<CallToolResult> => {
// Wait for frames using the service
const screen = emulatorService.waitFrames(duration_frames);
return { content: [screen] };
}
);
// Register load ROM tool
server.tool(
'load_rom',
'Load a GameBoy ROM file',
{
romPath: z.string().describe('Path to the ROM file')
},
async ({ romPath }): Promise<CallToolResult> => {
// Load ROM using the service (already advances initial frames)
const screen = emulatorService.loadRom(romPath);
return { content: [screen] };
}
);
// Register get screen tool
server.tool(
'get_screen',
'Get the current GameBoy screen (advances one frame)', // Updated description
{},
async (): Promise<CallToolResult> => {
// Advance one frame and get the screen using the service
const screen = emulatorService.advanceFrameAndGetScreen();
return { content: [screen] };
}
);
// Register is_rom_loaded tool
server.tool(
'is_rom_loaded',
'Check if a ROM is currently loaded in the emulator',
{},
async (): Promise<CallToolResult> => {
const isLoaded = emulatorService.isRomLoaded();
const romPath = emulatorService.getRomPath();
const responseText: TextContent = {
type: 'text',
text: JSON.stringify({
romLoaded: isLoaded,
romPath: romPath || null
})
};
log.verbose('Checked ROM loaded status', JSON.stringify({
romLoaded: isLoaded,
romPath: romPath || null
}));
return { content: [responseText] };
}
);
// Register list_roms tool
server.tool(
'list_roms',
'List all available GameBoy ROM files',
{},
async (): Promise<CallToolResult> => {
try {
const romsDir = path.join(process.cwd(), 'roms');
// Create roms directory if it doesn't exist
if (!fs.existsSync(romsDir)) {
fs.mkdirSync(romsDir);
log.info('Created roms directory');
}
// Get list of ROM files
const romFiles = fs.readdirSync(romsDir)
.filter(file => file.endsWith('.gb') || file.endsWith('.gbc'))
.map(file => ({
name: file,
path: path.join(romsDir, file)
}));
const responseText: TextContent = {
type: 'text',
text: JSON.stringify(romFiles)
};
log.verbose('Listed available ROMs', JSON.stringify({
count: romFiles.length,
roms: romFiles
}));
return { content: [responseText] };
} catch (error) {
log.error('Error listing ROMs:', error instanceof Error ? error.message : String(error));
const errorText: TextContent = {
type: 'text',
text: JSON.stringify({
error: 'Failed to list ROMs',
message: error instanceof Error ? error.message : String(error)
})
};
return { content: [errorText] };
}
}
);
}
```
--------------------------------------------------------------------------------
/src/server/stdio.ts:
--------------------------------------------------------------------------------
```typescript
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { GameBoyEmulator } from '../gameboy';
import { EmulatorService } from '../emulatorService'; // Import EmulatorService
import { createGameBoyServer } from './server';
import * as path from 'path';
import * as fs from 'fs';
import open from 'open';
import express, { Request, Response } from 'express'; // Import Request, Response
import http from 'http';
import multer from 'multer'; // Import multer
import { setupWebUI, setupRomSelectionUI } from '../ui'; // Import setupRomSelectionUI
import { log } from '../utils/logger';
/**
* Start the GameBoy MCP server in stdio mode
*/
export async function startStdioServer(): Promise<void> {
// Create the emulator
const emulator = new GameBoyEmulator();
// Create the emulator service
const emulatorService = new EmulatorService(emulator);
// Create the server using the service
const server = createGameBoyServer(emulatorService);
// Check for ROM path in environment variable
const romPath = process.env.ROM_PATH;
if (!romPath) {
log.error('ROM_PATH environment variable not set');
process.exit(1);
}
// Check if the ROM file exists
if (!fs.existsSync(romPath)) {
log.error(`ROM file not found: ${romPath}`);
process.exit(1);
}
// Load the ROM using the service
try {
emulatorService.loadRom(romPath); // Use service to load ROM
log.info(`ROM loaded: ${path.basename(romPath)}`);
// Create an Express app to serve the GameBoy screen
const app = express();
const port = process.env.SERVER_PORT ? parseInt(process.env.SERVER_PORT, 10) : 3000;
// Add middleware to parse JSON bodies
app.use(express.json());
app.use(express.static(path.join(process.cwd(), 'public'))); // Serve static files if needed by UI
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const romsDir = path.join(process.cwd(), 'roms');
if (!fs.existsSync(romsDir)) {
fs.mkdirSync(romsDir);
}
cb(null, romsDir);
},
filename: (req, file, cb) => {
cb(null, file.originalname);
}
});
const upload = multer({ storage });
// Set up the main web UI (emulator screen)
setupWebUI(app, emulatorService);
// Set up the ROM selection UI
setupRomSelectionUI(app, emulatorService);
// Handle ROM upload
app.post('/upload', upload.single('rom'), (req: Request, res: Response) => {
// Redirect back to the ROM selection page after upload
res.redirect('/');
});
// Add the /gameboy route to handle loading a ROM selected from the UI
app.get('/gameboy', (req: Request, res: Response) => {
const relativeRomPath = req.query.rom as string;
if (!relativeRomPath) {
log.error('[stdio /gameboy] No ROM path provided in query.');
res.redirect('/'); // Redirect to selection if no path
return;
}
const absoluteRomPath = path.resolve(process.cwd(), relativeRomPath);
log.info(`[stdio /gameboy] Received relative path: ${relativeRomPath}`);
log.info(`[stdio /gameboy] Resolved to absolute path: ${absoluteRomPath}`);
if (!fs.existsSync(absoluteRomPath)) {
log.error(`[stdio /gameboy] ROM file not found at absolute path: ${absoluteRomPath}`);
res.status(404).send(`ROM not found: ${relativeRomPath}`); // Send error or redirect
return;
}
try {
log.info(`[stdio /gameboy] Attempting to load ROM from absolute path: ${absoluteRomPath}`);
emulatorService.loadRom(absoluteRomPath); // Load the newly selected ROM
log.info(`[stdio /gameboy] ROM loaded successfully: ${absoluteRomPath}`);
res.redirect('/emulator'); // Redirect to the emulator page
} catch (error) {
log.error(`[stdio /gameboy] Error loading ROM from ${absoluteRomPath}:`, error);
res.status(500).send('Error loading ROM'); // Send error or redirect
}
});
// // Add a redirect from root to emulator (Keep commented out, root is now ROM selection)
// app.get('/', (req, res) => {
// res.redirect('/emulator');
// });
// Start the Express server
const httpServer = http.createServer(app);
httpServer.listen(port, () => {
// Log both the emulator page and the selection page
log.info(`Initial ROM loaded. Emulator available at http://localhost:${port}/emulator`);
log.info(`ROM Selection available at http://localhost:${port}/`);
// Open the web interface directly to the emulator for the initial ROM
open(`http://localhost:${port}/emulator`);
});
// Create the stdio transport
const transport = new StdioServerTransport();
// Connect the transport to the server
await server.connect(transport);
log.info('MCP server running on stdio');
} catch (error) {
log.error(`Error starting server: ${error}`);
process.exit(1);
}
}
```
--------------------------------------------------------------------------------
/src/server/sse.ts:
--------------------------------------------------------------------------------
```typescript
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { GameBoyEmulator } from '../gameboy';
import { EmulatorService } from '../emulatorService'; // Import EmulatorService
import { createGameBoyServer } from './server';
import express, { Request, Response } from 'express';
import path from 'path';
import fs from 'fs';
import http from 'http';
import multer from 'multer';
import { setupWebUI, setupRomSelectionUI } from '../ui';
import { log } from '../utils/logger';
/**
* Start the GameBoy MCP server in SSE mode
* @param port Port to listen on (defaults to SERVER_PORT from .env or 3001)
*/
export async function startSseServer(port?: number): Promise<void> {
// Use SERVER_PORT from environment variables if port is not provided
const ssePort = port || (process.env.SERVER_PORT ? parseInt(process.env.SERVER_PORT, 10) : 3001);
const webUiPort = process.env.SERVER_PORT ? parseInt(process.env.SERVER_PORT, 10) : 3002;
// Create the emulator
const emulator = new GameBoyEmulator();
// Create the emulator service
const emulatorService = new EmulatorService(emulator);
// Create the server using the service
const server = createGameBoyServer(emulatorService);
// Create the Express app
const app = express();
// Middleware
app.use(express.static(path.join(process.cwd(), 'public')));
app.use(express.json());
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const romsDir = path.join(process.cwd(), 'roms');
if (!fs.existsSync(romsDir)) {
fs.mkdirSync(romsDir);
}
cb(null, romsDir);
},
filename: (req, file, cb) => {
cb(null, file.originalname);
}
});
const upload = multer({ storage });
// Store transports by session ID
const transports: Record<string, SSEServerTransport> = {};
// SSE endpoint for establishing the stream
app.get('/mcp', async (req: Request, res: Response) => {
log.info('Received GET request to /mcp (establishing SSE stream)');
try {
// Create a new SSE transport for the client
const transport = new SSEServerTransport('/messages', res);
// Store the transport by session ID
const sessionId = transport.sessionId;
transports[sessionId] = transport;
// Set up onclose handler to clean up transport when closed
transport.onclose = () => {
log.info(`SSE transport closed for session ${sessionId}`);
delete transports[sessionId];
};
// Connect the transport to the MCP server
await server.connect(transport);
log.info(`Established SSE stream with session ID: ${sessionId}`);
} catch (error) {
log.error('Error establishing SSE stream:', error);
if (!res.headersSent) {
res.status(500).send('Error establishing SSE stream');
}
}
});
// Messages endpoint for receiving client JSON-RPC requests
app.post('/messages', async (req: Request, res: Response) => {
log.info('Received POST request to /messages');
// Extract session ID from URL query parameter
const sessionId = req.query.sessionId as string | undefined;
if (!sessionId) {
log.error('No session ID provided in request URL');
res.status(400).send('Missing sessionId parameter');
return;
}
const transport = transports[sessionId];
if (!transport) {
log.error(`No active transport found for session ID: ${sessionId}`);
res.status(404).send('Session not found');
return;
}
try {
// Handle the POST message with the transport
await transport.handlePostMessage(req, res, req.body);
} catch (error) {
log.error('Error handling request:', error);
if (!res.headersSent) {
res.status(500).send('Error handling request');
}
}
});
// Set up the ROM selection UI using the service
setupRomSelectionUI(app, emulatorService); // Pass service, remove emulator
// Handle ROM upload
app.post('/upload', upload.single('rom'), (req, res) => {
// Redirect to the ROM selection page
res.redirect('/');
});
// Create the GameBoy page
app.get('/gameboy', (req, res) => {
const romPath = req.query.rom as string;
// Check if the ROM file exists
if (!romPath || !fs.existsSync(romPath)) {
res.redirect('/');
return;
}
// Load the ROM using the service
try {
emulatorService.loadRom(romPath); // Use service
// NOTE: setupWebUI should ideally be called only ONCE during setup,
// not within a route handler. Let's move it outside.
// We'll set it up after the ROM selection UI.
// Redirect to the emulator page - the UI will fetch the screen
res.redirect('/emulator');
} catch (error) {
log.error(`Error loading ROM: ${error}`, error);
res.redirect('/'); // Redirect back to selection on error
}
});
// Set up the main Web UI (needs to be done once)
// Pass the service instance. The optional romPath isn't strictly needed here
// as the UI gets the current ROM from the service via API.
setupWebUI(app, emulatorService);
// Start the Express server
const httpServer = http.createServer(app);
httpServer.listen(ssePort, () => {
log.info(`GameBoy MCP Server listening on http://localhost:${ssePort}`);
log.info(`GameBoy Web UI available at http://localhost:${webUiPort}`);
});
}
```
--------------------------------------------------------------------------------
/src/ui.ts:
--------------------------------------------------------------------------------
```typescript
import express, { Request, Response, RequestHandler } from 'express'; // Import RequestHandler
import * as path from 'path';
import * as fs from 'fs';
import { EmulatorService } from './emulatorService'; // Import EmulatorService
import { GameBoyButton } from './types'; // Import GameBoyButton
import { log } from './utils/logger';
/**
* Sets up the web UI routes for the GameBoy emulator
* @param app Express application
* @param emulatorService Emulator service instance
*/
export function setupWebUI(app: express.Application, emulatorService: EmulatorService): void {
// Route for the main emulator page
app.get('/emulator', (req: Request, res: Response) => {
const currentRomPath = emulatorService.getRomPath();
const displayRomPath = currentRomPath || 'No ROM loaded';
const romName = currentRomPath ? path.basename(currentRomPath) : 'No ROM loaded';
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>GameBoy Emulator</title>
<style>
body { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; background-color: #f0f0f0; font-family: Arial, sans-serif; }
h1 { margin-bottom: 20px; }
#gameboy { border: 10px solid #333; border-radius: 10px; background-color: #9bbc0f; padding: 20px; }
#screen { width: 320px; height: 288px; image-rendering: pixelated; background-color: #0f380f; display: block; }
#info { margin-top: 10px; text-align: center; }
#controls { margin-top: 15px; display: flex; flex-direction: column; align-items: center; }
.control-row { display: flex; margin-bottom: 10px; align-items: center; }
.dpad { display: grid; grid-template-columns: repeat(3, 40px); grid-template-rows: repeat(3, 40px); gap: 5px; margin-right: 20px; }
.action-buttons { display: grid; grid-template-columns: repeat(2, 40px); grid-template-rows: repeat(2, 40px); gap: 5px; margin-left: 20px; }
.menu-buttons { display: flex; gap: 10px; margin-top: 10px; }
.control-button, .menu-button { background-color: #333; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; display: flex; align-items: center; justify-content: center; }
.control-button { width: 40px; height: 40px; }
.menu-button { padding: 5px 10px; }
.control-button:hover, .menu-button:hover { background-color: #555; }
.control-button:active { background-color: #777; }
.skip-button { margin-top: 10px; padding: 5px 10px; background-color: #4CAF50; color: white; border: none; border-radius: 5px; cursor: pointer; }
.skip-button:hover { background-color: #45a049; }
#auto-play-container { margin-top: 10px; display: flex; align-items: center; }
#auto-play-checkbox { margin-right: 5px; }
#back-button { margin-top: 15px; padding: 5px 10px; background-color: #4CAF50; color: white; border: none; border-radius: 3px; cursor: pointer; text-decoration: none; }
#back-button:hover { background-color: #45a049; }
</style>
</head>
<body>
<h1>GameBoy Emulator</h1>
<div id="gameboy">
<img id="screen" src="/screen" alt="GameBoy Screen" />
</div>
<div id="info">
<p>ROM: ${romName}</p>
</div>
<div id="controls">
<div class="control-row">
<div class="dpad">
<div></div><button class="control-button" id="btn-up">↑</button><div></div>
<button class="control-button" id="btn-left">←</button><div></div><button class="control-button" id="btn-right">→</button>
<div></div><button class="control-button" id="btn-down">↓</button><div></div>
</div>
<div class="action-buttons">
<div></div><div></div>
<button class="control-button" id="btn-b">B</button>
<button class="control-button" id="btn-a">A</button>
</div>
</div>
<div class="menu-buttons">
<button class="menu-button" id="btn-select">SELECT</button>
<button class="menu-button" id="btn-start">START</button>
</div>
<button class="skip-button" id="btn-skip">Skip 100 Frames</button>
<div id="auto-play-container">
<input type="checkbox" id="auto-play-checkbox">
<label for="auto-play-checkbox">Auto-Play</label>
</div>
<div id="button-duration-container" style="margin-top: 10px; display: flex; align-items: center;">
<label for="button-duration-input" style="margin-right: 5px;">Button Press Duration:</label>
<input type="number" id="button-duration-input" min="1" value="25" style="width: 50px;">
</div>
</div>
<a id="back-button" href="/">Back to ROM Selection</a>
<script>
let autoPlayEnabled = false;
let updateTimeoutId = null;
const autoPlayCheckbox = document.getElementById('auto-play-checkbox');
const buttonDurationInput = document.getElementById('button-duration-input');
const screenImg = document.getElementById('screen');
autoPlayCheckbox.addEventListener('change', (event) => {
autoPlayEnabled = event.target.checked;
console.log('Auto-play toggled:', autoPlayEnabled);
// Clear existing timeout and immediately trigger an update
if (updateTimeoutId) clearTimeout(updateTimeoutId);
updateScreen();
});
async function updateScreen() {
if (!screenImg) return; // Exit if image element not found
const endpoint = autoPlayEnabled ? '/api/advance_and_get_screen' : '/screen';
const timestamp = new Date().getTime(); // Prevent caching
try {
const response = await fetch(endpoint + '?' + timestamp);
if (!response.ok) {
console.error('Error fetching screen:', response.status, response.statusText);
// Schedule next attempt after a delay, even on error
scheduleNextUpdate();
return;
}
const blob = await response.blob();
// Revoke previous object URL to free memory
if (screenImg.src.startsWith('blob:')) {
URL.revokeObjectURL(screenImg.src);
}
screenImg.src = URL.createObjectURL(blob);
} catch (error) {
console.error('Error fetching screen:', error);
} finally {
scheduleNextUpdate();
}
}
function scheduleNextUpdate() {
// Schedule next update
// ~60fps if auto-playing (1000ms / 60fps ≈ 16.67ms)
// Slower update rate if not auto-playing to reduce load
const delay = autoPlayEnabled ? 17 : 100;
updateTimeoutId = setTimeout(updateScreen, delay);
}
async function callApiTool(toolName, params = {}) {
console.log(\`Calling tool: \${toolName} with params:\`, params);
try {
const response = await fetch('/api/tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool: toolName, params: params })
});
if (!response.ok) {
const errorText = await response.text();
console.error(\`Error calling tool \${toolName}: \${response.status} \${errorText}\`);
} else {
console.log(\`Tool \${toolName} called successfully.\`);
// Optionally force a screen update if not auto-playing
// if (!autoPlayEnabled) {
// if (updateTimeoutId) clearTimeout(updateTimeoutId);
// updateScreen();
// }
}
} catch (error) {
console.error(\`Network error calling tool \${toolName}:\`, error);
}
}
// Add event listeners for buttons
document.getElementById('btn-up')?.addEventListener('click', () => {
const duration = parseInt(buttonDurationInput.value) || 5;
callApiTool('press_up', { duration_frames: duration });
});
document.getElementById('btn-down')?.addEventListener('click', () => {
const duration = parseInt(buttonDurationInput.value) || 5;
callApiTool('press_down', { duration_frames: duration });
});
document.getElementById('btn-left')?.addEventListener('click', () => {
const duration = parseInt(buttonDurationInput.value) || 5;
callApiTool('press_left', { duration_frames: duration });
});
document.getElementById('btn-right')?.addEventListener('click', () => {
const duration = parseInt(buttonDurationInput.value) || 5;
callApiTool('press_right', { duration_frames: duration });
});
document.getElementById('btn-a')?.addEventListener('click', () => {
const duration = parseInt(buttonDurationInput.value) || 5;
callApiTool('press_a', { duration_frames: duration });
});
document.getElementById('btn-b')?.addEventListener('click', () => {
const duration = parseInt(buttonDurationInput.value) || 5;
callApiTool('press_b', { duration_frames: duration });
});
document.getElementById('btn-start')?.addEventListener('click', () => {
const duration = parseInt(buttonDurationInput.value) || 5;
callApiTool('press_start', { duration_frames: duration });
});
document.getElementById('btn-select')?.addEventListener('click', () => {
const duration = parseInt(buttonDurationInput.value) || 5;
callApiTool('press_select', { duration_frames: duration });
});
document.getElementById('btn-skip')?.addEventListener('click', () => callApiTool('wait_frames', { duration_frames: 100 }));
// Start the screen update loop
updateScreen();
</script>
</body>
</html>
`);
});
// Route to get the current screen image (no frame advance)
const screenHandler: RequestHandler = (req, res) => {
if (!emulatorService.isRomLoaded()) {
res.status(400).send('No ROM loaded');
} else {
try {
const screen = emulatorService.getScreen(); // Does not advance frame
const screenBuffer = Buffer.from(screen.data, 'base64');
res.setHeader('Content-Type', 'image/png');
res.send(screenBuffer); // Ends the response
} catch (error) {
log.error('Error getting screen:', error);
res.status(500).send('Error getting screen'); // Ends the response
}
}
};
app.get('/screen', screenHandler);
// API endpoint for advancing one frame and getting the screen (for Auto-Play)
const advanceAndGetScreenHandler: RequestHandler = (req, res) => {
if (!emulatorService.isRomLoaded()) {
res.status(400).send('No ROM loaded');
} else {
try {
const screen = emulatorService.advanceFrameAndGetScreen(); // Advances frame
const screenBuffer = Buffer.from(screen.data, 'base64');
res.setHeader('Content-Type', 'image/png');
res.send(screenBuffer); // Ends the response
} catch (error) {
log.error('Error advancing frame and getting screen:', error);
res.status(500).send('Error advancing frame and getting screen'); // Ends the response
}
}
};
app.get('/api/advance_and_get_screen', advanceAndGetScreenHandler);
// API endpoint to call emulator tools (used by UI buttons)
const apiToolHandler: RequestHandler = async (req, res) => {
const { tool, params } = req.body;
log.info(`API /api/tool called: ${tool}`, params);
if (!tool) {
res.status(400).json({ error: 'Tool name is required' });
return; // Exit early if no tool provided
}
if (!emulatorService.isRomLoaded() && tool !== 'load_rom') {
res.status(400).json({ error: 'No ROM loaded' });
return; // Exit early if ROM not loaded (except for load_rom tool)
}
try {
let result: any; // Should be ImageContent
switch (tool) {
case 'get_screen':
result = emulatorService.getScreen();
break;
case 'load_rom':
if (!params || !params.romPath) {
res.status(400).json({ error: 'ROM path is required' });
return; // Exit early
}
result = emulatorService.loadRom(params.romPath);
break;
case 'wait_frames':
const duration_frames_wait = params?.duration_frames ?? 100;
if (typeof duration_frames_wait !== 'number' || duration_frames_wait <= 0) {
res.status(400).json({ error: 'Invalid duration_frames' });
return; // Exit early
}
result = emulatorService.waitFrames(duration_frames_wait);
break;
default:
if (tool.startsWith('press_')) {
const buttonName = tool.replace('press_', '').toUpperCase();
if (!(Object.values(GameBoyButton) as string[]).includes(buttonName)) {
res.status(400).json({ error: `Invalid button: ${buttonName}` });
return; // Exit early
}
const duration_frames_press = params?.duration_frames ?? 5;
if (typeof duration_frames_press !== 'number' || duration_frames_press <= 0) {
res.status(400).json({ error: 'Invalid duration_frames for press' });
return; // Exit early
}
emulatorService.pressButton(buttonName as GameBoyButton, duration_frames_press);
result = emulatorService.getScreen();
} else {
res.status(400).json({ error: `Unknown tool: ${tool}` });
return; // Exit early
}
}
// Send response if result was obtained
res.json({ content: [result] }); // Ends the response
} catch (error) {
log.error(`Error calling tool ${tool} via API:`, error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: `Failed to call tool: ${errorMessage}` }); // Ends the response
}
};
app.post('/api/tool', apiToolHandler);
// --- Test Interface Endpoints (Keep as is for now, assuming they are correct or will be fixed separately if needed) ---
// Add a route for the test interface
app.get('/test', (req: Request, res: Response) => {
res.sendFile(path.join(process.cwd(), 'public', 'test-interface.html'));
});
// Get list of available ROMs
app.get('/api/roms', (req: Request, res: Response) => {
try {
const romsDir = path.join(process.cwd(), 'roms');
if (!fs.existsSync(romsDir)) {
fs.mkdirSync(romsDir);
}
const romFiles = fs.readdirSync(romsDir)
.filter(file => file.endsWith('.gb') || file.endsWith('.gbc'))
.map(file => ({
name: file,
path: path.join(romsDir, file)
}));
res.json(romFiles);
} catch (error) {
log.error('Error getting ROM list:', error);
res.status(500).json({ error: 'Failed to get ROM list' });
}
});
// Check MCP server status
app.get('/api/status', (req: Request, res: Response) => {
try {
const romLoaded = emulatorService.isRomLoaded();
res.json({
connected: true, // Assume connected if UI is reachable
romLoaded,
romPath: emulatorService.getRomPath()
});
} catch (error) {
log.error('Error checking MCP server status:', error);
res.status(500).json({ error: 'Failed to check MCP server status' });
}
});
}
/**
* Sets up the ROM selection page
* @param app Express application
* @param emulatorService Emulator service instance (No longer needs emulator directly)
*/
export function setupRomSelectionUI(app: express.Application, emulatorService: EmulatorService): void {
// Create the ROM selection page
app.get('/', (req: Request, res: Response) => {
// No need to check emulatorService.isRomLoaded() here,
// let the user always see the selection page.
const romsDir = path.join(process.cwd(), 'roms');
let romFiles: { name: string; path: string }[] = [];
try {
if (!fs.existsSync(romsDir)) {
fs.mkdirSync(romsDir);
}
romFiles = fs.readdirSync(romsDir)
.filter(file => file.endsWith('.gb') || file.endsWith('.gbc'))
.map(file => ({
name: file,
// IMPORTANT: Use relative path for security and consistency
path: path.join('roms', file) // Use relative path from project root
}));
} catch (error) {
log.error("Error reading ROM directory:", error);
// Proceed with empty list if directory reading fails
}
// Render the ROM selection page
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>GameBoy ROM Selection</title>
<style>
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background-color: #f0f0f0; font-family: Arial, sans-serif; padding: 20px; box-sizing: border-box; }
h1 { margin-bottom: 20px; text-align: center; }
.container { max-width: 500px; width: 100%; }
.rom-list, .upload-form { width: 100%; border: 1px solid #ccc; border-radius: 5px; padding: 15px; background-color: white; margin-bottom: 20px; box-sizing: border-box; }
.rom-list { max-height: 400px; overflow-y: auto; }
.rom-item { padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; word-break: break-all; }
.rom-item:hover { background-color: #f5f5f5; }
.rom-item:last-child { border-bottom: none; }
.upload-form h2 { margin-top: 0; margin-bottom: 15px; }
.upload-form input[type="file"] { margin-bottom: 10px; display: block; width: 100%; }
.upload-form button { padding: 8px 15px; background-color: #4CAF50; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 1em; }
.upload-form button:hover { background-color: #45a049; }
.no-roms { text-align: center; color: #555; }
</style>
</head>
<body>
<div class="container">
<h1>Select a GameBoy ROM</h1>
<div class="rom-list">
${romFiles.length > 0
? romFiles.map(rom => `
<div class="rom-item" onclick="selectRom('${rom.path.replace(/\\/g, '\\\\')}')">
${rom.name}
</div>`).join('') // Escape backslashes for JS string literal
: '<p class="no-roms">No ROM files found in ./roms directory. Upload one below.</p>'
}
</div>
<div class="upload-form">
<h2>Upload a ROM</h2>
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="rom" accept=".gb,.gbc" required />
<button type="submit">Upload</button>
</form>
</div>
</div>
<script>
function selectRom(romPath) {
// Use the /gameboy endpoint which now handles loading via the service
window.location.href = '/gameboy?rom=' + encodeURIComponent(romPath);
}
</script>
</body>
</html>
`);
});
}
```