@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.
@@ -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();