# 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:
--------------------------------------------------------------------------------

```
1 | # Server configuration
2 | SERVER_PORT=3001
3 | 
4 | # ROM path for stdio mode
5 | ROM_PATH=./roms/dangan.gb
6 | 
```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Dependencies
 2 | node_modules/
 3 | npm-debug.log
 4 | yarn-debug.log
 5 | yarn-error.log
 6 | 
 7 | # Build output
 8 | dist/
 9 | build/
10 | 
11 | # Environment variables
12 | .env
13 | .env.local
14 | .env.development.local
15 | .env.test.local
16 | .env.production.local
17 | 
18 | # Editor directories and files
19 | .idea/
20 | .vscode/
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw?
26 | 
27 | # OS generated files
28 | .DS_Store
29 | .DS_Store?
30 | ._*
31 | .Spotlight-V100
32 | .Trashes
33 | ehthumbs.db
34 | Thumbs.db
35 | 
36 | mcp-gameboy.log
37 | roms/example.gb
38 | roms/Pokemon Red.gb
39 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # MCP GameBoy Server
  2 | [![smithery badge](https://smithery.ai/badge/@mario-andreschak/mcp-gameboy)](https://smithery.ai/server/@mario-andreschak/mcp-gameboy)
  3 | 
  4 | <a href="https://glama.ai/mcp/servers/@mario-andreschak/mcp-gameboy">
  5 |   <img width="380" height="200" src="https://glama.ai/mcp/servers/@mario-andreschak/mcp-gameboy/badge" alt="GameBoy Server MCP server" />
  6 | </a>
  7 | 
  8 | ## Overview
  9 | A Model Context Protocol (MCP) server for serverboy, allowing LLMs to interact with a GameBoy emulator.
 10 | Your LLM can...
 11 | - Load ROMS
 12 | - Press Keys
 13 | - Look at the Gameboy Screen
 14 | - skip frames
 15 | 
 16 | You can...
 17 | - control the gameboy emulator using the @modelcontextprotocol/inspector
 18 | - control the gameboy emulator (and upload ROMs) using a web-interface at http://localhost:3001/emulator
 19 | - install the gameboy emulator in your favorite MCP-Client
 20 | 
 21 | ![Screenshot 2025-04-25 183528](https://github.com/user-attachments/assets/a248ef8a-73bb-4fc7-9c7f-7832cea34498)
 22 | 
 23 | ![Screenshot 2025-04-25 081510](https://github.com/user-attachments/assets/dd47d7ea-fe93-4162-9da5-8da7d9aab469)
 24 | 
 25 | ![image](https://github.com/user-attachments/assets/b9565920-b2ae-41d5-8609-59d832a90d44)
 26 | 
 27 | 
 28 | ## Features
 29 | 
 30 | - Supports both stdio and SSE transports
 31 | - Provides tools for GameBoy controls (up, down, left, right, A, B, start, select)
 32 | - Provides tools to load different ROMs
 33 | - Provides tools to get the current screen
 34 | - All tools return an ImageContent with the latest screen frame
 35 | 
 36 | ## Installation
 37 | 
 38 | ### Installing via Smithery
 39 | 
 40 | To install GameBoy Emulator Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@mario-andreschak/mcp-gameboy):
 41 | 
 42 | ```bash
 43 | npx -y @smithery/cli install @mario-andreschak/mcp-gameboy --client claude
 44 | ```
 45 | 
 46 | ### Installing in [FLUJO](https://github.com/mario-andreschak/FLUJO/)
 47 | 1. Click Add Server
 48 | 2. Copy & Paste Github URL into FLUJO
 49 | 3. Click Parse, Clone, Install, Build and Save.
 50 | 
 51 | ### Manual Installation
 52 | 
 53 | ```bash
 54 | # Clone the repository
 55 | git clone https://github.com/yourusername/mcp-gameboy.git
 56 | cd mcp-gameboy
 57 | 
 58 | # Install dependencies
 59 | npm install
 60 | 
 61 | # Build the project
 62 | npm run build
 63 | ```
 64 | 
 65 | ### Installing via Configuration Files
 66 | 
 67 | !! **ATTENTION** : Many MCP Clients require to specify the ROM-Path in the .env vars as an **absolute path**
 68 | 
 69 | To integrate this MCP server with Cline or other MCP clients via configuration files:
 70 | 
 71 | 1. Open your Cline settings:
 72 |    - In VS Code, go to File -> Preferences -> Settings
 73 |    - Search for "Cline MCP Settings"
 74 |    - Click "Edit in settings.json"
 75 | 
 76 | 2. Add the server configuration to the `mcpServers` object:
 77 |    ```json
 78 |    {
 79 |      "mcpServers": {
 80 |        "mcp-gameboy": {
 81 |          "command": "node",
 82 |          "args": [
 83 |            "/path/to/mcp-gameboy/dist/index.js"
 84 |          ],
 85 |          "disabled": false,
 86 |          "autoApprove": []
 87 |        }
 88 |      }
 89 |    }
 90 |    ```
 91 | 
 92 | 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.
 93 | 
 94 | 4. Save the settings file. Cline should automatically connect to the server.
 95 | 
 96 | 
 97 | ## Usage
 98 | 
 99 | ### Environment Variables
100 | !! **ATTENTION** : Many MCP Clients require to specify the ROM-Path in the .env vars as an **absolute path**
101 | 
102 | Create a `.env` file in the root directory with the following variables:
103 | 
104 | ```
105 | # Server configuration
106 | PORT=3001
107 | 
108 | # ROM path for stdio mode
109 | ROM_PATH=./roms/dangan.gb
110 | ```
111 | 
112 | ### Running in stdio Mode
113 | 
114 | 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.
115 | 
116 | ```bash
117 | npm run start
118 | ```
119 | 
120 | ### Running in SSE Mode
121 | 
122 | In SSE mode, the server starts an Express server that serves a web page for ROM selection.
123 | 
124 | ```bash
125 | npm run start-sse
126 | ```
127 | 
128 | Then open your browser to `http://localhost:3001` to select a ROM.
129 | 
130 | ## Tools
131 | 
132 | The server provides the following tools:
133 | 
134 | - `press_up`: Press the UP button on the GameBoy
135 | - `press_down`: Press the DOWN button on the GameBoy
136 | - `press_left`: Press the LEFT button on the GameBoy
137 | - `press_right`: Press the RIGHT button on the GameBoy
138 | - `press_a`: Press the A button on the GameBoy
139 | - `press_b`: Press the B button on the GameBoy
140 | - `press_start`: Press the START button on the GameBoy
141 | - `press_select`: Press the SELECT button on the GameBoy
142 | - `load_rom`: Load a GameBoy ROM file
143 | - `get_screen`: Get the current GameBoy screen
144 | 
145 | All tools return an ImageContent with the latest screen frame.
146 | 
147 | ## Implementation Details
148 | 
149 | This server is built using the Model Context Protocol (MCP) TypeScript SDK. It uses:
150 | 
151 | - `McpServer` from `@modelcontextprotocol/sdk/server/mcp.js` for the server implementation
152 | - `StdioServerTransport` from `@modelcontextprotocol/sdk/server/stdio.js` for stdio transport
153 | - `SSEServerTransport` from `@modelcontextprotocol/sdk/server/sse.js` for SSE transport
154 | - `serverboy` for the GameBoy emulation
155 | - `express` for the web server in SSE mode
156 | - `canvas` for rendering the GameBoy screen
157 | 
158 | ## License
159 | 
160 | MIT
161 | 
```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "compilerOptions": {
 3 |     "target": "ES2020",
 4 |     "module": "NodeNext",
 5 |     "moduleResolution": "NodeNext",
 6 |     "esModuleInterop": true,
 7 |     "strict": true,
 8 |     "noImplicitAny": true,
 9 |     "outDir": "dist",
10 |     "sourceMap": true,
11 |     "declaration": true,
12 |     "resolveJsonModule": true,
13 |     "rootDir": "src",
14 |     "skipLibCheck": true
15 |   },
16 |   "include": ["src/**/*"],
17 |   "exclude": ["node_modules", "dist"]
18 | }
19 | 
```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
 1 | # syntax=docker/dockerfile:1
 2 | FROM node:lts-alpine
 3 | 
 4 | # Install build dependencies for canvas
 5 | RUN apk add --no-cache python3 make g++ cairo-dev pango-dev jpeg-dev giflib-dev
 6 | 
 7 | # Set working directory
 8 | WORKDIR /app
 9 | 
10 | # Copy package files first for better caching
11 | COPY package.json package-lock.json ./
12 | 
13 | # Install all dependencies (including dev dependencies needed for build)
14 | RUN npm ci
15 | 
16 | # Copy source code and ROM files
17 | COPY . .
18 | 
19 | # Make sure the ROM directory exists
20 | RUN mkdir -p roms
21 | 
22 | # Build TypeScript
23 | RUN npm run build
24 | 
25 | # Create .env file with default configuration
26 | RUN echo "SERVER_PORT=3001\nROM_PATH=./roms/dangan.gb" > .env
27 | 
28 | # Default command uses stdio transport
29 | CMD ["node", "dist/index.js"]
30 | 
```

--------------------------------------------------------------------------------
/src/server/server.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
 2 | import { EmulatorService } from '../emulatorService'; // Import EmulatorService
 3 | import { registerGameBoyTools } from '../tools';
 4 | 
 5 | /**
 6 |  * Create a GameBoy MCP server
 7 |  * @param emulatorService Emulator service instance
 8 |  * @returns MCP server instance
 9 |  */
10 | export function createGameBoyServer(emulatorService: EmulatorService): McpServer {
11 |   // Create the server
12 |   const server = new McpServer(
13 |     {
14 |       name: 'serverboy',
15 |       version: '1.0.0',
16 |     },
17 |     {
18 |       capabilities: {
19 |         tools: {},
20 |       },
21 |     }
22 |   );
23 | 
24 |   // Register GameBoy tools
25 |   registerGameBoyTools(server, emulatorService); // Pass emulatorService
26 | 
27 |   return server;
28 | }
29 | 
```

--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { 
 2 |   Tool, 
 3 |   Resource, 
 4 |   ImageContent, 
 5 |   TextContent 
 6 | } from '@modelcontextprotocol/sdk/types.js';
 7 | 
 8 | // GameBoy button types
 9 | export enum GameBoyButton {
10 |   UP = 'UP',
11 |   DOWN = 'DOWN',
12 |   LEFT = 'LEFT',
13 |   RIGHT = 'RIGHT',
14 |   A = 'A',
15 |   B = 'B',
16 |   START = 'START',
17 |   SELECT = 'SELECT'
18 | }
19 | 
20 | // Tool schemas
21 | export interface PressButtonToolSchema {
22 |   button: GameBoyButton;
23 |   duration_frames?: number;
24 | }
25 | 
26 | export interface WaitFramesToolSchema {
27 |   duration_frames: number;
28 | }
29 | 
30 | export interface LoadRomToolSchema {
31 |   romPath: string;
32 | }
33 | 
34 | export interface GetScreenToolSchema {
35 |   // No parameters needed
36 | }
37 | 
38 | // Tool response types
39 | export interface GameBoyToolResponse {
40 |   screen: ImageContent;
41 | }
42 | 
43 | // Server configuration
44 | export interface GameBoyServerConfig {
45 |   romPath?: string;
46 |   port?: number;
47 | }
48 | 
49 | // Session state
50 | export interface GameBoySession {
51 |   romLoaded: boolean;
52 |   romPath?: string;
53 | }
54 | 
```

--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------

```yaml
 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
 2 | 
 3 | startCommand:
 4 |   type: stdio
 5 |   configSchema:
 6 |     # JSON Schema defining the configuration options for the MCP.
 7 |     type: object
 8 |     required:
 9 |       - romPath
10 |     properties:
11 |       romPath:
12 |         type: string
13 |         default: ./roms/dangan.gb
14 |         description: Path to the GameBoy ROM file
15 |       mode:
16 |         type: string
17 |         default: stdio
18 |         description: "Transport mode: stdio or sse"
19 |       port:
20 |         type: number
21 |         default: 3000
22 |         description: Port for SSE mode
23 |   commandFunction:
24 |     # A JS function that produces the CLI command based on the given config to start the MCP on stdio.
25 |     |-
26 |     (config) => {
27 |       const args = [];
28 |       if (config.mode === 'sse') {
29 |         args.push('--sse');
30 |       }
31 |       return {
32 |         command: 'node',
33 |         args: ['dist/index.js', ...args],
34 |         env: {
35 |           ROM_PATH: config.romPath,
36 |           PORT: String(config.port)
37 |         }
38 |       };
39 |     }
40 |   exampleConfig:
41 |     romPath: ./roms/dangan.gb
42 |     mode: stdio
43 |     port: 3000
44 | 
```

--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { startStdioServer } from './server/stdio';
 2 | import { startSseServer } from './server/sse';
 3 | import dotenv from 'dotenv';
 4 | import { log } from './utils/logger';
 5 | 
 6 | // Load environment variables from .env file
 7 | dotenv.config();
 8 | 
 9 | /**
10 |  * Main entry point
11 |  */
12 | async function main(): Promise<void> {
13 |   // Parse command-line arguments
14 |   const args = process.argv.slice(2);
15 |   const isStdio = args.includes('--stdio');
16 |   const isSse = args.includes('--sse');
17 |   
18 |   // Get the SSE port from environment variable or use default
19 |   const ssePort = process.env.SERVER_PORT ? parseInt(process.env.SERVER_PORT, 10) : 3001;
20 |   
21 |   // Start the appropriate server
22 |   if (isStdio) {
23 |     log.info('Starting GameBoy MCP server in stdio mode');
24 |     await startStdioServer();
25 |   } else if (isSse) {
26 |     log.info(`Starting GameBoy MCP server in SSE mode on port ${ssePort}`);
27 |     await startSseServer(ssePort);
28 |   } else {
29 |     // Default to stdio mode
30 |     log.info('No mode specified, defaulting to stdio mode');
31 |     await startStdioServer();
32 |   }
33 | }
34 | 
35 | // Run the main function
36 | main().catch(error => {
37 |   log.error(`Error: ${error}`);
38 |   process.exit(1);
39 | });
40 | 
```

--------------------------------------------------------------------------------
/src/gameboy.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { GameBoyButton } from './types';
  2 | import * as fs from 'fs';
  3 | import * as path from 'path';
  4 | import { createCanvas, Canvas } from 'canvas';
  5 | import { log } from './utils/logger';
  6 | 
  7 | // Import the serverboy library
  8 | const Gameboy = require('serverboy');
  9 | 
 10 | export class GameBoyEmulator {
 11 |   private gameboy: any;
 12 |   private canvas: Canvas;
 13 |   private romLoaded: boolean = false;
 14 |   private romPath?: string;
 15 | 
 16 |   constructor() {
 17 |     this.gameboy = new Gameboy();
 18 |     // Create a canvas for rendering the screen
 19 |     this.canvas = createCanvas(160, 144);
 20 |   }
 21 | 
 22 |   /**
 23 |    * Load a ROM file
 24 |    * @param romPath Path to the ROM file
 25 |    */
 26 |   public loadRom(romPath: string): void {
 27 |     try {
 28 |       const rom = fs.readFileSync(romPath);
 29 |       this.gameboy.loadRom(rom);
 30 |       this.romLoaded = true;
 31 |       this.romPath = romPath;
 32 |       log.info(`ROM loaded: ${path.basename(romPath)}`);
 33 |     } catch (error) {
 34 |       log.error(`Error loading ROM: ${error}`);
 35 |       throw new Error(`Failed to load ROM: ${error}`);
 36 |     }
 37 |   }
 38 | 
 39 |   /**
 40 |    * Press a button on the GameBoy
 41 |    * @param button Button to press
 42 |    */
 43 |   public pressButton(button: GameBoyButton, durationFrames: number = 1): void {
 44 |     if (!this.romLoaded) {
 45 |       throw new Error('No ROM loaded');
 46 |     }
 47 | 
 48 |     // Map our button enum to serverboy's keymap
 49 |     const buttonMap: Record<GameBoyButton, number> = {
 50 |       [GameBoyButton.UP]: Gameboy.KEYMAP.UP,
 51 |       [GameBoyButton.DOWN]: Gameboy.KEYMAP.DOWN,
 52 |       [GameBoyButton.LEFT]: Gameboy.KEYMAP.LEFT,
 53 |       [GameBoyButton.RIGHT]: Gameboy.KEYMAP.RIGHT,
 54 |       [GameBoyButton.A]: Gameboy.KEYMAP.A,
 55 |       [GameBoyButton.B]: Gameboy.KEYMAP.B,
 56 |       [GameBoyButton.START]: Gameboy.KEYMAP.START,
 57 |       [GameBoyButton.SELECT]: Gameboy.KEYMAP.SELECT
 58 |     };
 59 | 
 60 |     for (let i=0; i < durationFrames; i++) {
 61 |       this.gameboy.pressKeys([buttonMap[button]]);
 62 |       this.gameboy.doFrame();
 63 |     }
 64 | 
 65 |     // for now: advance one frame so we dont "hold" the button all the time.
 66 |     this.gameboy.doFrame();
 67 |   }
 68 | 
 69 |   /**
 70 |    * Advance the emulation by one frame
 71 |    */
 72 |   public doFrame(): void {
 73 |     if (!this.romLoaded) {
 74 |       throw new Error('No ROM loaded');
 75 |     }
 76 |     this.gameboy.doFrame();
 77 |   }
 78 | 
 79 |   /**
 80 |    * Get the current screen as a base64 encoded PNG
 81 |    * @returns Base64 encoded PNG image
 82 |    */
 83 |   public getScreenAsBase64(): string {
 84 |     if (!this.romLoaded) {
 85 |       throw new Error('No ROM loaded');
 86 |     }
 87 | 
 88 |     // Get the raw screen data
 89 |     const screenData = this.gameboy.getScreen();
 90 |     
 91 |     // Draw to canvas
 92 |     const ctx = this.canvas.getContext('2d');
 93 |     const imageData = ctx.createImageData(160, 144);
 94 |     
 95 |     for (let i = 0; i < screenData.length; i++) {
 96 |       imageData.data[i] = screenData[i];
 97 |     }
 98 |     
 99 |     ctx.putImageData(imageData, 0, 0);
100 |     
101 |     // Convert to base64 PNG
102 |     return this.canvas.toDataURL('image/png').split(',')[1];
103 |   }
104 | 
105 |   /**
106 |    * Get the current ROM path
107 |    * @returns Current ROM path or undefined if no ROM is loaded
108 |    */
109 |   public getRomPath(): string | undefined {
110 |     return this.romPath;
111 |   }
112 | 
113 |   /**
114 |    * Check if a ROM is loaded
115 |    * @returns True if a ROM is loaded, false otherwise
116 |    */
117 |   public isRomLoaded(): boolean {
118 |     return this.romLoaded;
119 |   }
120 | }
121 | 
```

--------------------------------------------------------------------------------
/src/utils/logger.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import * as fs from 'fs';
  2 | import * as path from 'path';
  3 | import { string } from 'zod';
  4 | 
  5 | const logFilePath = './mcp-gameboy.log';
  6 | // const logFilePath = path.join(process.cwd(), 'mcp-gameboy.log');
  7 | 
  8 | // Ensure the log file exists
  9 | try {
 10 |   fs.appendFileSync(logFilePath, ''); // Create file if it doesn't exist, or just touch it
 11 | } catch (err) {
 12 |   console.error('CRITICAL FAILURE: Failed to ensure log file exists:', err); // Use console here as logger isn't ready
 13 | }
 14 | 
 15 | type LogLevel = 'INFO' | 'ERROR' | 'WARN' | 'DEBUG' | 'VERBOSE';
 16 | 
 17 | function writeLog(level: LogLevel, message: string, ...optionalParams: any[]) {
 18 |   const timestamp = new Date().toISOString();
 19 |   let logEntry = `${timestamp} [${level}] ${message}`;
 20 | 
 21 |   if (optionalParams.length > 0) {
 22 |     // Handle additional parameters, ensuring objects are stringified for VERBOSE
 23 |     const formattedParams = optionalParams.map(param => {
 24 |       if (level === 'VERBOSE' && typeof param === 'object' && param !== null) {
 25 |         try {
 26 |           // Use JSON.stringify for verbose objects as per requirement
 27 |           return JSON.stringify(param);
 28 |         } catch (e) {
 29 |           return '[Unserializable Object]';
 30 |         }
 31 |       } else if (typeof param === 'object' && param !== null) {
 32 |          // For other levels, use a simpler representation or toString()
 33 |          return param instanceof Error ? param.stack || param.message : JSON.stringify(param); // Show stack for errors
 34 |       } else {
 35 |         return String(param);
 36 |       }
 37 |     });
 38 |     logEntry += ` ${formattedParams.join(' ')}`;
 39 |   }
 40 | 
 41 |   logEntry += '\n'; // Add newline for separation
 42 | 
 43 |   try {
 44 |     fs.appendFileSync(logFilePath, logEntry);
 45 |   } catch (err) {
 46 |     // Fallback to console if file logging fails
 47 |     console.error(`Failed to write to log file: ${logFilePath}`, err);
 48 |   }
 49 | }
 50 | 
 51 | /**
 52 |  * Logger utility for MCP-GameBoy
 53 |  * Writes logs to a file instead of console output
 54 |  */
 55 | export const log = {
 56 |   /**
 57 |    * Log informational messages
 58 |    * @param message The message to log
 59 |    * @param optionalParams Additional parameters to log
 60 |    */
 61 |   info: (message: string, ...optionalParams: any[]) => {
 62 |     writeLog('INFO', message, ...optionalParams);
 63 |   },
 64 | 
 65 |   /**
 66 |    * Log error messages
 67 |    * @param message The message to log
 68 |    * @param optionalParams Additional parameters to log
 69 |    */
 70 |   error: (message: string, ...optionalParams: any[]) => {
 71 |     console.error(message)
 72 |     writeLog('ERROR', message, ...optionalParams);
 73 |   },
 74 | 
 75 |   /**
 76 |    * Log warning messages
 77 |    * @param message The message to log
 78 |    * @param optionalParams Additional parameters to log
 79 |    */
 80 |   warn: (message: string, ...optionalParams: any[]) => {
 81 |     writeLog('WARN', message, ...optionalParams);
 82 |   },
 83 | 
 84 |   /**
 85 |    * Log debug messages
 86 |    * @param message The message to log
 87 |    * @param optionalParams Additional parameters to log
 88 |    */
 89 |   debug: (message: string, ...optionalParams: any[]) => {
 90 |     writeLog('DEBUG', message, ...optionalParams);
 91 |   },
 92 | 
 93 |   /**
 94 |    * Log verbose messages
 95 |    * Objects passed to this method will be serialized with JSON.stringify
 96 |    * @param message The message to log
 97 |    * @param optionalParams Additional parameters to log (objects will be JSON.stringify'd)
 98 |    */
 99 |   verbose: (message: string, ...optionalParams: any[]) => {
100 |     writeLog('VERBOSE', message, ...optionalParams);
101 |   }
102 | };
103 | 
```

--------------------------------------------------------------------------------
/src/emulatorService.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { GameBoyEmulator } from './gameboy';
  2 | import { GameBoyButton } from './types';
  3 | import { ImageContent } from '@modelcontextprotocol/sdk/types.js';
  4 | import * as fs from 'fs';
  5 | import * as path from 'path';
  6 | import { log } from './utils/logger';
  7 | 
  8 | /**
  9 |  * Service class to encapsulate GameBoyEmulator interactions.
 10 |  */
 11 | export class EmulatorService {
 12 |   private emulator: GameBoyEmulator;
 13 | 
 14 |   constructor(emulator: GameBoyEmulator) {
 15 |     this.emulator = emulator;
 16 |     log.info('EmulatorService initialized');
 17 |   }
 18 | 
 19 |   /**
 20 |    * Checks if a ROM is currently loaded.
 21 |    * @returns True if a ROM is loaded, false otherwise.
 22 |    */
 23 |   isRomLoaded(): boolean {
 24 |     return this.emulator.isRomLoaded();
 25 |   }
 26 | 
 27 |   /**
 28 |    * Gets the path of the currently loaded ROM.
 29 |    * @returns The ROM path or undefined if no ROM is loaded.
 30 |    */
 31 |   getRomPath(): string | undefined {
 32 |     return this.emulator.getRomPath();
 33 |   }
 34 | 
 35 |   /**
 36 |    * Loads a GameBoy ROM file.
 37 |    * @param romPath Path to the ROM file.
 38 |    * @returns The initial screen content after loading.
 39 |    * @throws Error if the ROM file doesn't exist or fails to load.
 40 |    */
 41 |   loadRom(romPath: string): ImageContent {
 42 |     log.info(`Attempting to load ROM: ${romPath}`);
 43 |     if (!fs.existsSync(romPath)) {
 44 |       log.error(`ROM file not found: ${romPath}`);
 45 |       throw new Error(`ROM file not found: ${romPath}`);
 46 |     }
 47 | 
 48 |     try {
 49 |       this.emulator.loadRom(romPath);
 50 |       log.info(`ROM loaded successfully: ${path.basename(romPath)}`);
 51 | 
 52 |       // Advance a few frames to initialize the screen
 53 |       for (let i = 0; i < 5; i++) {
 54 |         this.emulator.doFrame();
 55 |       }
 56 |       log.verbose('Advanced initial frames after ROM load');
 57 | 
 58 |       return this.getScreen();
 59 |     } catch (error) {
 60 |       log.error(`Error loading ROM: ${romPath}`, error instanceof Error ? error.message : String(error));
 61 |       throw new Error(`Failed to load ROM: ${romPath}. Reason: ${error instanceof Error ? error.message : String(error)}`);
 62 |     }
 63 |   }
 64 | 
 65 |   /**
 66 |    * Presses a GameBoy button for a single frame.
 67 |    * @param button The button to press.
 68 |    * @param durationFrames The number of frames to press the button.
 69 |    * @returns The screen content after pressing the button.
 70 |    * @throws Error if no ROM is loaded.
 71 |    */
 72 |   pressButton(button: GameBoyButton, durationFrames: number): ImageContent {
 73 |     log.debug(`Pressing button: ${button}`);
 74 |     if (!this.isRomLoaded()) {
 75 |       log.warn('Attempted to press button with no ROM loaded');
 76 |       throw new Error('No ROM loaded');
 77 |     }
 78 |     this.emulator.pressButton(button, durationFrames); // This advances one frame
 79 |     return this.getScreen();
 80 |   }
 81 | 
 82 |   /**
 83 |    * Waits (advances) for a specified number of frames.
 84 |    * @param durationFrames The number of frames to wait.
 85 |    * @returns The screen content after waiting.
 86 |    * @throws Error if no ROM is loaded.
 87 |    */
 88 |   waitFrames(durationFrames: number): ImageContent {
 89 |     log.debug(`Waiting for ${durationFrames} frames`);
 90 |     if (!this.isRomLoaded()) {
 91 |       log.warn('Attempted to wait frames with no ROM loaded');
 92 |       throw new Error('No ROM loaded');
 93 |     }
 94 |     for (let i = 0; i < durationFrames; i++) {
 95 |       this.emulator.doFrame();
 96 |     }
 97 |     log.verbose(`Waited ${durationFrames} frames`, JSON.stringify({ frames: durationFrames }));
 98 |     return this.getScreen();
 99 |   }
100 | 
101 |   /**
102 |    * Gets the current GameBoy screen as base64 PNG data.
103 |    * Does NOT advance a frame.
104 |    * @returns The screen content.
105 |    * @throws Error if no ROM is loaded.
106 |    */
107 |   getScreen(): ImageContent {
108 |     log.verbose('Getting current screen');
109 |     if (!this.isRomLoaded()) {
110 |       log.warn('Attempted to get screen with no ROM loaded');
111 |       throw new Error('No ROM loaded');
112 |     }
113 |     const screenBase64 = this.emulator.getScreenAsBase64();
114 |     const screen: ImageContent = {
115 |       type: 'image',
116 |       data: screenBase64,
117 |       mimeType: 'image/png'
118 |     };
119 |     log.verbose('Screen data retrieved', JSON.stringify({ mimeType: screen.mimeType, dataLength: screen.data.length }));
120 |     return screen;
121 |   }
122 | 
123 |   /**
124 |    * Advances the emulator by one frame and returns the new screen.
125 |    * @returns The screen content after advancing one frame.
126 |    * @throws Error if no ROM is loaded.
127 |    */
128 |   advanceFrameAndGetScreen(): ImageContent {
129 |     log.verbose('Advancing one frame and getting screen');
130 |     if (!this.isRomLoaded()) {
131 |       log.warn('Attempted to advance frame with no ROM loaded');
132 |       throw new Error('No ROM loaded');
133 |     }
134 |     this.emulator.doFrame();
135 |     return this.getScreen();
136 |   }
137 | }
138 | 
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
  1 | {
  2 |   "name": "mcp-gameboy",
  3 |   "version": "1.0.0",
  4 |   "description": "A gameboy emulator for LLM's",
  5 |   "main": "index.js",
  6 |   "dependencies": {
  7 |     "@modelcontextprotocol/sdk": "^1.10.2",
  8 |     "@types/multer": "^1.4.12",
  9 |     "accepts": "^2.0.0",
 10 |     "acorn": "^8.14.1",
 11 |     "acorn-walk": "^8.3.4",
 12 |     "agent-base": "^7.1.3",
 13 |     "arg": "^4.1.3",
 14 |     "body-parser": "^2.2.0",
 15 |     "bytes": "^3.1.2",
 16 |     "call-bind-apply-helpers": "^1.0.2",
 17 |     "call-bound": "^1.0.4",
 18 |     "canvas": "^3.1.0",
 19 |     "content-disposition": "^1.0.0",
 20 |     "content-type": "^1.0.5",
 21 |     "cookie": "^0.7.2",
 22 |     "cookie-signature": "^1.2.2",
 23 |     "cors": "^2.8.5",
 24 |     "create-require": "^1.1.1",
 25 |     "cross-spawn": "^7.0.6",
 26 |     "cssstyle": "^4.3.1",
 27 |     "data-urls": "^5.0.0",
 28 |     "debug": "^4.4.0",
 29 |     "decimal.js": "^10.5.0",
 30 |     "depd": "^2.0.0",
 31 |     "diff": "^4.0.2",
 32 |     "dotenv": "^16.5.0",
 33 |     "dunder-proto": "^1.0.1",
 34 |     "ee-first": "^1.1.1",
 35 |     "encodeurl": "^2.0.0",
 36 |     "entities": "^6.0.0",
 37 |     "es-define-property": "^1.0.1",
 38 |     "es-errors": "^1.3.0",
 39 |     "es-object-atoms": "^1.1.1",
 40 |     "escape-html": "^1.0.3",
 41 |     "etag": "^1.8.1",
 42 |     "eventsource": "^3.0.6",
 43 |     "eventsource-parser": "^3.0.1",
 44 |     "express": "^5.1.0",
 45 |     "express-rate-limit": "^7.5.0",
 46 |     "finalhandler": "^2.1.0",
 47 |     "forwarded": "^0.2.0",
 48 |     "fresh": "^2.0.0",
 49 |     "function-bind": "^1.1.2",
 50 |     "gameboy-emulator": "^1.1.2",
 51 |     "get-intrinsic": "^1.3.0",
 52 |     "get-proto": "^1.0.1",
 53 |     "gopd": "^1.2.0",
 54 |     "has-symbols": "^1.1.0",
 55 |     "hasown": "^2.0.2",
 56 |     "html-encoding-sniffer": "^4.0.0",
 57 |     "http-errors": "^2.0.0",
 58 |     "http-proxy-agent": "^7.0.2",
 59 |     "https-proxy-agent": "^7.0.6",
 60 |     "iconv-lite": "^0.6.3",
 61 |     "inherits": "^2.0.4",
 62 |     "ipaddr.js": "^1.9.1",
 63 |     "is-potential-custom-element-name": "^1.0.1",
 64 |     "is-promise": "^4.0.0",
 65 |     "isexe": "^2.0.0",
 66 |     "jsdom": "^26.1.0",
 67 |     "lru-cache": "^10.4.3",
 68 |     "make-error": "^1.3.6",
 69 |     "math-intrinsics": "^1.1.0",
 70 |     "media-typer": "^1.1.0",
 71 |     "merge-descriptors": "^2.0.0",
 72 |     "mime-db": "^1.54.0",
 73 |     "mime-types": "^3.0.1",
 74 |     "ms": "^2.1.3",
 75 |     "multer": "^1.4.5-lts.2",
 76 |     "negotiator": "^1.0.0",
 77 |     "nwsapi": "^2.2.20",
 78 |     "object-assign": "^4.1.1",
 79 |     "object-inspect": "^1.13.4",
 80 |     "on-finished": "^2.4.1",
 81 |     "once": "^1.4.0",
 82 |     "open": "^10.1.1",
 83 |     "parse5": "^7.3.0",
 84 |     "parseurl": "^1.3.3",
 85 |     "path-key": "^3.1.1",
 86 |     "path-to-regexp": "^8.2.0",
 87 |     "pkce-challenge": "^5.0.0",
 88 |     "proxy-addr": "^2.0.7",
 89 |     "punycode": "^2.3.1",
 90 |     "qs": "^6.14.0",
 91 |     "range-parser": "^1.2.1",
 92 |     "raw-body": "^3.0.0",
 93 |     "router": "^2.2.0",
 94 |     "rrweb-cssom": "^0.8.0",
 95 |     "safe-buffer": "^5.2.1",
 96 |     "safer-buffer": "^2.1.2",
 97 |     "saxes": "^6.0.0",
 98 |     "send": "^1.2.0",
 99 |     "serve-static": "^2.2.0",
100 |     "serverboy": "^0.0.7",
101 |     "setprototypeof": "^1.2.0",
102 |     "shebang-command": "^2.0.0",
103 |     "shebang-regex": "^3.0.0",
104 |     "side-channel": "^1.1.0",
105 |     "side-channel-list": "^1.0.0",
106 |     "side-channel-map": "^1.0.1",
107 |     "side-channel-weakmap": "^1.0.2",
108 |     "statuses": "^2.0.1",
109 |     "symbol-tree": "^3.2.4",
110 |     "tldts": "^6.1.86",
111 |     "tldts-core": "^6.1.86",
112 |     "toidentifier": "^1.0.1",
113 |     "tough-cookie": "^5.1.2",
114 |     "tr46": "^5.1.1",
115 |     "ts-node": "^10.9.2",
116 |     "type-is": "^2.0.1",
117 |     "typescript": "^5.8.3",
118 |     "undici-types": "^6.21.0",
119 |     "unpipe": "^1.0.0",
120 |     "v8-compile-cache-lib": "^3.0.1",
121 |     "vary": "^1.1.2",
122 |     "w3c-xmlserializer": "^5.0.0",
123 |     "webidl-conversions": "^7.0.0",
124 |     "whatwg-encoding": "^3.1.1",
125 |     "whatwg-mimetype": "^4.0.0",
126 |     "whatwg-url": "^14.2.0",
127 |     "which": "^2.0.2",
128 |     "wrappy": "^1.0.2",
129 |     "ws": "^8.18.1",
130 |     "xml-name-validator": "^5.0.0",
131 |     "xmlchars": "^2.2.0",
132 |     "yn": "^3.1.1",
133 |     "zod": "^3.24.3",
134 |     "zod-to-json-schema": "^3.24.5"
135 |   },
136 |   "scripts": {
137 |     "test": "echo \"Error: no test specified\" && exit 1",
138 |     "build": "tsc",
139 |     "start": "node dist/index.js",
140 |     "start-sse": "node dist/index.js --sse",
141 |     "dev": "ts-node src/index.ts",
142 |     "debug": "tsc && npx @modelcontextprotocol/inspector node dist/index.js --stdio",
143 |     "debug-sse": "tsc && @modelcontextprotocol/inspector node dist/index.js --sse"
144 |   },
145 |   "repository": {
146 |     "type": "git",
147 |     "url": "git+https://github.com/mario-andreschak/mcp-gameboy.git"
148 |   },
149 |   "keywords": [],
150 |   "author": "",
151 |   "license": "ISC",
152 |   "type": "commonjs",
153 |   "bugs": {
154 |     "url": "https://github.com/mario-andreschak/mcp-gameboy/issues"
155 |   },
156 |   "homepage": "https://github.com/mario-andreschak/mcp-gameboy#readme",
157 |   "devDependencies": {
158 |     "@types/express": "^5.0.1"
159 |   }
160 | }
161 | 
```

--------------------------------------------------------------------------------
/src/tools.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { 
  2 |   CallToolResult,
  3 |   ImageContent,
  4 |   TextContent
  5 | } from '@modelcontextprotocol/sdk/types.js';
  6 | import { z } from 'zod';
  7 | import { GameBoyButton } from './types';
  8 | import { EmulatorService } from './emulatorService'; // Import EmulatorService
  9 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
 10 | import * as fs from 'fs';
 11 | import * as path from 'path';
 12 | import { log } from './utils/logger';
 13 | 
 14 | /**
 15 |  * Register GameBoy tools with the MCP server
 16 |  * @param server MCP server instance
 17 |  * @param emulatorService Emulator service instance
 18 |  */
 19 | export function registerGameBoyTools(server: McpServer, emulatorService: EmulatorService): void {
 20 |   // Register button press tools
 21 |   Object.values(GameBoyButton).forEach(button => {
 22 |     server.tool(
 23 |       `press_${button.toLowerCase()}`,
 24 |       `Press the ${button} button on the GameBoy`,
 25 |       {
 26 |         duration_frames: z.number().int().positive().optional().default(1).describe('Number of frames to hold the button').default(25)
 27 |       },
 28 |       async ({ duration_frames }): Promise<CallToolResult> => {
 29 |         // Press the button using the service (advances one frame)
 30 |         emulatorService.pressButton(button, duration_frames);
 31 | 
 32 |         // Return the current screen using the service
 33 |         const screen = emulatorService.getScreen();
 34 |         return { content: [screen] };
 35 |       }
 36 |     );
 37 |   });
 38 | 
 39 |   // Register wait_frames tool
 40 |   server.tool(
 41 |     'wait_frames',
 42 |     'Wait for a specified number of frames',
 43 |     {
 44 |       duration_frames: z.number().int().positive().describe('Number of frames to wait').default(100)
 45 |     },
 46 |     async ({ duration_frames }): Promise<CallToolResult> => {
 47 |       // Wait for frames using the service
 48 |       const screen = emulatorService.waitFrames(duration_frames);
 49 |       return { content: [screen] };
 50 |     }
 51 |   );
 52 | 
 53 |   // Register load ROM tool
 54 |   server.tool(
 55 |     'load_rom',
 56 |     'Load a GameBoy ROM file',
 57 |     {
 58 |       romPath: z.string().describe('Path to the ROM file')
 59 |     },
 60 |     async ({ romPath }): Promise<CallToolResult> => {
 61 |       // Load ROM using the service (already advances initial frames)
 62 |       const screen = emulatorService.loadRom(romPath);
 63 |       return { content: [screen] };
 64 |     }
 65 |   );
 66 | 
 67 |   // Register get screen tool
 68 |   server.tool(
 69 |     'get_screen',
 70 |     'Get the current GameBoy screen (advances one frame)', // Updated description
 71 |     {},
 72 |     async (): Promise<CallToolResult> => {
 73 |       // Advance one frame and get the screen using the service
 74 |       const screen = emulatorService.advanceFrameAndGetScreen();
 75 |       return { content: [screen] };
 76 |     }
 77 |   );
 78 | 
 79 |   // Register is_rom_loaded tool
 80 |   server.tool(
 81 |     'is_rom_loaded',
 82 |     'Check if a ROM is currently loaded in the emulator',
 83 |     {},
 84 |     async (): Promise<CallToolResult> => {
 85 |       const isLoaded = emulatorService.isRomLoaded();
 86 |       const romPath = emulatorService.getRomPath();
 87 |       
 88 |       const responseText: TextContent = {
 89 |         type: 'text',
 90 |         text: JSON.stringify({
 91 |           romLoaded: isLoaded,
 92 |           romPath: romPath || null
 93 |         })
 94 |       };
 95 |       
 96 |       log.verbose('Checked ROM loaded status', JSON.stringify({ 
 97 |         romLoaded: isLoaded, 
 98 |         romPath: romPath || null 
 99 |       }));
100 |       
101 |       return { content: [responseText] };
102 |     }
103 |   );
104 | 
105 |   // Register list_roms tool
106 |   server.tool(
107 |     'list_roms',
108 |     'List all available GameBoy ROM files',
109 |     {},
110 |     async (): Promise<CallToolResult> => {
111 |       try {
112 |         const romsDir = path.join(process.cwd(), 'roms');
113 |         
114 |         // Create roms directory if it doesn't exist
115 |         if (!fs.existsSync(romsDir)) {
116 |           fs.mkdirSync(romsDir);
117 |           log.info('Created roms directory');
118 |         }
119 |         
120 |         // Get list of ROM files
121 |         const romFiles = fs.readdirSync(romsDir)
122 |           .filter(file => file.endsWith('.gb') || file.endsWith('.gbc'))
123 |           .map(file => ({
124 |             name: file,
125 |             path: path.join(romsDir, file)
126 |           }));
127 |         
128 |         const responseText: TextContent = {
129 |           type: 'text',
130 |           text: JSON.stringify(romFiles)
131 |         };
132 |         
133 |         log.verbose('Listed available ROMs', JSON.stringify({ 
134 |           count: romFiles.length, 
135 |           roms: romFiles 
136 |         }));
137 |         
138 |         return { content: [responseText] };
139 |       } catch (error) {
140 |         log.error('Error listing ROMs:', error instanceof Error ? error.message : String(error));
141 |         
142 |         const errorText: TextContent = {
143 |           type: 'text',
144 |           text: JSON.stringify({
145 |             error: 'Failed to list ROMs',
146 |             message: error instanceof Error ? error.message : String(error)
147 |           })
148 |         };
149 |         
150 |         return { content: [errorText] };
151 |       }
152 |     }
153 |   );
154 | }
155 | 
```

--------------------------------------------------------------------------------
/src/server/stdio.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
  2 | import { GameBoyEmulator } from '../gameboy';
  3 | import { EmulatorService } from '../emulatorService'; // Import EmulatorService
  4 | import { createGameBoyServer } from './server';
  5 | import * as path from 'path';
  6 | import * as fs from 'fs';
  7 | import open from 'open';
  8 | import express, { Request, Response } from 'express'; // Import Request, Response
  9 | import http from 'http';
 10 | import multer from 'multer'; // Import multer
 11 | import { setupWebUI, setupRomSelectionUI } from '../ui'; // Import setupRomSelectionUI
 12 | import { log } from '../utils/logger';
 13 | 
 14 | /**
 15 |  * Start the GameBoy MCP server in stdio mode
 16 |  */
 17 | export async function startStdioServer(): Promise<void> {
 18 |   // Create the emulator
 19 |   const emulator = new GameBoyEmulator();
 20 |   // Create the emulator service
 21 |   const emulatorService = new EmulatorService(emulator);
 22 |   
 23 |   // Create the server using the service
 24 |   const server = createGameBoyServer(emulatorService);
 25 |   
 26 |   // Check for ROM path in environment variable
 27 |   const romPath = process.env.ROM_PATH;
 28 |   if (!romPath) {
 29 |     log.error('ROM_PATH environment variable not set');
 30 |     process.exit(1);
 31 |   }
 32 |   
 33 |   // Check if the ROM file exists
 34 |   if (!fs.existsSync(romPath)) {
 35 |     log.error(`ROM file not found: ${romPath}`);
 36 |     process.exit(1);
 37 |   }
 38 |   
 39 |   // Load the ROM using the service
 40 |   try {
 41 |     emulatorService.loadRom(romPath); // Use service to load ROM
 42 |     log.info(`ROM loaded: ${path.basename(romPath)}`);
 43 |     
 44 |     // Create an Express app to serve the GameBoy screen
 45 |     const app = express();
 46 |     const port = process.env.SERVER_PORT ? parseInt(process.env.SERVER_PORT, 10) : 3000;
 47 |     
 48 |     // Add middleware to parse JSON bodies
 49 |     app.use(express.json());
 50 |     app.use(express.static(path.join(process.cwd(), 'public'))); // Serve static files if needed by UI
 51 | 
 52 |     // Configure multer for file uploads
 53 |     const storage = multer.diskStorage({
 54 |       destination: (req, file, cb) => {
 55 |         const romsDir = path.join(process.cwd(), 'roms');
 56 |         if (!fs.existsSync(romsDir)) {
 57 |           fs.mkdirSync(romsDir);
 58 |         }
 59 |         cb(null, romsDir);
 60 |       },
 61 |       filename: (req, file, cb) => {
 62 |         cb(null, file.originalname);
 63 |       }
 64 |     });
 65 |     const upload = multer({ storage });
 66 | 
 67 |     // Set up the main web UI (emulator screen)
 68 |     setupWebUI(app, emulatorService);
 69 | 
 70 |     // Set up the ROM selection UI
 71 |     setupRomSelectionUI(app, emulatorService);
 72 | 
 73 |     // Handle ROM upload
 74 |     app.post('/upload', upload.single('rom'), (req: Request, res: Response) => {
 75 |       // Redirect back to the ROM selection page after upload
 76 |       res.redirect('/');
 77 |     });
 78 | 
 79 |     // Add the /gameboy route to handle loading a ROM selected from the UI
 80 |     app.get('/gameboy', (req: Request, res: Response) => {
 81 |       const relativeRomPath = req.query.rom as string;
 82 | 
 83 |       if (!relativeRomPath) {
 84 |         log.error('[stdio /gameboy] No ROM path provided in query.');
 85 |         res.redirect('/'); // Redirect to selection if no path
 86 |         return;
 87 |       }
 88 | 
 89 |       const absoluteRomPath = path.resolve(process.cwd(), relativeRomPath);
 90 |       log.info(`[stdio /gameboy] Received relative path: ${relativeRomPath}`);
 91 |       log.info(`[stdio /gameboy] Resolved to absolute path: ${absoluteRomPath}`);
 92 | 
 93 |       if (!fs.existsSync(absoluteRomPath)) {
 94 |         log.error(`[stdio /gameboy] ROM file not found at absolute path: ${absoluteRomPath}`);
 95 |         res.status(404).send(`ROM not found: ${relativeRomPath}`); // Send error or redirect
 96 |         return;
 97 |       }
 98 | 
 99 |       try {
100 |         log.info(`[stdio /gameboy] Attempting to load ROM from absolute path: ${absoluteRomPath}`);
101 |         emulatorService.loadRom(absoluteRomPath); // Load the newly selected ROM
102 |         log.info(`[stdio /gameboy] ROM loaded successfully: ${absoluteRomPath}`);
103 |         res.redirect('/emulator'); // Redirect to the emulator page
104 |       } catch (error) {
105 |         log.error(`[stdio /gameboy] Error loading ROM from ${absoluteRomPath}:`, error);
106 |         res.status(500).send('Error loading ROM'); // Send error or redirect
107 |       }
108 |     });
109 | 
110 |     // // Add a redirect from root to emulator (Keep commented out, root is now ROM selection)
111 |     // app.get('/', (req, res) => {
112 |     //   res.redirect('/emulator');
113 |     // });
114 |     
115 |     // Start the Express server
116 |     const httpServer = http.createServer(app);
117 |     httpServer.listen(port, () => {
118 |       // Log both the emulator page and the selection page
119 |       log.info(`Initial ROM loaded. Emulator available at http://localhost:${port}/emulator`);
120 |       log.info(`ROM Selection available at http://localhost:${port}/`);
121 | 
122 |       // Open the web interface directly to the emulator for the initial ROM
123 |       open(`http://localhost:${port}/emulator`);
124 |     });
125 | 
126 |     // Create the stdio transport
127 |     const transport = new StdioServerTransport();
128 |     
129 |     // Connect the transport to the server
130 |     await server.connect(transport);
131 |     
132 |     log.info('MCP server running on stdio');
133 |     
134 |   } catch (error) {
135 |     log.error(`Error starting server: ${error}`);
136 |     process.exit(1);
137 |   }
138 | }
139 | 
```

--------------------------------------------------------------------------------
/src/server/sse.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
  2 | import { GameBoyEmulator } from '../gameboy';
  3 | import { EmulatorService } from '../emulatorService'; // Import EmulatorService
  4 | import { createGameBoyServer } from './server';
  5 | import express, { Request, Response } from 'express';
  6 | import path from 'path';
  7 | import fs from 'fs';
  8 | import http from 'http';
  9 | import multer from 'multer';
 10 | import { setupWebUI, setupRomSelectionUI } from '../ui';
 11 | import { log } from '../utils/logger';
 12 | 
 13 | /**
 14 |  * Start the GameBoy MCP server in SSE mode
 15 |  * @param port Port to listen on (defaults to SERVER_PORT from .env or 3001)
 16 |  */
 17 | export async function startSseServer(port?: number): Promise<void> {
 18 |   // Use SERVER_PORT from environment variables if port is not provided
 19 |   const ssePort = port || (process.env.SERVER_PORT ? parseInt(process.env.SERVER_PORT, 10) : 3001);
 20 |   const webUiPort = process.env.SERVER_PORT ? parseInt(process.env.SERVER_PORT, 10) : 3002;
 21 |   
 22 |   // Create the emulator
 23 |   const emulator = new GameBoyEmulator();
 24 |   // Create the emulator service
 25 |   const emulatorService = new EmulatorService(emulator);
 26 |   
 27 |   // Create the server using the service
 28 |   const server = createGameBoyServer(emulatorService);
 29 |   
 30 |   // Create the Express app
 31 |   const app = express();
 32 |   
 33 |   // Middleware
 34 |   app.use(express.static(path.join(process.cwd(), 'public')));
 35 |   app.use(express.json());
 36 |   
 37 |   // Configure multer for file uploads
 38 |   const storage = multer.diskStorage({
 39 |     destination: (req, file, cb) => {
 40 |       const romsDir = path.join(process.cwd(), 'roms');
 41 |       if (!fs.existsSync(romsDir)) {
 42 |         fs.mkdirSync(romsDir);
 43 |       }
 44 |       cb(null, romsDir);
 45 |     },
 46 |     filename: (req, file, cb) => {
 47 |       cb(null, file.originalname);
 48 |     }
 49 |   });
 50 |   
 51 |   const upload = multer({ storage });
 52 |   
 53 |   // Store transports by session ID
 54 |   const transports: Record<string, SSEServerTransport> = {};
 55 |   
 56 |   // SSE endpoint for establishing the stream
 57 |   app.get('/mcp', async (req: Request, res: Response) => {
 58 |     log.info('Received GET request to /mcp (establishing SSE stream)');
 59 |     
 60 |     try {
 61 |       // Create a new SSE transport for the client
 62 |       const transport = new SSEServerTransport('/messages', res);
 63 |       
 64 |       // Store the transport by session ID
 65 |       const sessionId = transport.sessionId;
 66 |       transports[sessionId] = transport;
 67 |       
 68 |       // Set up onclose handler to clean up transport when closed
 69 |       transport.onclose = () => {
 70 |         log.info(`SSE transport closed for session ${sessionId}`);
 71 |         delete transports[sessionId];
 72 |       };
 73 |       
 74 |       // Connect the transport to the MCP server
 75 |       await server.connect(transport);
 76 |       
 77 |       log.info(`Established SSE stream with session ID: ${sessionId}`);
 78 |     } catch (error) {
 79 |       log.error('Error establishing SSE stream:', error);
 80 |       if (!res.headersSent) {
 81 |         res.status(500).send('Error establishing SSE stream');
 82 |       }
 83 |     }
 84 |   });
 85 |   
 86 |   // Messages endpoint for receiving client JSON-RPC requests
 87 |   app.post('/messages', async (req: Request, res: Response) => {
 88 |     log.info('Received POST request to /messages');
 89 |     
 90 |     // Extract session ID from URL query parameter
 91 |     const sessionId = req.query.sessionId as string | undefined;
 92 |     
 93 |     if (!sessionId) {
 94 |       log.error('No session ID provided in request URL');
 95 |       res.status(400).send('Missing sessionId parameter');
 96 |       return;
 97 |     }
 98 |     
 99 |     const transport = transports[sessionId];
100 |     if (!transport) {
101 |       log.error(`No active transport found for session ID: ${sessionId}`);
102 |       res.status(404).send('Session not found');
103 |       return;
104 |     }
105 |     
106 |     try {
107 |       // Handle the POST message with the transport
108 |       await transport.handlePostMessage(req, res, req.body);
109 |     } catch (error) {
110 |       log.error('Error handling request:', error);
111 |       if (!res.headersSent) {
112 |         res.status(500).send('Error handling request');
113 |       }
114 |     }
115 |   });
116 |   
117 |   // Set up the ROM selection UI using the service
118 |   setupRomSelectionUI(app, emulatorService); // Pass service, remove emulator
119 |   
120 |   // Handle ROM upload
121 |   app.post('/upload', upload.single('rom'), (req, res) => {
122 |     // Redirect to the ROM selection page
123 |     res.redirect('/');
124 |   });
125 |   
126 |   // Create the GameBoy page
127 |   app.get('/gameboy', (req, res) => {
128 |     const romPath = req.query.rom as string;
129 |     
130 |     // Check if the ROM file exists
131 |     if (!romPath || !fs.existsSync(romPath)) {
132 |       res.redirect('/');
133 |       return;
134 |     }
135 |     
136 |     // Load the ROM using the service
137 |     try {
138 |       emulatorService.loadRom(romPath); // Use service
139 |       
140 |       // NOTE: setupWebUI should ideally be called only ONCE during setup,
141 |       // not within a route handler. Let's move it outside.
142 |       // We'll set it up after the ROM selection UI.
143 |       
144 |       // Redirect to the emulator page - the UI will fetch the screen
145 |       res.redirect('/emulator'); 
146 |     } catch (error) {
147 |       log.error(`Error loading ROM: ${error}`, error);
148 |       res.redirect('/'); // Redirect back to selection on error
149 |     }
150 |   });
151 | 
152 |   // Set up the main Web UI (needs to be done once)
153 |   // Pass the service instance. The optional romPath isn't strictly needed here
154 |   // as the UI gets the current ROM from the service via API.
155 |   setupWebUI(app, emulatorService); 
156 |   
157 |   // Start the Express server
158 |   const httpServer = http.createServer(app);
159 |   httpServer.listen(ssePort, () => {
160 |     log.info(`GameBoy MCP Server listening on http://localhost:${ssePort}`);
161 |     log.info(`GameBoy Web UI available at http://localhost:${webUiPort}`);
162 |   });
163 | }
164 | 
```

--------------------------------------------------------------------------------
/src/ui.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import express, { Request, Response, RequestHandler } from 'express'; // Import RequestHandler
  2 | import * as path from 'path';
  3 | import * as fs from 'fs';
  4 | import { EmulatorService } from './emulatorService'; // Import EmulatorService
  5 | import { GameBoyButton } from './types'; // Import GameBoyButton
  6 | import { log } from './utils/logger';
  7 | 
  8 | /**
  9 |  * Sets up the web UI routes for the GameBoy emulator
 10 |  * @param app Express application
 11 |  * @param emulatorService Emulator service instance
 12 |  */
 13 | export function setupWebUI(app: express.Application, emulatorService: EmulatorService): void {
 14 | 
 15 |   // Route for the main emulator page
 16 |   app.get('/emulator', (req: Request, res: Response) => {
 17 |     const currentRomPath = emulatorService.getRomPath();
 18 |     const displayRomPath = currentRomPath || 'No ROM loaded';
 19 |     const romName = currentRomPath ? path.basename(currentRomPath) : 'No ROM loaded';
 20 | 
 21 |     res.send(`
 22 |       <!DOCTYPE html>
 23 |       <html>
 24 |         <head>
 25 |           <title>GameBoy Emulator</title>
 26 |           <style>
 27 |             body { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; background-color: #f0f0f0; font-family: Arial, sans-serif; }
 28 |             h1 { margin-bottom: 20px; }
 29 |             #gameboy { border: 10px solid #333; border-radius: 10px; background-color: #9bbc0f; padding: 20px; }
 30 |             #screen { width: 320px; height: 288px; image-rendering: pixelated; background-color: #0f380f; display: block; }
 31 |             #info { margin-top: 10px; text-align: center; }
 32 |             #controls { margin-top: 15px; display: flex; flex-direction: column; align-items: center; }
 33 |             .control-row { display: flex; margin-bottom: 10px; align-items: center; }
 34 |             .dpad { display: grid; grid-template-columns: repeat(3, 40px); grid-template-rows: repeat(3, 40px); gap: 5px; margin-right: 20px; }
 35 |             .action-buttons { display: grid; grid-template-columns: repeat(2, 40px); grid-template-rows: repeat(2, 40px); gap: 5px; margin-left: 20px; }
 36 |             .menu-buttons { display: flex; gap: 10px; margin-top: 10px; }
 37 |             .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; }
 38 |             .control-button { width: 40px; height: 40px; }
 39 |             .menu-button { padding: 5px 10px; }
 40 |             .control-button:hover, .menu-button:hover { background-color: #555; }
 41 |             .control-button:active { background-color: #777; }
 42 |             .skip-button { margin-top: 10px; padding: 5px 10px; background-color: #4CAF50; color: white; border: none; border-radius: 5px; cursor: pointer; }
 43 |             .skip-button:hover { background-color: #45a049; }
 44 |             #auto-play-container { margin-top: 10px; display: flex; align-items: center; }
 45 |             #auto-play-checkbox { margin-right: 5px; }
 46 |             #back-button { margin-top: 15px; padding: 5px 10px; background-color: #4CAF50; color: white; border: none; border-radius: 3px; cursor: pointer; text-decoration: none; }
 47 |             #back-button:hover { background-color: #45a049; }
 48 |           </style>
 49 |         </head>
 50 |         <body>
 51 |           <h1>GameBoy Emulator</h1>
 52 |           <div id="gameboy">
 53 |             <img id="screen" src="/screen" alt="GameBoy Screen" />
 54 |           </div>
 55 |           <div id="info">
 56 |             <p>ROM: ${romName}</p>
 57 |           </div>
 58 |           <div id="controls">
 59 |             <div class="control-row">
 60 |               <div class="dpad">
 61 |                 <div></div><button class="control-button" id="btn-up">↑</button><div></div>
 62 |                 <button class="control-button" id="btn-left">←</button><div></div><button class="control-button" id="btn-right">→</button>
 63 |                 <div></div><button class="control-button" id="btn-down">↓</button><div></div>
 64 |               </div>
 65 |               <div class="action-buttons">
 66 |                 <div></div><div></div>
 67 |                 <button class="control-button" id="btn-b">B</button>
 68 |                 <button class="control-button" id="btn-a">A</button>
 69 |               </div>
 70 |             </div>
 71 |             <div class="menu-buttons">
 72 |               <button class="menu-button" id="btn-select">SELECT</button>
 73 |               <button class="menu-button" id="btn-start">START</button>
 74 |             </div>
 75 |             <button class="skip-button" id="btn-skip">Skip 100 Frames</button>
 76 |             <div id="auto-play-container">
 77 |               <input type="checkbox" id="auto-play-checkbox">
 78 |               <label for="auto-play-checkbox">Auto-Play</label>
 79 |             </div>
 80 |             <div id="button-duration-container" style="margin-top: 10px; display: flex; align-items: center;">
 81 |               <label for="button-duration-input" style="margin-right: 5px;">Button Press Duration:</label>
 82 |               <input type="number" id="button-duration-input" min="1" value="25" style="width: 50px;">
 83 |             </div>
 84 |           </div>
 85 |           <a id="back-button" href="/">Back to ROM Selection</a>
 86 | 
 87 |           <script>
 88 |             let autoPlayEnabled = false;
 89 |             let updateTimeoutId = null;
 90 |             const autoPlayCheckbox = document.getElementById('auto-play-checkbox');
 91 |             const buttonDurationInput = document.getElementById('button-duration-input');
 92 |             const screenImg = document.getElementById('screen');
 93 | 
 94 |             autoPlayCheckbox.addEventListener('change', (event) => {
 95 |               autoPlayEnabled = event.target.checked;
 96 |               console.log('Auto-play toggled:', autoPlayEnabled);
 97 |               // Clear existing timeout and immediately trigger an update
 98 |               if (updateTimeoutId) clearTimeout(updateTimeoutId);
 99 |               updateScreen();
100 |             });
101 | 
102 |             async function updateScreen() {
103 |               if (!screenImg) return; // Exit if image element not found
104 | 
105 |               const endpoint = autoPlayEnabled ? '/api/advance_and_get_screen' : '/screen';
106 |               const timestamp = new Date().getTime(); // Prevent caching
107 | 
108 |               try {
109 |                 const response = await fetch(endpoint + '?' + timestamp);
110 |                 if (!response.ok) {
111 |                   console.error('Error fetching screen:', response.status, response.statusText);
112 |                   // Schedule next attempt after a delay, even on error
113 |                   scheduleNextUpdate();
114 |                   return;
115 |                 }
116 |                 const blob = await response.blob();
117 |                 // Revoke previous object URL to free memory
118 |                 if (screenImg.src.startsWith('blob:')) {
119 |                   URL.revokeObjectURL(screenImg.src);
120 |                 }
121 |                 screenImg.src = URL.createObjectURL(blob);
122 |               } catch (error) {
123 |                 console.error('Error fetching screen:', error);
124 |               } finally {
125 |                 scheduleNextUpdate();
126 |               }
127 |             }
128 | 
129 |             function scheduleNextUpdate() {
130 |                // Schedule next update
131 |                // ~60fps if auto-playing (1000ms / 60fps ≈ 16.67ms)
132 |                // Slower update rate if not auto-playing to reduce load
133 |                const delay = autoPlayEnabled ? 17 : 100;
134 |                updateTimeoutId = setTimeout(updateScreen, delay);
135 |             }
136 | 
137 |             async function callApiTool(toolName, params = {}) {
138 |                console.log(\`Calling tool: \${toolName} with params:\`, params);
139 |                try {
140 |                  const response = await fetch('/api/tool', {
141 |                    method: 'POST',
142 |                    headers: { 'Content-Type': 'application/json' },
143 |                    body: JSON.stringify({ tool: toolName, params: params })
144 |                  });
145 |                  if (!response.ok) {
146 |                    const errorText = await response.text();
147 |                    console.error(\`Error calling tool \${toolName}: \${response.status} \${errorText}\`);
148 |                  } else {
149 |                    console.log(\`Tool \${toolName} called successfully.\`);
150 |                    // Optionally force a screen update if not auto-playing
151 |                    // if (!autoPlayEnabled) {
152 |                    //   if (updateTimeoutId) clearTimeout(updateTimeoutId);
153 |                    //   updateScreen();
154 |                    // }
155 |                  }
156 |                } catch (error) {
157 |                  console.error(\`Network error calling tool \${toolName}:\`, error);
158 |                }
159 |             }
160 | 
161 |             // Add event listeners for buttons
162 |             document.getElementById('btn-up')?.addEventListener('click', () => {
163 |               const duration = parseInt(buttonDurationInput.value) || 5;
164 |               callApiTool('press_up', { duration_frames: duration });
165 |             });
166 |             document.getElementById('btn-down')?.addEventListener('click', () => {
167 |               const duration = parseInt(buttonDurationInput.value) || 5;
168 |               callApiTool('press_down', { duration_frames: duration });
169 |             });
170 |             document.getElementById('btn-left')?.addEventListener('click', () => {
171 |               const duration = parseInt(buttonDurationInput.value) || 5;
172 |               callApiTool('press_left', { duration_frames: duration });
173 |             });
174 |             document.getElementById('btn-right')?.addEventListener('click', () => {
175 |               const duration = parseInt(buttonDurationInput.value) || 5;
176 |               callApiTool('press_right', { duration_frames: duration });
177 |             });
178 |             document.getElementById('btn-a')?.addEventListener('click', () => {
179 |               const duration = parseInt(buttonDurationInput.value) || 5;
180 |               callApiTool('press_a', { duration_frames: duration });
181 |             });
182 |             document.getElementById('btn-b')?.addEventListener('click', () => {
183 |               const duration = parseInt(buttonDurationInput.value) || 5;
184 |               callApiTool('press_b', { duration_frames: duration });
185 |             });
186 |             document.getElementById('btn-start')?.addEventListener('click', () => {
187 |               const duration = parseInt(buttonDurationInput.value) || 5;
188 |               callApiTool('press_start', { duration_frames: duration });
189 |             });
190 |             document.getElementById('btn-select')?.addEventListener('click', () => {
191 |               const duration = parseInt(buttonDurationInput.value) || 5;
192 |               callApiTool('press_select', { duration_frames: duration });
193 |             });
194 |             document.getElementById('btn-skip')?.addEventListener('click', () => callApiTool('wait_frames', { duration_frames: 100 }));
195 | 
196 |             // Start the screen update loop
197 |             updateScreen();
198 |           </script>
199 |         </body>
200 |       </html>
201 |     `);
202 |   });
203 | 
204 |   // Route to get the current screen image (no frame advance)
205 |   const screenHandler: RequestHandler = (req, res) => {
206 |     if (!emulatorService.isRomLoaded()) {
207 |       res.status(400).send('No ROM loaded');
208 |     } else {
209 |       try {
210 |         const screen = emulatorService.getScreen(); // Does not advance frame
211 |         const screenBuffer = Buffer.from(screen.data, 'base64');
212 |         res.setHeader('Content-Type', 'image/png');
213 |         res.send(screenBuffer); // Ends the response
214 |       } catch (error) {
215 |          log.error('Error getting screen:', error);
216 |          res.status(500).send('Error getting screen'); // Ends the response
217 |       }
218 |     }
219 |   };
220 |   app.get('/screen', screenHandler);
221 | 
222 |   // API endpoint for advancing one frame and getting the screen (for Auto-Play)
223 |   const advanceAndGetScreenHandler: RequestHandler = (req, res) => {
224 |     if (!emulatorService.isRomLoaded()) {
225 |       res.status(400).send('No ROM loaded');
226 |     } else {
227 |       try {
228 |         const screen = emulatorService.advanceFrameAndGetScreen(); // Advances frame
229 |         const screenBuffer = Buffer.from(screen.data, 'base64');
230 |         res.setHeader('Content-Type', 'image/png');
231 |         res.send(screenBuffer); // Ends the response
232 |       } catch (error) {
233 |         log.error('Error advancing frame and getting screen:', error);
234 |          res.status(500).send('Error advancing frame and getting screen'); // Ends the response
235 |       }
236 |     }
237 |   };
238 |   app.get('/api/advance_and_get_screen', advanceAndGetScreenHandler);
239 | 
240 |   // API endpoint to call emulator tools (used by UI buttons)
241 |   const apiToolHandler: RequestHandler = async (req, res) => {
242 |     const { tool, params } = req.body;
243 |     log.info(`API /api/tool called: ${tool}`, params);
244 | 
245 |     if (!tool) {
246 |       res.status(400).json({ error: 'Tool name is required' });
247 |       return; // Exit early if no tool provided
248 |     }
249 | 
250 |     if (!emulatorService.isRomLoaded() && tool !== 'load_rom') {
251 |         res.status(400).json({ error: 'No ROM loaded' });
252 |         return; // Exit early if ROM not loaded (except for load_rom tool)
253 |     }
254 | 
255 |     try {
256 |       let result: any; // Should be ImageContent
257 | 
258 |       switch (tool) {
259 |         case 'get_screen':
260 |           result = emulatorService.getScreen();
261 |           break;
262 |         case 'load_rom':
263 |           if (!params || !params.romPath) {
264 |             res.status(400).json({ error: 'ROM path is required' });
265 |             return; // Exit early
266 |           }
267 |           result = emulatorService.loadRom(params.romPath);
268 |           break;
269 |         case 'wait_frames':
270 |           const duration_frames_wait = params?.duration_frames ?? 100;
271 |           if (typeof duration_frames_wait !== 'number' || duration_frames_wait <= 0) {
272 |             res.status(400).json({ error: 'Invalid duration_frames' });
273 |             return; // Exit early
274 |           }
275 |           result = emulatorService.waitFrames(duration_frames_wait);
276 |           break;
277 |         default:
278 |           if (tool.startsWith('press_')) {
279 |             const buttonName = tool.replace('press_', '').toUpperCase();
280 |             if (!(Object.values(GameBoyButton) as string[]).includes(buttonName)) {
281 |               res.status(400).json({ error: `Invalid button: ${buttonName}` });
282 |               return; // Exit early
283 |             }
284 |              const duration_frames_press = params?.duration_frames ?? 5;
285 |              if (typeof duration_frames_press !== 'number' || duration_frames_press <= 0) {
286 |                res.status(400).json({ error: 'Invalid duration_frames for press' });
287 |                return; // Exit early
288 |              }
289 |              emulatorService.pressButton(buttonName as GameBoyButton, duration_frames_press);
290 |              result = emulatorService.getScreen();
291 |           } else {
292 |             res.status(400).json({ error: `Unknown tool: ${tool}` });
293 |             return; // Exit early
294 |           }
295 |       }
296 | 
297 |       // Send response if result was obtained
298 |       res.json({ content: [result] }); // Ends the response
299 | 
300 |     } catch (error) {
301 |       log.error(`Error calling tool ${tool} via API:`, error);
302 |       const errorMessage = error instanceof Error ? error.message : 'Unknown error';
303 |       res.status(500).json({ error: `Failed to call tool: ${errorMessage}` }); // Ends the response
304 |     }
305 |   };
306 |   app.post('/api/tool', apiToolHandler);
307 | 
308 |   // --- Test Interface Endpoints (Keep as is for now, assuming they are correct or will be fixed separately if needed) ---
309 | 
310 |    // Add a route for the test interface
311 |    app.get('/test', (req: Request, res: Response) => {
312 |     res.sendFile(path.join(process.cwd(), 'public', 'test-interface.html'));
313 |   });
314 | 
315 |   // Get list of available ROMs
316 |   app.get('/api/roms', (req: Request, res: Response) => {
317 |     try {
318 |       const romsDir = path.join(process.cwd(), 'roms');
319 |       if (!fs.existsSync(romsDir)) {
320 |         fs.mkdirSync(romsDir);
321 |       }
322 |       const romFiles = fs.readdirSync(romsDir)
323 |         .filter(file => file.endsWith('.gb') || file.endsWith('.gbc'))
324 |         .map(file => ({
325 |           name: file,
326 |           path: path.join(romsDir, file)
327 |         }));
328 |       res.json(romFiles);
329 |     } catch (error) {
330 |       log.error('Error getting ROM list:', error);
331 |       res.status(500).json({ error: 'Failed to get ROM list' });
332 |     }
333 |   });
334 | 
335 |   // Check MCP server status
336 |   app.get('/api/status', (req: Request, res: Response) => {
337 |     try {
338 |       const romLoaded = emulatorService.isRomLoaded();
339 |       res.json({
340 |         connected: true, // Assume connected if UI is reachable
341 |         romLoaded,
342 |         romPath: emulatorService.getRomPath()
343 |       });
344 |     } catch (error) {
345 |       log.error('Error checking MCP server status:', error);
346 |       res.status(500).json({ error: 'Failed to check MCP server status' });
347 |     }
348 |   });
349 | }
350 | 
351 | /**
352 |  * Sets up the ROM selection page
353 |  * @param app Express application
354 |  * @param emulatorService Emulator service instance (No longer needs emulator directly)
355 |  */
356 | export function setupRomSelectionUI(app: express.Application, emulatorService: EmulatorService): void {
357 |   // Create the ROM selection page
358 |   app.get('/', (req: Request, res: Response) => {
359 |     // No need to check emulatorService.isRomLoaded() here,
360 |     // let the user always see the selection page.
361 | 
362 |     const romsDir = path.join(process.cwd(), 'roms');
363 |     let romFiles: { name: string; path: string }[] = [];
364 |     try {
365 |       if (!fs.existsSync(romsDir)) {
366 |         fs.mkdirSync(romsDir);
367 |       }
368 |       romFiles = fs.readdirSync(romsDir)
369 |         .filter(file => file.endsWith('.gb') || file.endsWith('.gbc'))
370 |         .map(file => ({
371 |           name: file,
372 |           // IMPORTANT: Use relative path for security and consistency
373 |           path: path.join('roms', file) // Use relative path from project root
374 |         }));
375 |     } catch (error) {
376 |       log.error("Error reading ROM directory:", error);
377 |       // Proceed with empty list if directory reading fails
378 |     }
379 | 
380 |     // Render the ROM selection page
381 |     res.send(`
382 |       <!DOCTYPE html>
383 |       <html>
384 |         <head>
385 |           <title>GameBoy ROM Selection</title>
386 |           <style>
387 |             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; }
388 |             h1 { margin-bottom: 20px; text-align: center; }
389 |             .container { max-width: 500px; width: 100%; }
390 |             .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; }
391 |             .rom-list { max-height: 400px; overflow-y: auto; }
392 |             .rom-item { padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; word-break: break-all; }
393 |             .rom-item:hover { background-color: #f5f5f5; }
394 |             .rom-item:last-child { border-bottom: none; }
395 |             .upload-form h2 { margin-top: 0; margin-bottom: 15px; }
396 |             .upload-form input[type="file"] { margin-bottom: 10px; display: block; width: 100%; }
397 |             .upload-form button { padding: 8px 15px; background-color: #4CAF50; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 1em; }
398 |             .upload-form button:hover { background-color: #45a049; }
399 |             .no-roms { text-align: center; color: #555; }
400 |           </style>
401 |         </head>
402 |         <body>
403 |           <div class="container">
404 |             <h1>Select a GameBoy ROM</h1>
405 |             <div class="rom-list">
406 |               ${romFiles.length > 0
407 |                 ? romFiles.map(rom => `
408 |                   <div class="rom-item" onclick="selectRom('${rom.path.replace(/\\/g, '\\\\')}')">
409 |                     ${rom.name}
410 |                   </div>`).join('') // Escape backslashes for JS string literal
411 |                 : '<p class="no-roms">No ROM files found in ./roms directory. Upload one below.</p>'
412 |               }
413 |             </div>
414 |             <div class="upload-form">
415 |               <h2>Upload a ROM</h2>
416 |               <form action="/upload" method="post" enctype="multipart/form-data">
417 |                 <input type="file" name="rom" accept=".gb,.gbc" required />
418 |                 <button type="submit">Upload</button>
419 |               </form>
420 |             </div>
421 |           </div>
422 |           <script>
423 |             function selectRom(romPath) {
424 |               // Use the /gameboy endpoint which now handles loading via the service
425 |               window.location.href = '/gameboy?rom=' + encodeURIComponent(romPath);
426 |             }
427 |           </script>
428 |         </body>
429 |       </html>
430 |     `);
431 |   });
432 | }
433 | 
```

