@nataliapc/mcp-openmsx 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +339 -0
- package/README.md +193 -0
- package/dist/openmsx.js +331 -0
- package/dist/server.js +912 -0
- package/dist/utils.js +23 -0
- package/package.json +60 -0
package/dist/openmsx.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openMSX wrapper class
|
|
3
|
+
*
|
|
4
|
+
* @author Natalia Pujol Cremades (@nataliapc)
|
|
5
|
+
* @license GPL2
|
|
6
|
+
*/
|
|
7
|
+
import fs from "fs/promises";
|
|
8
|
+
import { extractDescriptionFromXML } from "./utils.js";
|
|
9
|
+
import { spawn } from 'child_process';
|
|
10
|
+
/**
|
|
11
|
+
* OpenMSX class for controlling the openMSX emulator via TCL commands over TCP socket
|
|
12
|
+
*/
|
|
13
|
+
export class OpenMSX {
|
|
14
|
+
process = null;
|
|
15
|
+
isConnected = false;
|
|
16
|
+
/**
|
|
17
|
+
* Launch the openMSX emulator in stdio control mode
|
|
18
|
+
* @param machine - MSX machine to emulate (e.g., 'Panasonic_FS-A1GT', 'C-BIOS_MSX2+')
|
|
19
|
+
* @param extensions - Array of extensions to load (e.g., ['fmpac', 'ide'])
|
|
20
|
+
* @returns Promise that resolves when the emulator is ready
|
|
21
|
+
*/
|
|
22
|
+
async emu_launch(executable, machine, extensions) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
let resolved = false;
|
|
25
|
+
let connectionTime = null;
|
|
26
|
+
const FATAL_ERROR_GRACE_PERIOD = 500; // 1/2 second grace period after connection
|
|
27
|
+
const safeResolve = (message) => {
|
|
28
|
+
if (!resolved) {
|
|
29
|
+
resolved = true;
|
|
30
|
+
resolve(message);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
// Check if emulator is already running
|
|
35
|
+
if (this.process && !this.process.killed) {
|
|
36
|
+
safeResolve("Error: openMSX emulator is already running. Close it first before launching a new instance.");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Build command line arguments
|
|
40
|
+
const args = ['-control', 'stdio'];
|
|
41
|
+
// Add machine parameter if specified
|
|
42
|
+
if (machine) {
|
|
43
|
+
args.push('-machine', machine);
|
|
44
|
+
}
|
|
45
|
+
// Add extensions if specified
|
|
46
|
+
if (extensions && extensions.length > 0) {
|
|
47
|
+
extensions.forEach(ext => {
|
|
48
|
+
args.push('-ext', ext);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
// Launch openMSX with stdio control
|
|
52
|
+
this.process = spawn(executable, args, {
|
|
53
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
54
|
+
});
|
|
55
|
+
if (!this.process.stdin || !this.process.stdout || !this.process.stderr) {
|
|
56
|
+
safeResolve('Error: Failed to create stdio pipes');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Check if process was launched successfully
|
|
60
|
+
if (!this.process.pid || this.process.killed) {
|
|
61
|
+
const stderrMessage = this.process.stderr.read()?.toString() || 'Failed to launch openMSX process';
|
|
62
|
+
this.process = null; // Reset process to null on failure
|
|
63
|
+
this.isConnected = false;
|
|
64
|
+
safeResolve(`Error: ${stderrMessage}`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Handle process events
|
|
68
|
+
this.process.on('error', (error) => {
|
|
69
|
+
console.error('openMSX process error:', error);
|
|
70
|
+
safeResolve(`Error: ${error.message}`);
|
|
71
|
+
});
|
|
72
|
+
this.process.on('exit', (code, signal) => {
|
|
73
|
+
this.isConnected = false;
|
|
74
|
+
this.process = null;
|
|
75
|
+
});
|
|
76
|
+
// Wait for the opening XML tag to confirm connection
|
|
77
|
+
this.process.stdout.on('data', (data) => {
|
|
78
|
+
const output = data.toString();
|
|
79
|
+
if (output.includes('<openmsx-output>')) {
|
|
80
|
+
this.isConnected = true;
|
|
81
|
+
connectionTime = Date.now();
|
|
82
|
+
// Don't resolve immediately, wait for potential fatal errors
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
// Only resolve if no fatal error occurred during grace period
|
|
85
|
+
if (!resolved) {
|
|
86
|
+
try {
|
|
87
|
+
this.writeData('<openmsx-control>\n');
|
|
88
|
+
// Set renderer to SDL
|
|
89
|
+
this.sendCommand('set renderer SDLGL-PP');
|
|
90
|
+
// set machine on
|
|
91
|
+
this.sendCommand('set power on');
|
|
92
|
+
// Return success message
|
|
93
|
+
safeResolve('Ok: openMSX emulator launched successfully');
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
safeResolve(`Error: Failed to send control commands - ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}, FATAL_ERROR_GRACE_PERIOD);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
// Handle stderr - check for fatal errors during grace period
|
|
103
|
+
this.process.stderr.on('data', (data) => {
|
|
104
|
+
const errorOutput = data.toString();
|
|
105
|
+
// Check for fatal errors before connection or during grace period
|
|
106
|
+
const isInGracePeriod = connectionTime && (Date.now() - connectionTime) < FATAL_ERROR_GRACE_PERIOD;
|
|
107
|
+
if (errorOutput.includes('Fatal error:') && (!this.isConnected || isInGracePeriod)) {
|
|
108
|
+
this.forceClose();
|
|
109
|
+
safeResolve(`Error: ${errorOutput.trim()}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
// Set timeout for connection
|
|
114
|
+
setTimeout(() => {
|
|
115
|
+
if (!this.isConnected) {
|
|
116
|
+
this.emu_close();
|
|
117
|
+
safeResolve('Error: Timeout waiting for openMSX to start');
|
|
118
|
+
}
|
|
119
|
+
}, 5000); // 5 second timeout
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
safeResolve(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Close the openMSX emulator process
|
|
128
|
+
* @returns Promise that resolves when the process is closed
|
|
129
|
+
*/
|
|
130
|
+
async emu_close() {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
if (!this.process) {
|
|
133
|
+
resolve("No emulator process running");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.process.on('exit', () => {
|
|
137
|
+
this.isConnected = false;
|
|
138
|
+
this.process = null;
|
|
139
|
+
resolve("Ok: Emulator process closed successfully");
|
|
140
|
+
});
|
|
141
|
+
this.process.on('error', (error) => {
|
|
142
|
+
resolve(`Error closing emulator: ${error.message}`);
|
|
143
|
+
});
|
|
144
|
+
// Try graceful shutdown first
|
|
145
|
+
if (this.isConnected) {
|
|
146
|
+
try {
|
|
147
|
+
this.sendCommand('exit');
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
// If writing fails, force kill
|
|
151
|
+
this.process.kill('SIGTERM');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
this.forceClose();
|
|
156
|
+
resolve("Error: Emulator process had to be force killed");
|
|
157
|
+
}
|
|
158
|
+
// Force kill after timeout
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
this.forceClose();
|
|
161
|
+
resolve("Error: Timeout. Emulator process had to be force killed");
|
|
162
|
+
}, 1000);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Get the status of the openMSX emulator using machine_info command
|
|
167
|
+
* @returns Promise<string> - JSON string with machine information or error message
|
|
168
|
+
*/
|
|
169
|
+
async emu_status() {
|
|
170
|
+
try {
|
|
171
|
+
const response = await this.sendCommand('machine_info');
|
|
172
|
+
if (response.startsWith('Error:')) {
|
|
173
|
+
return JSON.stringify({ error: response });
|
|
174
|
+
}
|
|
175
|
+
// Parse machine_info output into key-value pairs
|
|
176
|
+
const parameters = response.trim().split(' ');
|
|
177
|
+
const machineInfo = {};
|
|
178
|
+
for (const param of parameters) {
|
|
179
|
+
const trimmedLine = param.trim();
|
|
180
|
+
if (trimmedLine) {
|
|
181
|
+
const value = await this.sendCommand(`machine_info ${trimmedLine}`);
|
|
182
|
+
machineInfo[trimmedLine] = value;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return JSON.stringify(machineInfo, null, 2);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
return JSON.stringify({
|
|
189
|
+
error: `Failed to get machine status: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Get the list of machines available in the openMSX emulator
|
|
195
|
+
* @returns Promise<object> - object with machine names and descriptions or error message
|
|
196
|
+
*/
|
|
197
|
+
async getMachineList(machinesDirectory) {
|
|
198
|
+
// Read the machines directory
|
|
199
|
+
let machines = [];
|
|
200
|
+
let machinesList = "No machines found.";
|
|
201
|
+
try {
|
|
202
|
+
const allFiles = await fs.readdir(machinesDirectory);
|
|
203
|
+
machines = await Promise.all(allFiles
|
|
204
|
+
.filter((file) => file.endsWith('.xml'))
|
|
205
|
+
.map(async (file) => {
|
|
206
|
+
return {
|
|
207
|
+
name: file.replace('.xml', ''),
|
|
208
|
+
description: await extractDescriptionFromXML(`${machinesDirectory}+path.sep+${file}`)
|
|
209
|
+
};
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
machinesList = 'Error reading machines directory: ' + error;
|
|
214
|
+
}
|
|
215
|
+
if (machines.length !== 0) {
|
|
216
|
+
machinesList = JSON.stringify(machines, null, 2);
|
|
217
|
+
}
|
|
218
|
+
return machinesList;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Get the list of extensions available in the openMSX emulator
|
|
222
|
+
* @returns Promise<object> - object with extension names and descriptions or error message
|
|
223
|
+
*/
|
|
224
|
+
async getExtensionList(extensionDirectory) {
|
|
225
|
+
// Read the extensions directory
|
|
226
|
+
let extensions = [];
|
|
227
|
+
let extensionsList = "No extensions found.";
|
|
228
|
+
try {
|
|
229
|
+
const allFiles = await fs.readdir(extensionDirectory);
|
|
230
|
+
extensions = await Promise.all(allFiles
|
|
231
|
+
.filter((file) => file.endsWith('.xml'))
|
|
232
|
+
.map(async (file) => {
|
|
233
|
+
return {
|
|
234
|
+
name: file.replace('.xml', ''),
|
|
235
|
+
description: await extractDescriptionFromXML(`${extensionDirectory}+path.sep+${file}`)
|
|
236
|
+
};
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
extensionsList = 'Error reading extensions directory: ' + error;
|
|
241
|
+
}
|
|
242
|
+
if (extensions.length !== 0) {
|
|
243
|
+
extensionsList = JSON.stringify(extensions, null, 2);
|
|
244
|
+
}
|
|
245
|
+
return extensionsList;
|
|
246
|
+
}
|
|
247
|
+
;
|
|
248
|
+
/**
|
|
249
|
+
* Send a command to the openMSX emulator and return the response
|
|
250
|
+
* @param command - XML command to send to the emulator
|
|
251
|
+
* @returns string - resulting response from the emulator or an error message
|
|
252
|
+
*/
|
|
253
|
+
async sendCommand(command) {
|
|
254
|
+
try {
|
|
255
|
+
// Send command
|
|
256
|
+
this.writeData(`<command>${command}</command>\n`);
|
|
257
|
+
// Read response using readData()
|
|
258
|
+
const output = (await this.readData()).trim();
|
|
259
|
+
// Look for reply tags in the output
|
|
260
|
+
const replyMatch = output.match(/<reply result="(ok|nok)"[^>]*>(.*?)<\/reply>/s);
|
|
261
|
+
if (replyMatch) {
|
|
262
|
+
if (replyMatch[1] === 'ok') {
|
|
263
|
+
return replyMatch[2];
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
return `Error: ${replyMatch[2].trim()}`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return output.trim(); // Return raw output if no reply tag found
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
return `Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Write data to the openMSX process stdin
|
|
277
|
+
* @param data - XML command or data to send
|
|
278
|
+
*/
|
|
279
|
+
writeData(data) {
|
|
280
|
+
if (!this.process || !this.process.stdin || !this.isConnected) {
|
|
281
|
+
throw new Error('openMSX process not running or not connected');
|
|
282
|
+
}
|
|
283
|
+
this.process.stdin.write(data);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Read data from openMSX process stdout
|
|
287
|
+
* @returns Promise<string> - The data received from stdout
|
|
288
|
+
*/
|
|
289
|
+
readData() {
|
|
290
|
+
return new Promise((resolve, reject) => {
|
|
291
|
+
if (!this.process || !this.process.stdout || !this.isConnected) {
|
|
292
|
+
reject(new Error('openMSX process not running or not connected'));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const onData = (data) => {
|
|
296
|
+
this.process.stdout.removeListener('data', onData);
|
|
297
|
+
resolve(data.toString());
|
|
298
|
+
};
|
|
299
|
+
this.process.stdout.on('data', onData);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Destructor - Clean up resources and close emulator if running
|
|
304
|
+
* This method should be called when the instance is no longer needed
|
|
305
|
+
*/
|
|
306
|
+
async destroy() {
|
|
307
|
+
if (this.process && !this.process.killed) {
|
|
308
|
+
await this.emu_close();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Force close the emulator immediately (synchronous)
|
|
313
|
+
* Used for emergency shutdown when async methods may not work
|
|
314
|
+
*/
|
|
315
|
+
forceClose() {
|
|
316
|
+
if (this.process && !this.process.killed) {
|
|
317
|
+
try {
|
|
318
|
+
this.process.kill('SIGKILL');
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
// Ignore errors during force close
|
|
322
|
+
}
|
|
323
|
+
this.process = null;
|
|
324
|
+
this.isConnected = false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Global instance of OpenMSX for emulator control
|
|
330
|
+
*/
|
|
331
|
+
export const openMSXInstance = new OpenMSX();
|