UNPKG

@nataliapc/mcp-openmsx

Version:

Model context protocol server for openMSX automation and control

509 lines (508 loc) 23.1 kB
/** * openMSX wrapper class * * @author Natalia Pujol Cremades (@nataliapc) * @license GPL2 */ import fs from "fs/promises"; import { extractDescriptionFromXML, decodeHtmlEntities, encodeHtmlEntities } from "./utils.js"; import { spawn } from 'child_process'; import path from 'path'; import { OpenMsxWindowsConnector } from "./openmsx_windows.js"; /** True when running on Windows. Evaluated once at module load. */ const IS_WINDOWS = process.platform === 'win32'; export class OpenMSX { lastMachine = null; process = null; isConnected = false; // Active writable control channel: process.stdin (Linux/macOS) or the // Windows connection's input (TCP socket / SSPI proxy stdin). controlWritable = null; // Windows-only control connection (TCP+SSPI or stdio proxy). Owns the // transport and its teardown; null on Linux/macOS. See openmsx_windows.ts. controlConnection = null; // Accumulated I/O data for readData() — shared by all transports (never coexist) ioBuffer = ''; // Notify callback: fired when new I/O data arrives — shared by both platforms ioNotify = null; // Serial command queue — ensures sendCommand calls never overlap on any platform commandQueue = Promise.resolve(''); /** * Launch the openMSX emulator. * Linux/macOS: -control stdio via stdin/stdout pipes. * Windows: spawn with ignore+ignore+pipe, TCP socket + SSPI auth. */ async emu_launch(executable, machine, extensions) { return new Promise((resolve) => { let resolved = false; const diagLog = []; const diag = (msg) => { console.error(`[mcp-openmsx] ${msg}`); diagLog.push(msg); }; const safeResolve = (message) => { if (!resolved) { resolved = true; resolve(message); } }; try { if (this.process && !this.process.killed) { safeResolve(`Error: openMSX emulator instance is already running (current machine: ${this.lastMachine}). Close it first.`); return; } this.resetIO(); this.commandQueue = Promise.resolve(''); // reset queue for new session // Resolve the Windows control mode up front so an invalid value // fails before we spawn openMSX (avoids orphaned GUI processes). let windowsMode = null; if (IS_WINDOWS) { try { windowsMode = OpenMsxWindowsConnector.getControlMode(); } catch (e) { safeResolve(`Error: ${e instanceof Error ? e.message : e}`); return; } if (windowsMode === 'pipe') { safeResolve('Error: OPENMSX_WINDOWS_CONTROL=pipe is reserved but not implemented yet'); return; } diag(`Windows control mode: ${windowsMode}`); } // Build args const args = []; if (!IS_WINDOWS) { args.push('-control', 'stdio'); } if (machine) { this.lastMachine = machine; args.push('-machine', machine); } if (extensions?.length > 0) { extensions.forEach(ext => args.push('-ext', ext)); } diag(`platform=${process.platform} IS_WINDOWS=${IS_WINDOWS}`); diag(`spawn: "${executable}" ${args.join(' ')}`); // Windows: stdin+stdout ignored (GUI subsystem — pipes don't work). // Linux/macOS: all three streams piped. this.process = spawn(executable, args, { stdio: IS_WINDOWS ? ['ignore', 'ignore', 'pipe'] : ['pipe', 'pipe', 'pipe'], windowsHide: false }); if (IS_WINDOWS && !this.process.stderr) { safeResolve('Error: Failed to create stderr pipe'); return; } if (!IS_WINDOWS && (!this.process.stdout || !this.process.stderr || !this.process.stdin)) { safeResolve('Error: Failed to create stdio pipes'); return; } if (!this.process.pid || this.process.killed) { this.process = null; safeResolve('Error: Failed to launch openMSX process'); return; } diag(`process spawned PID=${this.process.pid}`); this.process.on('error', (error) => { diag(`process error: code=${error.code} ${error.message}`); if (error.code === 'ENOENT') { safeResolve(`Error: openMSX executable not found: "${executable}". ` + `Set OPENMSX_EXECUTABLE to the full path. ` + `On Windows: C:\\Users\\<user>\\openMSX\\openmsx.exe or ` + `C:\\Program Files\\openMSX\\openmsx.exe; ` + `on macOS: /Applications/openMSX.app/Contents/MacOS/openmsx; ` + `on Linux: openmsx (in PATH).`); } else { safeResolve(`Error: ${error.message}`); } }); this.process.on('exit', (code, signal) => { diag(`process exit: code=${code} signal=${signal} isConnected=${this.isConnected}`); if (!resolved) { safeResolve(`Error: openMSX process exited unexpectedly (code=${code}, signal=${signal}). ` + `Diagnostics: ${diagLog.join(' | ')}`); } this.isConnected = false; this.process = null; this.resetIO(); }); this.process.stderr.on('data', (data) => { const msg = data.toString().trim(); if (!this.isConnected) diag(`stderr: ${msg.substring(0, 300)}`); if (msg.includes('Fatal error:') && !this.isConnected) { this.forceClose(); safeResolve(`Error: ${msg}`); } }); const onOpenMSXReady = async (sendControlTag) => { diag('onOpenMSXReady: sending initial commands'); // On Linux/macOS: we receive <openmsx-output> first unprompted, // then we must send <openmsx-control> to start the session. // On Windows: we already sent <openmsx-control> to trigger <openmsx-output>, // so we must NOT send it again. if (sendControlTag) { this.writeData('<openmsx-control>\n'); } // await each command so replies are consumed in order and don't // contaminate ioBuffer for subsequent user commands await this.sendCommand('set save_settings_on_exit off'); await this.sendCommand('set renderer SDLGL-PP'); await this.sendCommand('set power on'); await this.sendCommand('reverse start'); let result = 'Ok: openMSX emulator launched successfully'; if (machine) result += ` with machine "${machine}"`; if (extensions?.length > 0) { if (machine) result += ' and'; result += ` with extensions: "${extensions.join('", "')}"`; } result += ', is powered on, and replay mode is started.'; safeResolve(result); }; const ctx = { diag, safeResolve, isResolved: () => resolved, onReady: onOpenMSXReady, }; if (IS_WINDOWS) { this.launchConnectWindows(ctx); } else { this.launchConnectLinux(ctx); } // Global timeout — 20s to allow for SSPI auth + slow VMs setTimeout(() => { if (!this.isConnected) { this.emu_close(); safeResolve(`Error: Timeout waiting for openMSX to start. Diagnostics: ${diagLog.join(' | ')}`); } }, 20000); } catch (error) { safeResolve(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); } }); } /** * Windows connection (both modes). All transport — socket-file polling, TCP * connect, SSPI, or proxy launch — is delegated to {@link OpenMsxWindowsConnector}, * which returns a uniform connection with SSPI already done. Here we only * attach the generic XML stream handlers and open the session. Both Windows * modes use the TCP semantics: we send `<openmsx-control>` and openMSX replies * with `<openmsx-output>`. */ launchConnectWindows(ctx) { const connector = new OpenMsxWindowsConnector({ openmsxProcess: this.process, diag: ctx.diag, }); connector.connect().then((connection) => { this.controlConnection = connection; this.controlWritable = connection.input; this.ioBuffer = ''; // Diagnostics from the SSPI proxy (stderr only — never stdout). connection.errorOutput?.on('data', (data) => { const msg = data.toString().trim(); if (msg && !this.isConnected) ctx.diag(`control stderr: ${msg.substring(0, 300)}`); }); // stdio-proxy: proxy exiting before we connect means SSPI/connect failed. connection.controlProcess?.on('exit', (code, signal) => { ctx.diag(`control process exit: code=${code} signal=${signal} isConnected=${this.isConnected}`); if (!this.isConnected && !ctx.isResolved()) { ctx.safeResolve(`Error: control process exited before connecting (code=${code}, signal=${signal}).`); } }); // direct-sspi: TCP socket errors / close. connection.tcpSocket?.on('error', e => { ctx.diag(`TCP socket error: ${e.message}`); if (!ctx.isResolved()) ctx.safeResolve(`Error: TCP socket error: ${e.message}`); }); connection.tcpSocket?.on('close', () => { ctx.diag('TCP socket closed'); this.isConnected = false; }); connection.output.on('error', (e) => ctx.diag(`control stream error: ${e.message}`)); this.attachOutputHandler(connection.output, ctx); // Open the XML session: we send <openmsx-control>; openMSX replies with // <openmsx-output>. (In stdio-proxy the proxy forwards it after SSPI.) ctx.diag('sending <openmsx-control> to start XML session'); connection.input.write('<openmsx-control>\n'); }).catch((err) => { ctx.diag(`windows connect failed: ${err.message}`); ctx.safeResolve(`Error: ${err.message}`); }); } /** * Shared output handler for both Windows modes (proxy stdout or TCP socket): * accumulate into `ioBuffer`, wake any pending `readData()`, and on the first * `<openmsx-output>` mark connected and trigger the initial command sequence * with `onReady(false)` (we already sent `<openmsx-control>`). Linux/macOS uses * its own variant in {@link launchConnectLinux} (openMSX emits the tag unprompted). */ attachOutputHandler(output, ctx) { output.on('data', (data) => { const chunk = data.toString(); this.ioBuffer += chunk; if (this.ioNotify) { const notify = this.ioNotify; this.ioNotify = null; notify(); } if (!this.isConnected && this.ioBuffer.includes('<openmsx-output>')) { this.isConnected = true; this.ioBuffer = this.ioBuffer.substring(this.ioBuffer.indexOf('<openmsx-output>') + '<openmsx-output>'.length); setTimeout(async () => { if (!ctx.isResolved()) { try { await ctx.onReady(false); } // <openmsx-control> already sent catch (e) { ctx.safeResolve(`Error: Failed to send control commands - ${e instanceof Error ? e.message : e}`); } } }, 300); } }); } /** * Linux/macOS connection: stdio pipes. * Registers stdout handler for ioBuffer accumulation and <openmsx-output> detection. */ launchConnectLinux(ctx) { const FATAL_ERROR_GRACE_PERIOD = 500; let connectionTime = null; this.controlWritable = this.process.stdin; this.process.stdout.on('data', (data) => { const output = data.toString(); if (!this.isConnected) { if (output.includes('<openmsx-output>')) { ctx.diag('<openmsx-output> detected on stdout'); this.isConnected = true; this.ioBuffer = ''; connectionTime = Date.now(); setTimeout(async () => { if (!ctx.isResolved()) { try { await ctx.onReady(true); } // Linux/macOS: must send <openmsx-control> catch (e) { ctx.safeResolve(`Error: Failed to send control commands - ${e instanceof Error ? e.message : e}`); } } }, FATAL_ERROR_GRACE_PERIOD); } } else { // Accumulate for readData() — persistent shared ioBuffer this.ioBuffer += output; if (this.ioNotify) { const notify = this.ioNotify; this.ioNotify = null; notify(); } } }); this.process.stderr.on('data', (data) => { const msg = data.toString().trim(); const isInGracePeriod = connectionTime && (Date.now() - connectionTime) < FATAL_ERROR_GRACE_PERIOD; if (msg.includes('Fatal error:') && (!this.isConnected || isInGracePeriod)) { this.forceClose(); ctx.safeResolve(`Error: ${msg}`); } }); } resetIO() { // Tear down the Windows control connection (TCP socket / SSPI proxy). // No-op on Linux/macOS where controlConnection is null. if (this.controlConnection) { try { this.controlConnection.forceClose(); } catch (_) { /* ignore */ } this.controlConnection = null; } this.controlWritable = null; this.ioBuffer = ''; this.ioNotify = null; } async emu_close() { return new Promise((resolve) => { let resolved = false; const safeResolve = (message) => { if (!resolved) { resolved = true; resolve(message); } }; if (!this.process) { safeResolve("Error: No emulator process running"); return; } this.process.on('exit', () => { this.lastMachine = null; this.isConnected = false; this.process = null; // resetIO() also tears down the Windows control connection (proxy/socket). this.resetIO(); safeResolve("Ok: Emulator process closed successfully"); }); this.process.on('error', (error) => safeResolve(`Error: error closing emulator: ${error.message}`)); if (this.isConnected) { this.sendCommand('exit').catch(() => { try { this.process?.kill(); } catch (_) { /* ignore */ } }); } else { this.forceClose(); safeResolve("Error: Emulator process had to be force killed"); } setTimeout(() => { this.forceClose(); safeResolve("Error: Timeout. Emulator process had to be force killed"); }, 1000); }); } async emu_status() { try { const response = await this.sendCommand('machine_info'); if (response.startsWith('Error:')) return response; const skipInfo = ['issubslotted', 'input_port', 'slot', 'isexternalslot', 'output_port']; const parameters = response.trim().split(' '); const machineInfo = {}; for (const param of parameters) { const trimmed = param.trim(); if (skipInfo.includes(trimmed) || !trimmed) continue; machineInfo[trimmed] = (await this.sendCommand(`machine_info ${trimmed}`)).trim(); } return JSON.stringify(machineInfo, null, 2); } catch (error) { return `Error: Failed to get machine status - ${error instanceof Error ? error.message : 'Unknown error'}`; } } async emu_isInBasic() { try { const response = await this.sendCommand('slotselect'); return response.includes('0000: slot 0') && response.includes('4000: slot 0'); } catch (_) { return false; } } async getMachineList(machinesDirectory) { return this.getXMLList(machinesDirectory, 'machines'); } async getExtensionList(extensionDirectory) { return this.getXMLList(extensionDirectory, 'extensions'); } async getXMLList(directory, entityName) { try { const allFiles = await fs.readdir(directory); const items = await Promise.all(allFiles.filter(f => f.endsWith('.xml')).map(async (f) => ({ name: f.replace('.xml', ''), description: await extractDescriptionFromXML(path.join(directory, f)) }))); return items.length ? JSON.stringify(items, null, 2) : `Error: No ${entityName} found.`; } catch (error) { return `Error: error reading ${entityName} directory - ${error instanceof Error ? error.message : error}`; } } /** * Send a TCL command to openMSX and return the response. * * Internally serialized via a promise queue so concurrent callers (with or * without `await`) never overlap — each command waits for the previous one * to complete before writing to the channel and reading the reply. * This is safe on all platforms (Linux/macOS stdio and Windows TCP). */ async sendCommand(command) { const execute = async () => { try { this.writeData(`<command>${encodeHtmlEntities(command)}</command>\n`); const output = (await this.readData()).trim(); const replyMatch = output.match(/<reply result="(ok|nok)"[^>]*>(.*?)<\/reply>/s); if (replyMatch) { const content = decodeHtmlEntities(replyMatch[2].trim()); return replyMatch[1] === 'ok' ? content : `Error: ${content}`; } return decodeHtmlEntities(output); } catch (error) { return `Error: ${error instanceof Error ? error.message : error}`; } }; // Chain onto the queue: wait for the previous command to finish, then run const result = this.commandQueue.then(execute, execute); // Update the queue tail — swallow errors so the chain never breaks this.commandQueue = result.then(() => '', () => ''); return result; } writeData(data) { if (!this.process || !this.isConnected) throw new Error('openMSX process not running or not connected'); const channel = this.controlWritable; if (!channel || channel.destroyed) throw new Error('openMSX control channel not available'); channel.write(data); } readData() { return new Promise((resolve, reject) => { if (!this.isConnected) { reject(new Error('openMSX process not running or not connected')); return; } if (!this.controlWritable) { reject(new Error('openMSX control channel not available')); return; } // Unified for all transports: accumulate in ioBuffer until a complete // <reply>…</reply> block, then extract and return it. const RESPONSE_TIMEOUT = 10000; const timer = setTimeout(() => { this.ioNotify = null; this.ioBuffer = ''; reject(new Error('Timeout waiting for openMSX response')); }, RESPONSE_TIMEOUT); const tryExtractReply = () => { const end = this.ioBuffer.indexOf('</reply>'); if (end !== -1) { clearTimeout(timer); const full = this.ioBuffer.substring(0, end + '</reply>'.length); this.ioBuffer = this.ioBuffer.substring(end + '</reply>'.length); resolve(full); } else { this.ioNotify = tryExtractReply; } }; tryExtractReply(); }); } async destroy() { if (this.process && !this.process.killed) await this.emu_close(); } forceClose() { // Kill the openMSX emulator; resetIO() force-closes the control connection // (TCP socket / SSPI proxy) on Windows. if (this.process && !this.process.killed) { try { this.process.kill('SIGKILL'); } catch (_) { /* ignore */ } } this.process = null; this.isConnected = false; this.resetIO(); } } export const openMSXInstance = new OpenMSX();