#
tokens: 15902/50000 17/17 files
lines: off (toggle) GitHub
raw markdown copy
# 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
[![smithery badge](https://smithery.ai/badge/@mario-andreschak/mcp-gameboy)](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

![Screenshot 2025-04-25 183528](https://github.com/user-attachments/assets/a248ef8a-73bb-4fc7-9c7f-7832cea34498)

![Screenshot 2025-04-25 081510](https://github.com/user-attachments/assets/dd47d7ea-fe93-4162-9da5-8da7d9aab469)

![image](https://github.com/user-attachments/assets/b9565920-b2ae-41d5-8609-59d832a90d44)


## 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>
    `);
  });
}

```