UNPKG

@nataliapc/mcp-openmsx

Version:

Model context protocol server for openMSX automation and control

1,053 lines 87.6 kB
import { z } from "zod"; import fs from "fs/promises"; import path from "path"; import { openMSXInstance } from "./openmsx.js"; import { VectorDB } from "./vectordb.js"; import { encodeTypeText, buildKeyComboCommand, isErrorResponse, getResponseContent, parseCpuRegs, is16bitRegister, parseVdpRegs, parsePalette, parseBreakpoints, parseReplayStatus, sleepWithAbort, ensureDirectoryExists, tclPath } from "./utils.js"; import { getRegisteredResourcesList } from "./server_resources.js"; import { resolveLaunchParams } from "./server_elicitations.js"; // ============================================================================ // Tools available in the MCP server // https://modelcontextprotocol.io/docs/concepts/tools#tool-definition-structure const TCL_COMMAND_MAX_LENGTH = 16384; const TCL_RESULT_MAX_LENGTH = 65536; const INVALID_XML_CONTROL_CHARACTERS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/; /** Register the raw Tcl escape hatch only when the user explicitly enables it. */ export function registerOpenMsxTclCommandTool(server) { if (process.env.OPENMSX_ENABLE_RAW_TCL?.trim().toLowerCase() !== 'true') return; server.registerTool("openmsx_tcl_cmd", { title: "Execute an openMSX Tcl command", description: `Execute one native Tcl command in the connected openMSX instance and return its raw result. This is an advanced escape hatch for functionality not covered by the typed tools. Prefer typed tools when available. Use openMSX runtime discovery before unfamiliar commands: 'help', 'help <command> [subcommand]', 'about <keyword>', 'openmsx_info', and 'machine_info'.`, inputSchema: { command: z.string() .min(1, 'Tcl command cannot be empty') .max(TCL_COMMAND_MAX_LENGTH, `Tcl command cannot exceed ${TCL_COMMAND_MAX_LENGTH} characters`) .refine(command => !INVALID_XML_CONTROL_CHARACTERS.test(command), 'Tcl command contains unsupported control characters') .describe("Native Tcl command to execute in openMSX."), }, outputSchema: { command: z.string().describe("The Tcl command that was executed."), result: z.string().describe("Raw result returned by openMSX."), truncated: z.boolean().describe("Whether the result was truncated to protect the client context."), }, annotations: { "readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": true, }, }, async ({ command }) => { const response = await openMSXInstance.sendCommand(command); const truncated = response.length > TCL_RESULT_MAX_LENGTH; const result = truncated ? response.slice(0, TCL_RESULT_MAX_LENGTH) : response; return { content: [{ type: "text", text: result }], structuredContent: { command, result, truncated }, isError: isErrorResponse(response), }; }); } export async function registerTools(server, emuDirectories) { registerOpenMsxTclCommandTool(server); // emu_control server.registerTool( // Name of the tool (used to call it) "emu_control", { title: "Emulator control tools", // Description of the tool (what it does) description: "Tools to control an openMSX emulator.", // Schema for the tool (input validation) inputSchema: { command: z.enum(["launch", "close", "powerOn", "powerOff", "reset", "getEmulatorSpeed", "setEmulatorSpeed", "machineList", "extensionList", "wait"]) .describe(`Available 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, or let them ambiguous and use elicitation. " + '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 3. `), machine: z.string() .max(100, 'Machine name too long') .optional() .describe("Machine name to launch; valid names can be obtained using [machineList]. Used by [launch]."), extensions: z.array(z.string() .min(1, 'Extension name cannot be empy') .max(100, 'Extension name too long')) .optional() .describe("List of extensions to use; valid extensions can be obtained using [extensionList]. Used by [launch]."), emuspeed: z.number() .min(1, 'Emulator speed too low') .max(10000, 'Emulator speed too high') .optional() .default(100) .describe("Emulator speed as a percentage (1-10000); default is 100. Used by [setEmulatorSpeed]."), seconds: z.number() .min(1, 'Minimum wait time too short') .max(10, 'Maximum wait time too long') .optional() .default(3) .describe("Number of seconds to wait; default is 3. Used by [wait]."), }, outputSchema: { command: z.string().describe("The command that was executed."), speed: z.number().optional() .describe("Emulator speed percentage. Present for 'getEmulatorSpeed' and 'setEmulatorSpeed'."), machines: z.array(z.object({ name: z.string().describe("Machine name."), description: z.string().describe("Machine description."), })).optional() .describe("List of available MSX machines. Present for 'machineList'."), extensions: z.array(z.object({ name: z.string().describe("Extension name."), description: z.string().describe("Extension description."), })).optional() .describe("List of available MSX extensions. Present for 'extensionList'."), result: z.string().optional() .describe("Generic result or status message."), }, annotations: { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false, }, }, // Handler for the tool (function to be executed when the tool is called) async ({ command, machine, extensions, emuspeed, seconds }, extra) => { let result = ''; switch (command) { case "launch": { const resolved = await resolveLaunchParams(server, emuDirectories, machine, extensions); if (resolved.cancelled) { return { content: [{ type: "text", text: "Launch cancelled by user." }], isError: true }; } if (resolved.error) { return { content: [{ type: "text", text: resolved.error }], isError: true }; } result = await openMSXInstance.emu_launch(emuDirectories.OPENMSX_EXECUTABLE, resolved.machine, resolved.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(emuDirectories.MACHINES_DIR); break; case "extensionList": result = await openMSXInstance.getExtensionList(emuDirectories.EXTENSIONS_DIR); break; case "wait": { const total = seconds; const progressToken = extra._meta?.progressToken; let elapsed = 0; try { for (let i = 1; i <= total; i++) { await sleepWithAbort(1000, extra.signal); elapsed = i; if (progressToken !== undefined) { await extra.sendNotification({ method: "notifications/progress", params: { progressToken, progress: i, total, message: `Waited ${i} of ${total} seconds` }, }); } } result = `Waited for ${total} seconds.`; } catch { result = `Wait cancelled after ${elapsed} of ${total} seconds.`; return { content: [{ type: "text", text: result }], isError: true }; } break; } default: result = `Error: Unknown command "${command}".`; break; } if (isErrorResponse(result)) { return { content: [{ type: "text", text: result }], isError: true }; } let structuredContent; switch (command) { case 'getEmulatorSpeed': { const match = result.match(/(\d+)%/); structuredContent = { command, speed: match ? parseInt(match[1]) : undefined, result }; break; } case 'setEmulatorSpeed': { structuredContent = { command, speed: emuspeed, result }; break; } case 'machineList': { try { const machines = JSON.parse(result); structuredContent = { command, machines }; } catch { structuredContent = { command, result }; } break; } case 'extensionList': { try { const extensions = JSON.parse(result); structuredContent = { command, extensions }; } catch { structuredContent = { command, result }; } break; } default: { structuredContent = { command, result }; } } return { content: [{ type: "text", text: result }], structuredContent, isError: false, }; }); // emu_info server.registerTool( // Name of the tool (used to call it) "emu_media", { title: "Emulator media tools", // Description of the tool (what it does) description: "Manage tapes, rom cartridges, and floppy disks.", // Schema for the tool (input validation) inputSchema: { command: z.enum(["tapeInsert", "tapeRewind", "tapeEject", "romInsert", "romEject", "diskInsert", "diskInsertFolder", "diskEject"]) .describe(`Available 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. `), tapefile: z.string() .max(200, 'Tape filename too long') .regex(/^.*(\.cas|\.wav|\.tsx)$/gi, 'Tape filename must end with .cas, .wav, or .tsx') // check also if command is 'tapeInsert' .optional() .describe("Absolute Tape filename to insert. Used by [tapeInsert]"), romfile: z.string() .max(200, 'ROM filename too long') .optional() .describe("Absolute ROM filename to insert. Used by [romInsert]"), diskfile: z.string() .max(200, 'Disk filename too long') .optional() .describe("Absolute Disk filename to insert. Used by [diskInsert]"), diskfolder: z.string() .max(200, 'Disk folder path too long') .optional() .describe("Absolute Disk folder filename to insert. Used by [diskInsertFolder]"), }, annotations: { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false, }, }, // 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 ]); }); // emu_info server.registerTool( // Name of the tool (used to call it) "emu_info", { title: "Emulator info tools", // Description of the tool (what it does) description: "Obtain informacion about the current emulated machine.", // Schema for the tool (input validation) inputSchema: { command: z.enum(["getStatus", "getSlotsMap", "getIOPortsMap"]).describe(`Available 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. `), }, outputSchema: { command: z.string().describe("The command that was executed."), status: z.record(z.string()).optional() .describe("Machine status key-value pairs (type, manufacturer, year, etc.). Present for 'getStatus'."), result: z.string().optional() .describe("Generic result text. Present for 'getSlotsMap' and 'getIOPortsMap'."), }, annotations: { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false, }, }, // Handler for the tool (function to be executed when the tool is called) async ({ command }) => { let response; switch (command) { case "getStatus": response = await openMSXInstance.emu_status(); break; case "getSlotsMap": response = await openMSXInstance.sendCommand("slotmap"); break; case "getIOPortsMap": response = await openMSXInstance.sendCommand("iomap"); break; default: return { content: [{ type: "text", text: `Error: Unknown emulator info command "${command}".` }], isError: true }; } if (isErrorResponse(response)) { return { content: [{ type: "text", text: response }], isError: true }; } let structuredContent; if (command === "getStatus") { try { const status = JSON.parse(response); structuredContent = { command, status }; } catch { structuredContent = { command, result: response }; } } else { //TODO: parse the slotmap and iomap responses into structured content structuredContent = { command, result: response }; } return { content: [{ type: "text", text: response }], structuredContent, isError: false, }; }); // emu_vdp server.registerTool( // Name of the tool (used to call it) "emu_vdp", { title: "VDP tools", // Description of the tool (what it does) description: "Manage the VDP (Video Display Processor).", // Schema for the tool (input validation) inputSchema: { command: z.enum(["getPalette", "getRegisters", "getRegisterValue", "setRegisterValue", "screenGetMode", "screenGetFullText"]) .describe(`Available 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. `), register: z.number() .min(0, 'VDP register number too low') .max(31, 'VDP register number too high') .optional() .describe("VDP register number (0-31) to read/write. Used by [getRegisterValue, setRegisterValue]"), value: z.string() .regex(/^0x[0-9a-fA-F]{2}$/, 'Value must be a 2 digits hexadecimal number') .optional() .describe("2 hexadecimal digits for a VDP register value (e.g. 0x1f). Used by [setRegisterValue]"), }, // Structured output schema (MCP protocol 2025-11-25) outputSchema: { command: z.string() .describe("The executed command name."), registers: z.record(z.string()).optional() .describe("VDP register values as hex strings keyed by register number (0-31). Present for 'getRegisters'."), register: z.number().optional() .describe("VDP register number queried/modified. Present for 'getRegisterValue' and 'setRegisterValue'."), decimalValue: z.number().optional() .describe("Register value in decimal. Present for 'getRegisterValue'."), hexValue: z.string().optional() .describe("Register value in hexadecimal (e.g. '0x1F'). Present for 'getRegisterValue'."), newValue: z.string().optional() .describe("Value written to the register. Present for 'setRegisterValue'."), palette: z.array(z.object({ index: z.number(), r: z.number(), g: z.number(), b: z.number(), rgb: z.string() })).optional() .describe("Color palette as array of 16 RGB333 entries. Present for 'getPalette'."), screenMode: z.string().optional() .describe("Current screen mode name (e.g. 'TEXT80', 'GRAPHIC2'). Present for 'screenGetMode'."), screenText: z.string().optional() .describe("Full text content of the MSX screen. Present for 'screenGetFullText'."), result: z.string().optional() .describe("Generic result or status message."), }, annotations: { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false, }, }, // 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 textResp = await openMSXInstance.sendCommand('get_screen'); if (isErrorResponse(textResp)) { return { content: [{ type: "text", text: textResp }], isError: true }; } return { content: [{ type: "text", text: `The screen text is:\n${textResp}` }], structuredContent: { command, screenText: textResp }, isError: false, }; } default: return { content: [{ type: "text", text: `Error: Unknown emulator vdp command "${command}".` }], isError: true }; } const response = await openMSXInstance.sendCommand(tclCommand); if (isErrorResponse(response)) { return { content: [{ type: "text", text: response }], isError: true }; } let structuredContent; switch (command) { case "getPalette": { const pal = parsePalette(response); structuredContent = { command, palette: pal }; break; } case "getRegisters": { const regs = parseVdpRegs(response); structuredContent = { command, registers: regs }; break; } case "getRegisterValue": { const dec = parseInt(response.trim(), 10); const hex = `0x${dec.toString(16).toUpperCase().padStart(2, '0')}`; structuredContent = { command, register, decimalValue: dec, hexValue: hex }; break; } case "setRegisterValue": { structuredContent = { command, register, newValue: value, result: response || "Ok" }; break; } case "screenGetMode": { structuredContent = { command, screenMode: response.trim() }; break; } default: structuredContent = { command, result: response }; } return { content: [{ type: "text", text: response || "Ok" }], structuredContent, isError: false, }; }); // debug_run server.registerTool( // Name of the tool (used to call it) "debug_run", { title: "CPU Runtime Debugger tools", // Description of the tool (what it does) description: "Control execution (break, continue, step).", // Schema for the tool (input validation) inputSchema: { command: z.enum(["break", "isBreaked", "continue", "stepIn", "stepOut", "stepOver", "stepBack", "runTo"]) .describe(`Available commands: 'break': to break CPU (pause emulation) 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. **Important Note**: Addresses and values are in hexadecimal format (e.g. 0x0000). `), address: z.string() .regex(/^0x[0-9a-fA-F]{4}$/, 'Address must be a 4 digits hexadecimal number') .optional() .describe("4 hexadecimal digits for a memory address (e.g. 0x4af3). Used by [runTo]"), }, annotations: { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false, }, }, // 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); //TODO: parse disassembly command response into structured content return getResponseContent([ response ]); }); // debug_cpu server.registerTool( // Name of the tool (used to call it) "debug_cpu", { title: "CPU tools", // Description of the tool (what it does) description: "Read/write CPU registers, CPU info, Stack pile, and Disassemble code from memory.", // Schema for the tool (input validation) inputSchema: { command: z.enum(["getCpuRegisters", "getRegister", "setRegister", "getStackPile", "disassemble", "getActiveCpu"]) .describe(`Available 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. "**Important Note**: Addresses and values are in hexadecimal format (e.g. 0xd2 0x3af2)." `), register: z.enum(["pc", "sp", "ix", "iy", "af", "bc", "de", "hl", "ixh", "ixl", "iyh", "iyl", "af'", "bc'", "de'", "hl'", "a", "f", "b", "c", "d", "e", "h", "l", "i", "r", "im", "iff"]) .optional() .describe("CPU register to read/write. Used by [getRegister, setRegister]"), address: z.string() .regex(/^0x[0-9a-fA-F]{4}$/, 'Address must be a 4 digits hexadecimal number') .optional() .describe("4 hexadecimal digits for a memory address (e.g. 0x4af3). Used by [disassemble]"), value: z.string() .regex(/^0x[0-9a-fA-F]{2,4}$/, 'Value must be a 2-4 digits hexadecimal number') .optional() .describe("2-4 hexadecimal digits for a byte value (e.g. 0xa5 or 0xa5b1). Used by [setRegister]"), size: z.number() .min(8, 'Minimum disassemble size too small') .max(50, 'Maximum disassemble size too large') .optional() .describe("Number of bytes to disassemble. Used by [disassemble]"), }, // Structured output schema (MCP protocol 2025-11-25) outputSchema: { command: z.string() .describe("The executed command name."), registers: z.record(z.string()).optional() .describe("All CPU register values as hex strings, keyed by register name (AF, BC, DE, HL, AF', BC', DE', HL', IX, IY, PC, SP, I, R, IM, IFF). Present for 'getCpuRegisters'."), register: z.string().optional() .describe("CPU register name queried/modified. Present for 'getRegister' and 'setRegister'."), decimalValue: z.number().optional() .describe("Register value in decimal. Present for 'getRegister'."), hexValue: z.string().optional() .describe("Register value in hexadecimal (e.g. '0x1A3F'). Present for 'getRegister'."), newValue: z.string().optional() .describe("Value written to the register. Present for 'setRegister'."), activeCpu: z.string().optional() .describe("Active CPU type: 'z80' or 'r800'. Present for 'getActiveCpu'."), stack: z.string().optional() .describe("Stack pile dump content. Present for 'getStackPile'."), disassembly: z.string().optional() .describe("Disassembled code listing. Present for 'disassemble'."), result: z.string().optional() .describe("Generic result or status message."), }, annotations: { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false, }, }, // 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 { content: [{ type: "text", text: `Error: Unknown command "${command}".` }], isError: true, }; } const response = await openMSXInstance.sendCommand(tclCommand); // On error, return unstructured content only (SDK skips outputSchema validation on errors) if (isErrorResponse(response)) { return { content: [{ type: "text", text: response }], isError: true, }; } // Build structuredContent based on the command let structuredContent; switch (command) { case "getCpuRegisters": { const regs = parseCpuRegs(response); structuredContent = { command, registers: regs }; break; } case "getRegister": { const decValue = parseInt(response.trim(), 10); const padLen = is16bitRegister(register) ? 4 : 2; const hexVal = `0x${decValue.toString(16).toUpperCase().padStart(padLen, '0')}`; structuredContent = { command, register, decimalValue: decValue, hexValue: hexVal }; break; } case "setRegister": { structuredContent = { command, register, newValue: value, result: response || "Ok" }; break; } case "getStackPile": { //TODO: parse the stack pile response into structured content (e.g. an array of stack entries with address and value) structuredContent = { command, stack: response }; break; } case "disassemble": { //TODO: parse the disassembly response into a structured format (e.g. an array of instructions with address, opcode, and assembly) structuredContent = { command, disassembly: response }; break; } case "getActiveCpu": { structuredContent = { command, activeCpu: response.trim() }; break; } default: structuredContent = { command, result: response }; } return { content: [{ type: "text", text: response || "Ok" }], structuredContent, isError: false, }; }); // debug_memory server.registerTool( // Name of the tool (used to call it) "debug_memory", { title: "Memory tools", // Description of the tool (what it does) description: "Slots info, and Read/write from/to memory in the openMSX emulator.", // Schema for the tool (input validation) inputSchema: { command: z.enum(["selectedSlots", "getBlock", "readByte", "readWord", "writeByte", "writeWord", "searchBytes"]) .describe(`Available 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. 'searchBytes <address> <length> <values>': to search a sequence of bytes in RAM memory starting from the specified address and within the specified length. **Important Note**: Addresses and values are in hexadecimal format (e.g. 0x0000). `), address: z.string() .regex(/^0x[0-9a-fA-F]{4}$/, 'Address must be a 4 digits hexadecimal number') .optional() .describe("4 hexadecimal digits for a memory address (e.g. 0x4af3). Used by [getBlock, readByte, writeByte, readWord, writeWord]"), lines: z.number() .min(1, 'Minimum number of lines too low') .max(50, 'Maximum number of lines too high') .optional() .default(8) .describe("Number of lines to obtain. Used by [getBlock]"), value8: z.string() .regex(/^0x[0-9a-fA-F]{2}$/, 'Must be a 2 digits hexadecimal number') .optional() .describe("2 hexadecimal digits for a byte value (e.g. 0xa5). Used by [writeByte]"), value16: z.string() .regex(/^0x[0-9a-fA-F]{4}$/, 'Must be a 4 digits hexadecimal number') .optional() .describe("4 hexadecimal digits for a word value (e.g. 0xa5b1). Used by [writeWord]"), values: z.string() .regex(/^(\s*0x[0-9a-fA-F]{2}\s*)+$/, "Values must be a space-separated string of 2 digits hexadecimal numbers (e.g. '0x1A 0xFF 0x00')") .optional() .describe("Space-separated string of 2 hexadecimal digits for byte values to search (e.g. '0x1A 0xFF 0x00'). Used by [searchBytes]"), length: z.number() .min(1, 'Minimum search length too low. Min: 1') .max(65536, 'Maximum search length too high. Max: 65536') .optional() .describe("Decimal number of bytes to search within. Used by [searchBytes]"), }, // Structured output schema (MCP protocol 2025-11-25) outputSchema: { command: z.string() .describe("The executed command name."), address: z.string().optional() .describe("Memory address queried/modified."), decimalValue: z.number().optional() .describe("Memory value in decimal. Present for 'readByte' and 'readWord'."), hexValue: z.string().optional() .describe("Memory value in hexadecimal. Present for 'readByte' and 'readWord'."), hexDump: z.string().optional() .describe("Hex dump block of memory. Present for 'getBlock'."), slots: z.string().optional() .describe("Currently selected memory slots info. Present for 'selectedSlots'."), length: z.number().optional() .describe("Length of bytes searched. Present for 'searchBytes'."), values: z.string().optional() .describe("Values searched for. Present for 'searchBytes'."), result: z.string().optional() .describe("Generic result or status message."), }, annotations: { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false, }, }, // Handler for the tool (function to be executed when the tool is called) async ({ command, address, lines, value8, value16, length, values }) => { 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 "searchBytes": length = parseInt(address, 16) + length > 0x10000 ? 0x10000 - parseInt(address, 16) : length; tclCommand = `set pattern { ${values} } set len [llength $pattern] set results "" for {set i ${address}} {$i \<= [expr {${address} + ${length} - $len}]} {incr i} { set match 1 for {set j 0} {$j \< $len} {incr j} { if {[peek [expr {$i + $j}]] != [lindex $pattern $j]} { set match 0; break } } if {$match} { append results [format "Found at 0x%04X\n" $i] } } if {$results eq ""} { return "No matches found" } return $results`; break; default: return { content: [{ type: "text", text: `Error: Unknown memory command "${command}".` }], isError: true }; } const response = await openMSXInstance.sendCommand(tclCommand); if (isErrorResponse(response)) { return { content: [{ type: "text", text: response }], isError: true }; } let structuredContent; switch (command) { case "selectedSlots": { //TODO: parse the slotselect response into structured content (e.g. an array of selected slots with slot number and content info) structuredContent = { command, slots: response }; break; } case "getBlock": { structuredContent = { command, address, hexDump: response }; break; } case "readByte": { const dec = parseInt(response.trim(), 10); structuredContent = { command, address, decimalValue: dec, hexValue: `0x${dec.toString(16).toUpperCase().padStart(2, '0')}` }; break; } case "readWord": { const dec = parseInt(response.trim(), 10); structuredContent = { command, address, decimalValue: dec, hexValue: `0x${dec.toString(16).toUpperCase().padStart(4, '0')}` }; break; } case "writeByte": { structuredContent = { command, address, result: response || "Ok" }; break; } case "writeWord": { structuredContent = { command, address, result: response || "Ok" }; break; } case "searchBytes": { structuredContent = { command, address, length, values, result: response }; break; } default: structuredContent = { command, result: response }; } return { content: [{ type: "text", text: response || "Ok" }], structuredContent, isError: false, }; }); // debug_vram server.registerTool( // Name of the tool (used to call it) "debug_vram", { title: "VRAM tools", // Description of the tool (what it does) description: "Read or write from/to VRAM video memory from the openMSX emulator.", // Schema for the tool (input validation) inputSchema: { command: z.enum(["getBlock", "readByte", "writeByte", "searchBytes"]) .describe(`Available 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. 'searchBytes <address> <length> <values>': to search a sequence of bytes in VRAM memory starting from the specified address and within the specified length. **Important Note**: Addresses and values are in hexadecimal format (e.g. 0x0000). `), address: z.string() .regex(/^0x[0-9a-fA-F]{5}$/, 'Address must be a 5 digits hexadecimal number') .optional() .describe("5 hexadecimal digits for a VRAM address (e.g. 0x04af3). Used by [getBlock, readByte, writeByte]"), lines: z.number() .min(1, 'Minimum number of lines too low') .max(50, 'Maximum number of lines too high') .optional() .default(8) .describe("Number of lines to obtain. Used by [getBlock]"), value8: z.string().regex(/^0x[0-9a-fA-F]{2}$/).optional().describe("2 hexadecimal digits for a byte value (e.g. 0xa5). Used by [writeByte]"), values: z.string() .regex(/^(\s*0x[0-9a-fA-F]{2}\s*)+$/, "Values must be a space-separated string of 2 digits hexadecimal numbers (e.g. '0x1A 0xFF 0x00')") .optional() .describe("Space-separated string of 2 hexadecimal digits for byte values to search (e.g. '0x1A 0xFF 0x00'). Used by [searchBytes]"), length: z.number() .min(1, 'Minimum search length too low. Min: 1') .max(65536, 'Maximum search length too high. Max: 65536') .optional() .describe("Decimal number of bytes to search within. Used by [searchBytes]"), }, // Structured output schema (MCP protocol 2025-11-25) outputSchema: { command: z.string() .describe("The executed command name."), address: z.string().optional() .describe("VRAM address queried/modified."), decimalValue: z.number().optional() .describe("VRAM byte value in decimal. Present for 'readByte'."), hexValue: z.string().optional() .describe("VRAM byte value in hexadecimal. Present for 'readByte'."), hexDump: z.string().optional() .describe("Hex dump block of VRAM. Present for 'getBlock'."), length: z.number().optional() .describe("Length of bytes searched. Present for 'searchBytes'."), values: z.string().optional() .describe("Values searched for. Present for 'searchBytes'."), result: z.string().optional() .describe("Generic result or status message."), }, annotations: { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false, }, }, // Handler for the tool (function to be executed when the tool is called) async ({ command, address, lines, value8, values, length }) => { 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; case "searchBytes": length = parseInt(address, 16) + length > 0x20000 ? 0x20000 - parseInt(address, 16) : length; tclCommand = `set pattern { ${values} } set len [llength $pattern] set results "" for {set i ${address}} {$i \<= [expr {${address} + ${length} - $len}]} {incr i} { set match 1 for {set j 0} {$j \< $len} {incr j} { if {[vpeek [expr {$i + $j}]] != [lindex $pattern $j]} { set match 0; break } } if {$match} { append results [format "Found at 0x%04X\n" $i] } } if {$results eq ""} { return "No matches found" } return $results`; break; default: return { content: [{ type: "text", text: `Error: Unknown video memory command "${command}".` }], isError: true }; } const response = await openMSXInstance.sendCommand(tclCommand); if (isErrorResponse(response)) { return { content: [{ type: "text", text: response }], isError: true }; } let structuredContent; switch (command) { case "getBlock": { structuredContent = { command, address, hexDump: response }; break; } case "readByte": { const dec = parseInt(response.trim(), 10); structuredContent = { command, address, decimalValue: dec, hexValue: `0x${dec.toString(16).toUpperCase().padStart(2, '0')}` }; break; } case "writeByte": { structuredContent = { command, address, result: response || "Ok" }; break; } case "searchBytes": { structuredContent = { command, address, length, values, result: response }; break; } default: structuredContent = { command, result: response }; } return { content: [{ type: "text", text: response || "Ok" }], structuredContent, isError: false, }; }); // debug_breakpoints server.registerTool( // Name of the tool (used to call it) "debug_breakpoints", { title: "Breakpoints tools", // Description of the tool (what it does) description: "Create, remove, and list breakpoints.", // Schema for the tool (input validation) inputSchema: { command: z.enum(["create", "remove", "list"]) .describe(`Available 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. "**Impor