@nataliapc/mcp-openmsx
Version:
Model context protocol server for openMSX automation and control
889 lines (884 loc) • 39.8 kB
JavaScript
/**
* MCP openMSX Server
*
* Model Context Protocol server that manages openMSX emulator instances
* through TCL commands via stdio.
*
* @package @nataliapc/mcp-openmsx
* @version 1.1.4
* @author Natalia Pujol Cremades (@nataliapc)
* @license GPL2
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import express from "express";
import fs from "fs/promises";
import path from "path";
import { openMSXInstance } from "./openmsx.js";
// Version info for CLI
const PACKAGE_VERSION = "1.1.4";
// Defaults for openMSX paths
var OPENMSX_EXECUTABLE = 'openmsx';
var OPENMSX_SHARE_DIR = '/usr/share/openmsx';
var OPENMSX_SCREENSHOT_DIR = '';
var OPENMSX_SCREENDUMP_DIR = '';
var MACHINES_DIR = `${OPENMSX_SHARE_DIR}/machines`;
var EXTENSIONS_DIR = `${OPENMSX_SHARE_DIR}/extensions`;
// ============================================================================
// Tools available in the MCP server
// https://modelcontextprotocol.io/docs/concepts/tools#tool-definition-structure
//
function registerAllTools(server) {
server.tool(
// Name of the tool (used to call it)
"emu_control",
// Description of the tool (what it does)
"Controls an openMSX emulator. Commands: " +
"'launch [machine] [extensions]': opens a powered-on openMSX emulator; you must wait some time waiting the machine is fully booted; machine and extensions parameters can be specified so use 'machineList' and 'extensionList' commands to obtain valid values. " +
"'close': closes the openMSX emulator. " +
"'powerOn': powers on the openMSX emulator. " +
"'powerOff': powers off the openMSX emulator. " +
"'reset': resets the current machine. " +
"'getEmulatorSpeed': gets the current emulator speed as a percentage, default is 100. " +
"'setEmulatorSpeed <emuspeed>': sets the emulator speed as a percentage, valid values are 1-10000, default is 100. " +
"'machineList': gets a list of all available MSX machines that can be emulated with openMSX. " +
"'extensionList': gets a list of all available MSX extensions that can be used with openMSX. " +
"'wait <seconds>': performs a wait for the specified number of seconds, default is 2. ",
// Schema for the tool (input validation)
{
command: z.enum(["launch", "close", "powerOn", "powerOff", "reset", "getEmulatorSpeed", "setEmulatorSpeed", "machineList", "extensionList", "wait"]),
machine: z.string().min(1).max(100).optional(),
extensions: z.array(z.string().min(1).max(100)).optional(),
emuspeed: z.number().min(1).max(10000).optional().default(100),
seconds: z.number().min(1).max(10).optional().default(2), // Seconds to wait
},
// Handler for the tool (function to be executed when the tool is called)
async ({ command, machine, extensions, emuspeed, seconds }) => {
let result = '';
switch (command) {
case "launch":
result = await openMSXInstance.emu_launch(OPENMSX_EXECUTABLE, machine || "", extensions || []);
break;
case "close":
result = await openMSXInstance.emu_close();
break;
case "powerOn":
result = await openMSXInstance.sendCommand('set power on');
result = result === "true" ? "openMSX emulator powered on" : "Error: " + result;
break;
case "powerOff":
result = await openMSXInstance.sendCommand('set power off');
result = result === "false" ? "openMSX emulator powered off" : "Error: " + result;
break;
case "reset":
result = await openMSXInstance.sendCommand('reset');
result = result === "" ? "openMSX emulator reset successful" : "Error: " + result;
break;
case 'getEmulatorSpeed':
result = await openMSXInstance.sendCommand('set speed');
result = !isNaN(Number(result)) ? `Current emulator speed is ${result}%` : "Error: " + result;
break;
case 'setEmulatorSpeed':
result = await openMSXInstance.sendCommand(`set speed ${emuspeed}`);
result = !isNaN(Number(result)) ? `Emulator speed set to ${emuspeed}%` : "Error: " + result;
break;
case "machineList":
result = await openMSXInstance.getMachineList(MACHINES_DIR);
break;
case "extensionList":
result = await openMSXInstance.getExtensionList(EXTENSIONS_DIR);
break;
case "wait":
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
result = `Waited for ${seconds} seconds.`;
break;
default:
result = `Error: Unknown command "${command}".`;
break;
}
// Return result with proper format for MCP
return getResponseContent([
result
]);
});
server.tool(
// Name of the tool (used to call it)
"emu_media",
// Description of the tool (what it does)
"Manage tapes, rom cartridges, and floppy disks. Commands: " +
"'tapeInsert <tapefile>': insert a valid tape file (*.cas, *.wav, *.tsx). " +
"'tapeRewind': rewind the current tape. " +
"'tapeEject': remove tape from virtual cassette player. " +
"'romInsert <romfile>': insert a valid ROM cartridge file (*.rom) at cartridge slot A. " +
"'romEject': remove the current ROM cartridge from cartridge slot A. " +
"'diskInsert <diskfile>': insert a valid disk file (*.dsk) in floppy disk A. " +
"'diskInsertFolder <diskfolder>': use a host folder as a floppy disk A root directory. " +
"'diskEject': remove the current disk from floppy disk A. ",
// Schema for the tool (input validation)
{
command: z.enum(["tapeInsert", "tapeRewind", "tapeEject", "romInsert", "romEject", "diskInsert", "diskInsertFolder", "diskEject"]),
tapefile: z.string().min(1).max(200).optional(), // Tape file to insert
romfile: z.string().min(1).max(200).optional(), // ROM file to insert
diskfile: z.string().min(1).max(200).optional(), // Disk file to insert
diskfolder: z.string().min(1).max(200).optional(), // Disk folder to insert
},
// Handler for the tool (function to be executed when the tool is called)
async ({ command, tapefile, romfile, diskfile, diskfolder }) => {
let tclCommand;
switch (command) {
case "tapeInsert":
tclCommand = `cassetteplayer insert "${tapefile}"`;
break;
case "tapeRewind":
tclCommand = "cassetteplayer rewind";
break;
case "tapeEject":
tclCommand = "cassetteplayer eject";
break;
case "romInsert":
tclCommand = `carta insert "${romfile}"`;
break;
case "romEject":
tclCommand = "carta eject";
break;
case "diskInsert":
tclCommand = `diska insert "${diskfile}"`;
break;
case "diskInsertFolder":
tclCommand = `diska insert "${diskfolder}"`;
break;
case "diskEject":
tclCommand = "diska eject";
break;
default:
return getResponseContent([
`Error: Unknown emulator media command "${command}".`
]);
}
const response = await openMSXInstance.sendCommand(tclCommand);
// Return the response from openMSX
return getResponseContent([
response
]);
});
server.tool(
// Name of the tool (used to call it)
"emu_info",
// Description of the tool (what it does)
"Obtain informacion about the current emulated machine. Commands: " +
"'getStatus': returns the status of the openMSX emulator. " +
"'getSlotsMap': shows what devices/ROM/RAM are inserted into which slots. " +
"'getIOPortsMap': shows an overview about the I/O mapped devices. ",
// Schema for the tool (input validation)
{
command: z.enum(["getStatus", "getSlotsMap", "getIOPortsMap"]),
},
// Handler for the tool (function to be executed when the tool is called)
async ({ command }) => {
let tclCommand;
switch (command) {
case "getStatus":
return getResponseContent([
await openMSXInstance.emu_status()
]);
case "getSlotsMap":
tclCommand = "slotmap";
break;
case "getIOPortsMap":
tclCommand = "iomap";
break;
default:
return getResponseContent([
`Error: Unknown emulator info command "${command}".`
]);
}
const response = await openMSXInstance.sendCommand(tclCommand);
return getResponseContent([
response
]);
});
server.tool(
// Name of the tool (used to call it)
"emu_vdp",
// Description of the tool (what it does)
"Manage the VDP (Video Display Processor). Commands: " +
"'getPalette': returns the current V9938/V9958 color palette in RGB333 format. " +
"'getRegisters': returns all VDP register values. " +
"'getRegisterValue <register>': returns the value of a specific VDP register (0-31) in decimal format. " +
"'setRegisterValue <register> <value>': sets a hexadecimal value to a specific VDP register (0-31). " +
"'screenGetMode': returns the current screen mode (0-12) as a number, which matches the BASIC SCREEN command. " +
"'screenGetFullText': returns the full content of an MSX text screen (screen 0 or 1) as a string; PRIORITIZE this command to view screen content in text modes. ",
// Schema for the tool (input validation)
{
command: z.enum(["getPalette", "getRegisters", "getRegisterValue", "setRegisterValue", "screenGetMode", "screenGetFullText"]),
register: z.number().min(0).max(31).optional(), // Register to read/write
value: z.string().regex(/^0x[0-9a-fA-F]{2}$/).optional(),
},
// Handler for the tool (function to be executed when the tool is called)
async ({ command, register, value }) => {
let tclCommand;
switch (command) {
case "getPalette":
tclCommand = "palette";
break;
case "getRegisters":
tclCommand = "vdpregs";
break;
case "getRegisterValue":
tclCommand = `vdpreg ${register}`;
break;
case "setRegisterValue":
tclCommand = `vdpreg ${register} ${value}`;
break;
case "screenGetMode":
tclCommand = "get_screen_mode";
break;
case "screenGetFullText":
const response = await openMSXInstance.sendCommand('get_screen');
return response.startsWith('Error:') ?
getResponseContent([response]) :
getResponseContent(["The screen text is:", response]);
default:
return getResponseContent([
`Error: Unknown emulator vdp command "${command}".`
]);
}
const response = await openMSXInstance.sendCommand(tclCommand);
return getResponseContent([
response
]);
});
server.tool(
// Name of the tool (used to call it)
"debug_run",
// Description of the tool (what it does)
"Control execution (break, continue, step). Commands: " +
"'break': to break CPU at current execution position. " +
"'isBreaked': to check if the CPU is currently in break state (1) or not (0). " +
"'continue': to continue execution after break. " +
"'stepIn': to execute one CPU instruction, go into subroutines. " +
"'stepOver': to execute one CPU instruction, but don't go into subroutines. " +
"'stepOut': to step out of the current subroutine. " +
"'stepBack': to step one instruction back in time. " +
"'runTo <address>': to run the CPU until it reaches the specified address. " +
"Note: Addresses and values are in hexadecimal format (e.g. 0x0000).",
// Schema for the tool (input validation)
{
command: z.enum(["break", "isBreaked", "continue", "stepIn", "stepOut", "stepOver", "stepBack", "runTo"]),
address: z.string().regex(/^0x[0-9a-fA-F]{4}$/).optional()
},
// Handler for the tool (function to be executed when the tool is called)
async ({ command, address }) => {
let tclCommand;
switch (command) {
case "break":
tclCommand = "debug break";
break;
case "isBreaked":
tclCommand = "debug breaked";
break;
case "continue":
tclCommand = "debug cont";
break;
case "stepIn":
tclCommand = "step_in";
break;
case "stepOver":
tclCommand = "step_over";
break;
case "stepOut":
tclCommand = "step_out";
break;
case "stepBack":
tclCommand = "step_back";
break;
case "runTo":
tclCommand = `run_to ${address}`;
break;
default:
return getResponseContent([
`Error: Unknown debug command "${command}".`
]);
}
const response = await openMSXInstance.sendCommand(tclCommand);
return getResponseContent([
response
]);
});
server.tool(
// Name of the tool (used to call it)
"debug_cpu",
// Description of the tool (what it does)
"Read/write CPU registers, CPU info, Stack pile, and Disassemble code from memory. Commands: " +
"'getCpuRegisters': to get an overview of all the CPU registers. " +
"'getRegister <register>': to get the decimal value of a specific CPU register (pc, sp, ix, iy, af, bc, de, hl, ixh, ixl, iyh, iyl, a, f, b, c, d, e, h, l, i, r, im, iff). " +
"'setRegister <register> <value>': to set the value of a specific CPU register (pc, sp, ix, iy, af, bc, de, hl, ixh, ixl, iyh, iyl, a, f, b, c, d, e, h, l, i, r, im, iff). " +
"'getStackPile': to get an overview of the CPU stack. " +
"'disassemble [address] [size]': to print disassembled instructions at the address parameter location or PC register if empty. " +
"'getActiveCpu': to return the active cpu: z80 or r800." +
"Note: Addresses and values are in hexadecimal format (e.g. 0x0000).",
// Schema for the tool (input validation)
{
command: z.enum(["getCpuRegisters", "getRegister", "setRegister", "getStackPile", "disassemble", "getActiveCpu"]),
register: z.enum(["pc", "sp", "ix", "iy", "af", "bc", "de", "hl", "ixh", "ixl", "iyh", "iyl", "a", "f", "b", "c", "d", "e", "h", "l", "i", "r", "im", "iff"]).optional(),
address: z.string().regex(/^0x[0-9a-fA-F]{4}$/), // 4 hex digits for MSX memory address
value: z.string().regex(/^0x[0-9a-fA-F]{2-4}$/).optional(), // 2-4 hex digits for byte value for writeByte command
size: z.number().min(1).max(50).optional().default(8), // Number of bytes for disassemble command
},
// Handler for the tool (function to be executed when the tool is called)
async ({ command, address, register, value, size }) => {
let tclCommand;
switch (command) {
case "getCpuRegisters":
tclCommand = "cpuregs";
break;
case "getRegister":
tclCommand = "reg ${register}";
break;
case "setRegister":
tclCommand = "reg ${register} ${value}";
break;
case "getStackPile":
tclCommand = "stack";
break;
case "disassemble":
tclCommand = `disasm ${address || ""} ${size || ""}`;
break;
case "getActiveCpu":
tclCommand = "get_active_cpu";
break;
default:
return getResponseContent([
`Error: Unknown memory command "${command}".`
]);
}
const response = await openMSXInstance.sendCommand(tclCommand);
return getResponseContent([
response
]);
});
server.tool(
// Name of the tool (used to call it)
"debug_memory",
// Description of the tool (what it does)
"Slots info, and Read/write from/to memory in the openMSX emulator. Commands: " +
"'selectedSlots': to get a list of the currently selected memory slots. " +
"'getBlock <address> [lines]': to read a block of memory from the specified address. " +
"'readByte <address>': to read a BYTE from the specified address. " +
"'readWord <address>': to read a WORD from the specified address. " +
"'writeByte <address> <value8>': to write a BYTE to the specified address. " +
"'writeWord <address> <value16>': to write a WORD to the specified address. " +
"'advanced_basic_listing': to list the current BASIC program, with the ram address of each line listed. " +
"Note: Addresses and values are in hexadecimal format (e.g. 0x0000).",
// Schema for the tool (input validation)
{
command: z.enum(["selectedSlots", "getBlock", "readByte", "readWord", "writeByte", "writeWord", "advanced_basic_listing"]),
address: z.string().regex(/^0x[0-9a-fA-F]{4}$/).optional(), // 4 hex digits for MSX memory address
lines: z.number().min(1).max(50).optional().default(8), // Number of lines for getBlock command
value8: z.string().regex(/^0x[0-9a-fA-F]{2}$/).optional(), // 2 hex digits for byte value for writeByte command
value16: z.string().regex(/^0x[0-9a-fA-F]{4}$/).optional(), // 4 hex digits for byte value for writeByte command
},
// Handler for the tool (function to be executed when the tool is called)
async ({ command, address, lines, value8, value16 }) => {
let tclCommand;
switch (command) {
case "selectedSlots":
tclCommand = "slotselect";
break;
case "getBlock":
tclCommand = `showmem ${address} ${lines}`;
break;
case "readByte":
tclCommand = `peek ${address}`;
break;
case "readWord":
tclCommand = `peek16 ${address}`;
break;
case "writeByte":
tclCommand = `poke ${address} ${value8}`;
break;
case "writeWord":
tclCommand = `poke16 ${address} ${value16}`;
break;
case "advanced_basic_listing":
tclCommand = "listing";
break;
default:
return getResponseContent([
`Error: Unknown memory command "${command}".`
]);
}
const response = await openMSXInstance.sendCommand(tclCommand);
return getResponseContent([
response
]);
});
server.tool(
// Name of the tool (used to call it)
"debug_vram",
// Description of the tool (what it does)
"Read or write from/to VRAM video memory in the openMSX emulator. Commands: " +
"'getBlock <address> [lines]': to read a block of VRAM memory from the specified address. " +
"'readByte <address>': to read a BYTE from the specified VRAM address. " +
"'writeByte <address> <value8>': to write a BYTE to the specified VRAM address. " +
"Note: Addresses and values are in hexadecimal format (e.g. 0x0000).",
// Schema for the tool (input validation)
{
command: z.enum(["getBlock", "readByte", "writeByte"]),
address: z.string().regex(/^0x[0-9a-fA-F]{4}$/).optional(), // 4 hex digits for MSX memory address
lines: z.number().min(1).max(50).optional().default(8), // Number of lines for getBlock command
value8: z.string().regex(/^0x[0-9a-fA-F]{2}$/).optional(), // 2 hex digits for byte value for writeByte command
},
// Handler for the tool (function to be executed when the tool is called)
async ({ command, address, lines, value8 }) => {
let tclCommand;
switch (command) {
case "getBlock":
tclCommand = `showdebuggable VRAM ${address} ${lines}`;
break;
case "readByte":
tclCommand = `vpeek ${address}`;
break;
case "writeByte":
tclCommand = `vpoke ${address} ${value8}`;
break;
default:
return getResponseContent([
`Error: Unknown video memory command "${command}".`
]);
}
const response = await openMSXInstance.sendCommand(tclCommand);
return getResponseContent([
response
]);
});
server.tool(
// Name of the tool (used to call it)
"debug_breakpoints",
// Description of the tool (what it does)
"Create, remove, and list breakpoints. Commands: " +
"'create <address>': create a breakpoint at a specified address, and returns its name. " +
"'remove <bpname>': remove a breakpoint by name (e.g. bp#1). " +
"'list': enumerate the active breakpoints. " +
"Note: Addresses and values are in hexadecimal format (e.g. 0x0000). " +
"Note: The memory addresses of functions and variables can be previously obtained from *.sym or *.map files.",
// Schema for the tool (input validation)
{
command: z.enum(["create", "remove", "list"]),
address: z.string().regex(/^0x[0-9a-fA-F]{4}$/).optional(), // 4 hex digits for MSX memory address
bpname: z.string().min(3).max(10).optional(), // breakpoint name (e.g. bp#1)
},
// Handler for the tool (function to be executed when the tool is called)
async ({ command, address, bpname }) => {
let tclCommand;
switch (command) {
case "create":
tclCommand = `debug set_bp ${address}`;
break;
case "remove":
tclCommand = `debug remove_bp ${bpname}`;
break;
case "list":
tclCommand = 'debug list_bp';
break;
default:
return getResponseContent([
`Error: Unknown breakpoint command "${command}".`
]);
}
const response = await openMSXInstance.sendCommand(tclCommand);
return getResponseContent([
response
]);
});
server.tool(
// Name of the tool (used to call it)
"emu_savestates",
// Description of the tool (what it does)
"Load, save, and list savestates. Commands: " +
"'load <name>': restores a previously created savestate. " +
"'save <name>': creates a snapshot of the currently emulated MSX machine specifying a name for the savestate. " +
"'list': returns the names of all previously created savestates, separated by spaces. " +
"Note: names with spaces are enclosed in {}.",
// Schema for the tool (input validation)
{
command: z.enum(["load", "save", "list"]),
name: z.string().min(1).max(20).optional(), // breakpoint name (e.g. bp#1)
},
// Handler for the tool (function to be executed when the tool is called)
async ({ command, name }) => {
let tclCommand;
let textResponse = "Error:";
switch (command) {
case "load":
textResponse = "Loaded savestate: ";
tclCommand = `loadstate ${name}`;
break;
case "save":
textResponse = "Saved savestate: ";
tclCommand = `savestate ${name}`;
break;
case "list":
textResponse = "Savestate names: ";
tclCommand = 'list_savestates';
break;
default:
return getResponseContent([
`Error: Unknown savestate command "${command}".`
]);
}
const response = await openMSXInstance.sendCommand(tclCommand);
return getResponseContent([
textResponse,
response
]);
});
server.tool(
// Name of the tool (used to call it)
"emu_replay",
// Description of the tool (what it does)
"When replay is enabled (the default) the emulator collect data while emulating, which enables you to go back and forward in MSX time; consider do a 'pause' to maintain the the timeline before a 'goBack' or 'absoluteGoto'. Commands: " +
"'start': starts the replay mode (enabled by default when emulator is launched). " +
"'stop': stops the replay mode. " +
"'status': gives information about the replay feature and the data that is collected. " +
"'goBack <seconds>': go back specified seconds (1-60) in the timeline, you cannot go back to a time before the time the replay started. " +
"'absoluteGoto <time>': go to the indicated absolute time in seconds in the MSX timeline, if time is before replay started it will jump to the time when is started. " +
"'truncate': stop replaying and wipe all the future replay data after now. " +
"'saveReplay [filename]': saves the current replay data to a file (extension .omr), filename is returned in the response. " +
"'loadReplay <filename>': loads a previously saved replay file (extension .omr), starts replaying from the begin, and starts replay mode.",
// Schema for the tool (input validation)
{
command: z.enum(["start", "stop", "status", "goBack", "absoluteGoto", "truncate", "saveReplay", "loadReplay"]),
seconds: z.number().min(1).max(60).optional(), // Seconds to go back
time: z.string().regex(/^\d+$/).optional(), // Time in seconds to go to
filename: z.string().min(1).max(200).optional(), // Filename to save/load replay
},
// Handler for the tool (function to be executed when the tool is called)
async ({ command, seconds, time, filename }) => {
let tclCommand;
switch (command) {
case "start":
tclCommand = "reverse start";
break;
case "stop":
tclCommand = "reverse stop";
break;
case "status":
tclCommand = "reverse status";
break;
case "goBack":
tclCommand = `reverse goback ${seconds}`;
break;
case "absoluteGoto":
tclCommand = `reverse goto ${time}`;
break;
case "truncate":
tclCommand = "reverse truncatereplay";
break;
case "saveReplay":
if (filename)
filename = `"${filename}"`;
tclCommand = `reverse savereplay ${filename || ''}`;
break;
case "loadReplay":
if (filename)
filename = `"${filename}"`;
tclCommand = `reverse loadreplay ${filename}`;
break;
default:
return getResponseContent([
`Error: Unknown replay command "${command}".`
]);
}
const response = await openMSXInstance.sendCommand(tclCommand);
return getResponseContent([
response
]);
});
server.tool(
// Name of the tool (used to call it)
"emu_keyboard",
// Description of the tool (what it does)
"Send a text to the openMSX emulator. Commands: " +
"'sendText <text>': type a string in the emulated MSX, this command automatically press and release keys in the MSX keyboard matrix, is useful for automating tasks in BASIC. " +
"Note: each 'text' sent is limited to 200 characters, and the 'text' is sent as if it was typed in the MSX keyboard. " +
"Note: escape keys that needs it as Return key (use \\r), double quotes (use \\\"), etc...",
// Schema for the tool (input validation)
{
command: z.enum(["sendText"]),
text: z.string().min(1).max(200).optional().default(''), // Key to send to the emulator
},
// Handler for the tool (function to be executed when the tool is called)
async ({ command, text }) => {
let tclCommand;
switch (command) {
case "sendText":
tclCommand = `type "${text}"`;
break;
default:
return getResponseContent([
`Error: Unknown keyboard command "${command}".`
]);
}
const response = await openMSXInstance.sendCommand(tclCommand);
return getResponseContent([
response
]);
});
server.tool(
// Name of the tool (used to call it)
"screen_shot",
// Description of the tool (what it does)
"Take a screenshot of the openMSX emulator screen. Commands: " +
"'as_image': take a screenshot and the image is returned in the response. " +
"'to_file': take a screenshot and save it to a file, the file name is returned in the response.",
// Schema for the tool (input validation)
{
command: z.enum(["as_image", "to_file"]),
},
// Handler for the tool (function to be executed when the tool is called)
async ({ command }) => {
const openmsxCommand = `screenshot -raw -prefix "${OPENMSX_SCREENSHOT_DIR}mcp_"`;
const response = await openMSXInstance.sendCommand(openmsxCommand);
switch (command) {
case "as_image":
try {
const imageBuffer = await fs.readFile(response);
const base64image = imageBuffer.toString('base64');
return {
content: [{
type: "text",
text: "Screenshot taken successfully:",
}, {
type: "image",
data: base64image,
mimeType: "image/png",
}],
};
}
catch (error) {
return getResponseContent([
'Error creating screenshot: ' + response,
error instanceof Error ? error.message : String(error),
], true);
}
case "to_file":
return getResponseContent([
response.startsWith('Error:') ? response : 'Screenshot taken in file: ' + response
]);
}
return getResponseContent([
`Error: Unknown screen_shot command "${command}".`
]);
});
server.tool(
// Name of the tool (used to call it)
"screen_dump",
// Description of the tool (what it does)
"Take a screendump of the openMSX emulator screen as SC?. The parameter scrbasename is the name of the filename (without path) to save the screendump, default is 'screendump'. ",
// Schema for the tool (input validation)
{
scrbasename: z.string().min(1).max(100).default("screendump"),
},
// Handler for the tool (function to be executed when the tool is called)
async ({ scrbasename }) => {
const openmsxCommand = `save_msx_screen "${OPENMSX_SCREENDUMP_DIR + scrbasename}"`;
const response = await openMSXInstance.sendCommand(openmsxCommand);
return getResponseContent([
response.startsWith('Error:') ? 'Fail:' : 'Screendump file saved as:',
response
]);
});
}
function getResponseContent(response, isError = false) {
// Check if any response line starts with "Error:" to automatically set isError to true
const hasError = isError || response.some(line => line.startsWith("Error:"));
return {
content: response.map(line => ({
type: "text",
text: line == '' ? "Ok" : line,
})),
isError: hasError
};
}
// ============================================================================
// Cleanup handlers for graceful shutdown of MCP server
// Ensure openMSX emulator is closed when MCP server stops
let isShuttingDown = false;
async function gracefulShutdown(exitCode = 0) {
if (isShuttingDown)
return;
isShuttingDown = true;
try {
// Try async close first
await Promise.race([
openMSXInstance.emu_close(),
new Promise(resolve => setTimeout(resolve, 2000)) // 2 second timeout
]);
}
catch (error) {
// If async close fails or times out, force close
openMSXInstance.forceClose();
}
// Give a moment for cleanup to complete
setTimeout(() => {
process.exit(exitCode);
}, 100);
}
// Handle process termination signals
process.on('SIGINT', () => gracefulShutdown(0));
process.on('SIGTERM', () => gracefulShutdown(0));
// Handle when the transport connection is closed (more reliable for MCP)
process.on('disconnect', () => gracefulShutdown(0));
// Handle uncaught exceptions and unhandled rejections
process.on('uncaughtException', async (error) => {
await gracefulShutdown(1);
});
process.on('unhandledRejection', async (reason, promise) => {
await gracefulShutdown(1);
});
// Additional cleanup when the process is about to exit
process.on('exit', () => {
// This is synchronous only - can't use async here
// Force close as last resort
openMSXInstance.forceClose();
});
// ============================================================================
// Help function to display usage information
//
function showHelp() {
console.log(`
MCP-openMSX Server v${PACKAGE_VERSION}
Model Context Protocol server for openMSX emulator automation
Usage:
mcp-openmsx [transport]
Transport options:
stdio Use stdio transport (default)
http Use HTTP transport
Environment variables:
OPENMSX_EXECUTABLE Path to openMSX executable
OPENMSX_SHARE_DIR openMSX share directory
OPENMSX_SCREENSHOT_DIR Screenshot output directory
OPENMSX_SCREENDUMP_DIR Screen dump output directory
MCP_HTTP_PORT HTTP server port (default: 3000)
Examples:
mcp-openmsx # stdio transport
mcp-openmsx http # HTTP transport
MCP_TRANSPORT=http mcp-openmsx # HTTP via environment
`);
}
// ============================================================================
// Start the server
//
async function startHttpServer() {
const app = express();
app.use(express.json());
const transports = {};
// Handle POST requests for client-to-server communication
app.post('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
let transport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
}
else if (!sessionId && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
transports[sessionId] = transport;
}
});
transport.onclose = () => {
if (transport.sessionId) {
delete transports[transport.sessionId];
}
};
// Create a new server instance for this session
const httpServer = createServerInstance();
await httpServer.connect(transport);
}
else {
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
id: null,
});
return;
}
await transport.handleRequest(req, res, req.body);
});
// Handle GET requests for server-to-client notifications via SSE
app.get('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
const transport = transports[sessionId];
await transport.handleRequest(req, res);
});
const port = process.env.MCP_HTTP_PORT || 3000;
app.listen(port, () => {
console.log(`MCP Server listening on port ${port}`);
});
}
function createServerInstance() {
// Create a new server instance (you might want to extract server creation logic)
const newServer = new McpServer({
name: "mcp-openmsx",
version: PACKAGE_VERSION,
});
// Re-register all tools (you might want to extract this to a separate function)
registerAllTools(newServer);
return newServer;
}
// ============================================================================
// Main function to start the MCP server
//
async function main() {
// Handle CLI arguments
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
showHelp();
return;
}
if (args.includes('--version') || args.includes('-v')) {
console.log(PACKAGE_VERSION);
return;
}
// Environment variables setup
if (process.env.OPENMSX_EXECUTABLE) {
OPENMSX_EXECUTABLE = process.env.OPENMSX_EXECUTABLE;
}
if (process.env.OPENMSX_SCREENSHOT_DIR && process.env.OPENMSX_SCREENSHOT_DIR !== '') {
OPENMSX_SCREENSHOT_DIR = process.env.OPENMSX_SCREENSHOT_DIR + path.sep;
}
if (process.env.OPENMSX_SCREENDUMP_DIR && process.env.OPENMSX_SCREENDUMP_DIR !== '') {
OPENMSX_SCREENDUMP_DIR = process.env.OPENMSX_SCREENDUMP_DIR + path.sep;
}
if (process.env.OPENMSX_SHARE_DIR) {
OPENMSX_SHARE_DIR = process.env.OPENMSX_SHARE_DIR + path.sep;
MACHINES_DIR = `${OPENMSX_SHARE_DIR}machines`;
EXTENSIONS_DIR = `${OPENMSX_SHARE_DIR}extensions`;
}
// Detect transport type from environment or command line
const transportType = process.env.MCP_TRANSPORT || process.argv[2] || 'stdio';
if (transportType === 'http') {
// Start Streamable HTTP server
await startHttpServer();
}
else {
// Default to stdio
const transport = new StdioServerTransport();
await createServerInstance().connect(transport);
}
}
main().catch((error) => {
gracefulShutdown(1);
process.exit(1);
});