@nataliapc/mcp-openmsx
Version:
Model context protocol server for openMSX automation and control
556 lines (555 loc) • 20.7 kB
JavaScript
/**
* Utils functions for mcp-openmsx
*
* @author Natalia Pujol Cremades (@nataliapc)
* @license GPL2
*/
import fs from 'fs/promises';
import path from 'path';
import mime from 'mime-types';
import { gunzipSync } from 'zlib';
import { PACKAGE_VERSION } from "./server.js";
import sanitizeHtml from 'sanitize-html';
import { existsSync } from "fs";
import os from "os";
/**
* Detect the openMSX executable path for the current platform.
*
* - Linux: 'openmsx' (expected in PATH after package install)
* - Windows: 'openmsx.exe' (Node spawn() needs the .exe extension on Windows)
* - macOS: probes the standard .app bundle path first; falls back to 'openmsx'
* in case the user has it in PATH (e.g. via Homebrew or manual install).
*
* The standard macOS bundle path is /Applications/openMSX.app/Contents/MacOS/openmsx.
* This is the path documented by the openMSX project (and its Catapult launcher).
*/
export function detectOpenMSXExecutable() {
if (process.platform === 'win32') {
return 'openmsx.exe';
}
if (process.platform === 'darwin') {
const appBundlePath = '/Applications/openMSX.app/Contents/MacOS/openmsx';
if (existsSync(appBundlePath)) {
return appBundlePath;
}
// Fallback: user may have openMSX in PATH (e.g. via Homebrew)
return 'openmsx';
}
// Linux / other POSIX
return 'openmsx';
}
/**
* Detect the openMSX share directory by checking various methods
* @returns string - The detected share directory or an empty string if not found
*/
export function detectOpenMSXShareDir() {
try {
// Check standard locations
const possiblePaths = [
// Linux paths
path.join(os.homedir(), '.openMSX', 'share'),
'/usr/local/share/openmsx',
'/usr/share/openmsx',
// Windows paths (appdata, documents, program files, portable)
...(process.env.APPDATA ? [path.join(process.env.APPDATA, 'openMSX', 'share')] : []),
...(process.env.LOCALAPPDATA ? [path.join(process.env.LOCALAPPDATA, 'openMSX', 'share')] : []),
path.join(os.homedir(), 'Documents', 'openMSX', 'share'),
path.join(os.homedir(), 'openMSX', 'share'),
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'openMSX', 'share'),
...(process.env['PROGRAMFILES(X86)'] ? [path.join(process.env['PROGRAMFILES(X86)'], 'openMSX', 'share')] : []),
// macOS paths
path.join(os.homedir(), 'Library', 'Application Support', 'openMSX', 'share'),
'/Applications/openMSX.app/Contents/Resources/share',
];
for (const dirPath of possiblePaths) {
if (existsSync(dirPath) && existsSync(path.join(dirPath, 'machines'))) {
return dirPath;
}
}
}
catch (error) {
console.error('Error detecting openMSX share directory:', error);
}
return '';
}
/**
* Extract description from XML file
* @param filePath - Full path to the XML file
* @returns Promise<string> - The description found in the XML file, or a default message
*/
export async function extractDescriptionFromXML(filePath) {
try {
const xmlContent = await fs.readFile(filePath, 'utf-8');
// Extract description from XML
const descriptionMatch = xmlContent.match(/<description>(.*?)<\/description>/);
return descriptionMatch ? descriptionMatch[1] : 'No description available';
}
catch (error) {
return 'Error reading description';
}
}
/**
* Add file extension to a file path if it exists in the same directory and determine its MIME type
* @param filePath - Full path to the file excluding extension
* @returns Promise<string[]> - An array containing the MIME type and the full file path with extension
*/
export async function addFileExtension(filePath) {
// Get directory and filename
const directory = path.dirname(filePath);
const filename = path.basename(filePath);
try {
// Get all files in directory that start with our filename
const files = await fs.readdir(directory);
const matchingFiles = files.filter(file => file.startsWith(filename));
if (matchingFiles.length > 0) {
const fileFound = path.join(directory, matchingFiles[0]);
return [
mime.lookup(fileFound) || 'text/plain',
fileFound
];
}
}
catch (error) {
console.error('Error reading directory:', error);
}
// Return original if no matches found
return ['text/plain', filePath];
}
/**
* List all folders and subfolders in the resources directory
* @param resourcesDir - Path to the resources directory
* @returns Promise<string[]> - List of folder names in the resources directory
*/
export async function listResourcesDirectory(resourcesDir) {
try {
const directories = await fs.readdir(resourcesDir, { withFileTypes: true, recursive: true });
const folderNames = directories
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
return folderNames;
}
catch (error) {
console.error("Error reading resources directory:", error);
return [];
}
}
/**
* Fetch a webpage and return its content (without scripts, styles, or links) and its MIME type
* @param url - URL of the webpage to fetch
* @returns Promise<[string, string]> - A tuple containing the webpage content and its MIME type
*/
export async function fetchCleanWebpage(url) {
let resourceContent;
let mimeType = 'text/plain';
try {
const response = await fetch(url, {
headers: {
// Accept compressed content gzip/deflate
'Accept-Encoding': 'gzip, deflate, br',
// User agent to avoid blocking by some servers
'User-Agent': `Mozilla/5.0 (compatible; MCP-openMSX/${PACKAGE_VERSION})`
}
});
if (response.status === 200) {
mimeType = response.headers.get('content-type') || 'text/plain';
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('x-gzip') || contentType.includes('gzip')) {
const arrayBuffer = await response.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
if (uint8Array[0] === 0x1f && uint8Array[1] === 0x8b) {
try {
const decompressed = gunzipSync(Buffer.from(uint8Array));
resourceContent = decompressed.toString('utf8');
mimeType = 'text/html';
}
catch (error) {
resourceContent = new TextDecoder().decode(uint8Array);
mimeType = 'text/html';
}
}
else {
resourceContent = new TextDecoder().decode(uint8Array);
mimeType = 'text/html';
}
}
else {
// Normal case, use response.text() which automatically handles decompression
resourceContent = await response.text();
}
}
else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Remove script, style, form, and link tags from the content if it's HTML
if (mimeType.startsWith('text/html')) {
resourceContent = sanitizeHtml(resourceContent);
}
}
catch (error) {
// Throw exception (MCP protocol requirement)
throw new Error(`Error fetching resource from ${url}: ${error instanceof Error ? error.message : String(error)}`);
}
return [resourceContent, mimeType];
}
/**
* Decode HTML entities in a string to plain text
* @param text - String containing HTML entities
* @returns string - String with HTML entities decoded
*/
export function decodeHtmlEntities(text) {
const htmlEntities = {
'<': '<',
'>': '>',
'&': '&',
'"': '"',
' ': ' ',
''': "'",
'/': '/',
'`': '`',
'=': '=',
''': "'",
''': "'",
'/': '/',
'`': '`',
'=': '=',
'
': '\n',
'
': '\n',
' ': '\n',
' ': '\r',
'	': '\t'
};
return text.replace(/&[#\w]+;/g, (entity) => {
return htmlEntities[entity] || entity;
});
}
/**
* Encode a plain text to HTML entities, also escaping characters with ASCII >= 127
* @param text - String to encode
* @returns string - String with HTML entities encoded
*/
export function encodeHtmlEntities(text) {
const htmlEntities = {
'<': '<',
'>': '>',
'&': '&',
'"': '"',
"'": ''',
'=': '=',
'/': '/',
};
return text.replace(/[\u00A0-\uFFFF<>&"'`=\/]/g, (char) => {
if (htmlEntities[char]) {
return htmlEntities[char];
}
const code = char.charCodeAt(0);
if (code >= 127) {
return `&#${code};`;
}
return char;
});
}
/**
* Encode a string for BASIC program input, escaping special characters
* @param text - String to encode
* @returns string - Encoded string with special characters escaped
*/
export function encodeTypeText(text) {
const replacementMap = {
'\r': '\\r',
'\t': '\\t',
'"': '\\"',
};
return text.replace(/[\r\t"]/g, (char) => {
if (replacementMap[char]) {
return replacementMap[char];
}
return char;
});
}
/**
* MSX keyboard matrix mapping for International (QWERTY) layout.
* Maps key names to [row, mask] coordinates in the MSX keyboard matrix.
*
* The MSX keyboard matrix is 11 rows × 8 bits. To press a key:
* - Use `keymatrixdown <row> <mask>` in openMSX TCL
* - Use `keymatrixup <row> <mask>` to release
*
* Example: CTRL is at row 6, bit 1 (mask 0x02)
* - Press: `keymatrixdown 6 0x02`
* - Release: `keymatrixup 6 0x02`
*
* Note: Rows 6-8 (modifier keys, special keys, navigation) are consistent
* across all MSX models. Other rows may vary by keyboard layout.
*
* Reference: mcp-server/resources/others/keyboard_matrices.md
*/
export const MSX_KEY_MATRIX = {
// Row 6: Modifier and function keys
'SHIFT': [6, 0x01], // bit 0
'CTRL': [6, 0x02], // bit 1
'GRAPH': [6, 0x04], // bit 2
'CAPS': [6, 0x08], // bit 3
'CODE': [6, 0x10], // bit 4
'F1': [6, 0x20], // bit 5
'F2': [6, 0x40], // bit 6
'F3': [6, 0x80], // bit 7
// Row 7: Special keys
'F4': [7, 0x01], // bit 0
'F5': [7, 0x02], // bit 1
'ESC': [7, 0x04], // bit 2
'TAB': [7, 0x08], // bit 3
'STOP': [7, 0x10], // bit 4
'BS': [7, 0x20], // bit 5
'SELECT': [7, 0x40], // bit 6
'RETURN': [7, 0x80], // bit 7
'ENTER': [7, 0x80], // bit 7 (alias for RETURN)
// Row 8: Navigation and editing keys
'SPACE': [8, 0x01], // bit 0
'HOME': [8, 0x02], // bit 1
'INS': [8, 0x04], // bit 2
'DEL': [8, 0x08], // bit 3
'LEFT': [8, 0x10], // bit 4
'UP': [8, 0x20], // bit 5
'DOWN': [8, 0x40], // bit 6
'RIGHT': [8, 0x80], // bit 7
};
/**
* Build a TCL command to press and release a combination of keys on the MSX keyboard.
*
* @param keys - Array of key names (e.g., ["CTRL", "STOP"])
* @param holdTimeMs - Time in milliseconds to hold keys down (default: 100)
* @returns TCL command string for openMSX
* @throws Error if any key name is not recognized
*
* @example
* // Press CTRL+STOP for 100ms
* buildKeyComboCommand(["CTRL", "STOP"], 100)
* // Returns: "keymatrixdown 6 0x02 ; keymatrixdown 7 0x10 ; after time 0.1 { keymatrixup 6 0x02 ; keymatrixup 7 0x10 }"
*/
export function buildKeyComboCommand(keys, holdTimeMs = 100) {
if (!keys || keys.length === 0) {
throw new Error('No keys provided for key combination');
}
// Validate all keys exist and collect their matrix coordinates
const keyCoords = [];
for (const key of keys) {
const keyUpper = key.toUpperCase();
if (!MSX_KEY_MATRIX[keyUpper]) {
const validKeys = Object.keys(MSX_KEY_MATRIX).join(', ');
throw new Error(`Unknown key "${key}". Valid keys: ${validKeys}`);
}
keyCoords.push(MSX_KEY_MATRIX[keyUpper]);
}
// Build press commands: keymatrixdown <row> <mask> for each key
const pressCommands = keyCoords
.map(([row, mask]) => `keymatrixdown ${row} ${mask}`)
.join(' ; ');
// Build release commands: keymatrixup <row> <mask> for each key
const releaseCommands = keyCoords
.map(([row, mask]) => `keymatrixup ${row} ${mask}`)
.join(' ; ');
// Convert milliseconds to seconds for openMSX 'after time' command
const holdTimeSec = holdTimeMs / 1000;
// Build full TCL command: press all keys, wait, then release all keys
return `${pressCommands} ; after time ${holdTimeSec} { ${releaseCommands} }`;
}
/**
* Check if a response is an error response
* @param response - The response string to check
* @returns boolean - True if the response indicates an error, false otherwise
*/
export function isErrorResponse(response) {
return response.startsWith('Error:') || response.startsWith('error:');
}
/**
* Get the content of a response, formatting it as a CallToolResult
* @param response - Array of strings representing the response lines
* @param isError - Optional boolean indicating if the response is an error
* @returns CallToolResult - Formatted response content
*/
export 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 => isErrorResponse(line));
return {
content: response.map(line => ({
type: "text",
text: line == '' ? "Ok" : line,
})),
isError: hasError
};
}
/**
* Parse the output of the openMSX 'cpuregs' TCL command into a structured object.
* The output format is:
* AF =0044 BC =0000 DE =0000 HL =F380
* AF'=0000 BC'=0000 DE'=0000 HL'=0000
* IX =0000 IY =0000 PC =632F SP =F37E
* I =00 R =5D IM =01 IFF=01
* @param response - Raw text response from the cpuregs command
* @returns Record mapping register names to hex values
*/
export function parseCpuRegs(response) {
const registers = {};
const regex = /(\w+'?)\s*=\s*([0-9a-fA-F]+)/g;
let match;
while ((match = regex.exec(response)) !== null) {
registers[match[1]] = match[2];
}
return registers;
}
/**
* Check if a CPU register name corresponds to a 16-bit register.
* @param register - Register name (e.g. 'pc', 'a', 'ix')
* @returns true if the register is 16-bit
*/
export function is16bitRegister(register) {
return ["pc", "sp", "ix", "iy", "af", "bc", "de", "hl"].includes(register.toLowerCase());
}
/**
* Parse the output of the openMSX 'vdpregs' TCL command into a structured object.
* Output format:
* 0 : 0x04 8 : 0x08 16 : 0x00 24 : 0x00
* 1 : 0x70 9 : 0x02 17 : 0x18 25 : 0x00
* ...
* @param response - Raw text response from the vdpregs command
* @returns Record mapping register number (string) to hex value string
*/
export function parseVdpRegs(response) {
const registers = {};
const regex = /(\d+)\s*:\s*(0x[0-9a-fA-F]{2})/g;
let match;
while ((match = regex.exec(response)) !== null) {
registers[match[1]] = match[2];
}
return registers;
}
/**
* Parse the output of the openMSX 'palette' TCL command into a structured object.
* Output format:
* 0:000 4:117 8:711 c:141
* 1:000 5:237 9:733 d:625
* ...
* @param response - Raw text response from the palette command
* @returns Array of 16 palette entries with index, r, g, b values
*/
export function parsePalette(response) {
const palette = [];
const regex = /([0-9a-fA-F]):([0-7])([0-7])([0-7])/g;
let match;
while ((match = regex.exec(response)) !== null) {
palette.push({
index: parseInt(match[1], 16),
r: parseInt(match[2]),
g: parseInt(match[3]),
b: parseInt(match[4]),
rgb: match[2] + match[3] + match[4],
});
}
palette.sort((a, b) => a.index - b.index);
return palette;
}
/**
* Parse the output of the openMSX 'debug list_bp' TCL command into a structured array.
* Output format:
* bp#1 0x4000 {} {debug break}
* bp#2 0x8000 {} {debug break}
* @param response - Raw text response from the debug list_bp command
* @returns Array of breakpoint objects
*/
export function parseBreakpoints(response) {
if (!response.trim())
return [];
const breakpoints = [];
const lines = response.trim().split('\n');
for (const line of lines) {
const match = line.match(/^(\S+)\s+(0x[0-9a-fA-F]{4})\s+\{([^}]*)\}\s+\{([^}]*)\}/);
if (match) {
breakpoints.push({
name: match[1],
address: match[2],
condition: match[3],
command: match[4],
});
}
}
return breakpoints;
}
/**
* Parse the output of the openMSX 'reverse status' TCL command into a structured object.
* Output format:
* status enabled begin 0.0 end 294.08 current 294.08 snapshots {...} last_event 0.0
* @param response - Raw text response from the reverse status command
* @returns Structured replay status object
*/
export function parseReplayStatus(response) {
const statusMatch = response.match(/status\s+(\w+)/);
const beginMatch = response.match(/begin\s+([\d.]+)/);
const endMatch = response.match(/end\s+([\d.]+)/);
const currentMatch = response.match(/current\s+([\d.]+)/);
const snapshotsMatch = response.match(/snapshots\s+\{([^}]*)\}/);
const snapshotCount = snapshotsMatch ? snapshotsMatch[1].trim().split(/\s+/).filter(s => s).length : 0;
return {
enabled: statusMatch ? statusMatch[1] === 'enabled' : false,
begin: beginMatch ? parseFloat(beginMatch[1]) : 0,
end: endMatch ? parseFloat(endMatch[1]) : 0,
current: currentMatch ? parseFloat(currentMatch[1]) : 0,
snapshotCount,
};
}
/**
* Ensure a directory exists, creating it (and any parents) if necessary.
* @param dirPath - Absolute path to the directory
* @returns null on success, or an error message string if creation failed
*/
export async function ensureDirectoryExists(dirPath) {
try {
await fs.mkdir(dirPath, { recursive: true });
return null;
}
catch (error) {
return `Cannot create directory "${dirPath}": ` +
(error instanceof Error ? error.message : String(error));
}
}
/**
* Normalize a file path for use inside openMSX TCL commands.
* Converts Windows backslashes to forward slashes, which TCL accepts on all platforms.
* On Linux/macOS this is a no-op (no backslashes to replace).
* @param filePath - File path to normalize
* @returns Path with forward slashes only
*/
export function tclPath(filePath) {
return filePath.replace(/\\/g, '/');
}
/*
* Sleep for a specified number of milliseconds
* @param ms - Number of milliseconds to sleep
* @returns Promise that resolves after the specified time
*/
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Sleep for a specified number of milliseconds, with support for cancellation via AbortSignal.
* @param ms - Number of milliseconds to sleep
* @param signal - AbortSignal to cancel the sleep early
* @returns Promise that resolves after the specified time, or rejects if aborted
* @throws {DOMException} If the signal is aborted (with name 'AbortError')
*/
export function sleepWithAbort(ms, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
return;
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort, { once: true });
});
}