@nataliapc/mcp-openmsx 1.2.6 → 1.2.8

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/README.md CHANGED
@@ -241,7 +241,7 @@ Edit it to include the following JSON entry:
241
241
 
242
242
  | Variable | Description | Default Value | Example |
243
243
  |----------|-------------|---------------|---------|
244
- | `OPENMSX_EXECUTABLE` | Path or command to the openMSX executable | `openmsx` (Linux/macOS) / `openmsx.exe` (Windows) | `/usr/local/bin/openmsx` or `C:\Program Files\openMSX\openmsx.exe` |
244
+ | `OPENMSX_EXECUTABLE` | Path or command to the openMSX executable | Auto-detected: `openmsx` (Linux), `/Applications/openMSX.app/Contents/MacOS/openmsx` (macOS), `openmsx.exe` (Windows) | `/usr/local/bin/openmsx` or `C:\Program Files\openMSX\openmsx.exe` |
245
245
  | `OPENMSX_SHARE_DIR` | Directory containing openMSX data files (machines, extensions, etc.) | System dependent | `/home/myuser/.openmsx/share` |
246
246
  | `OPENMSX_SCREENSHOT_DIR` | Directory where screenshots will be saved | Default for openmsx | `/myproject/screenshots` |
247
247
  | `OPENMSX_SCREENDUMP_DIR` | Directory where screen dumps will be saved | Default for openmsx | `/myproject/screendumps` |
package/dist/openmsx.js CHANGED
@@ -5,27 +5,39 @@
5
5
  * @license GPL2
6
6
  */
7
7
  import fs from "fs/promises";
8
+ import fsSync from "fs";
8
9
  import { extractDescriptionFromXML, decodeHtmlEntities, encodeHtmlEntities } from "./utils.js";
9
10
  import { spawn } from 'child_process';
11
+ import net from 'net';
12
+ import os from 'os';
10
13
  import path from 'path';
11
- /**
12
- * OpenMSX class for controlling the openMSX emulator via TCL commands over TCP socket
13
- */
14
+ /** True when running on Windows. Evaluated once at module load. */
15
+ const IS_WINDOWS = process.platform === 'win32';
14
16
  export class OpenMSX {
15
17
  lastMachine = null;
16
18
  process = null;
17
19
  isConnected = false;
20
+ // Windows TCP socket — bidirectional after SSPI auth
21
+ tcpSocket = null;
22
+ // Accumulated I/O data for readData() — shared by both platforms (never coexist)
23
+ ioBuffer = '';
24
+ // Notify callback: fired when new I/O data arrives — shared by both platforms
25
+ ioNotify = null;
26
+ // Serial command queue — ensures sendCommand calls never overlap on any platform
27
+ commandQueue = Promise.resolve('');
18
28
  /**
19
- * Launch the openMSX emulator in stdio control mode
20
- * @param machine - MSX machine to emulate (e.g., 'Panasonic_FS-A1GT', 'C-BIOS_MSX2+')
21
- * @param extensions - Array of extensions to load (e.g., ['fmpac', 'ide'])
22
- * @returns Promise that resolves when the emulator is ready
29
+ * Launch the openMSX emulator.
30
+ * Linux/macOS: -control stdio via stdin/stdout pipes.
31
+ * Windows: spawn with ignore+ignore+pipe, TCP socket + SSPI auth.
23
32
  */
24
33
  async emu_launch(executable, machine, extensions) {
25
34
  return new Promise((resolve) => {
26
35
  let resolved = false;
27
- let connectionTime = null;
28
- const FATAL_ERROR_GRACE_PERIOD = 500; // 1/2 second grace period after connection
36
+ const diagLog = [];
37
+ const diag = (msg) => {
38
+ console.error(`[mcp-openmsx] ${msg}`);
39
+ diagLog.push(msg);
40
+ };
29
41
  const safeResolve = (message) => {
30
42
  if (!resolved) {
31
43
  resolved = true;
@@ -33,108 +45,123 @@ export class OpenMSX {
33
45
  }
34
46
  };
35
47
  try {
36
- // Check if emulator is already running
37
48
  if (this.process && !this.process.killed) {
38
- safeResolve(`Error: openMSX emulator instance is already running (currrent machine: ${this.lastMachine}). Close it first before launching a new one.`);
49
+ safeResolve(`Error: openMSX emulator instance is already running (current machine: ${this.lastMachine}). Close it first.`);
39
50
  return;
40
51
  }
41
- // Build command line arguments
42
- const args = ['-control', 'stdio'];
43
- // Add machine parameter if specified
52
+ this.resetIO();
53
+ this.commandQueue = Promise.resolve(''); // reset queue for new session
54
+ // Build args
55
+ const args = [];
56
+ if (!IS_WINDOWS) {
57
+ args.push('-control', 'stdio');
58
+ }
44
59
  if (machine) {
45
- this.lastMachine = machine; // Store last machine for future reference
60
+ this.lastMachine = machine;
46
61
  args.push('-machine', machine);
47
62
  }
48
- // Add extensions if specified
49
- if (extensions && extensions.length > 0) {
50
- extensions.forEach(ext => {
51
- args.push('-ext', ext);
52
- });
63
+ if (extensions?.length > 0) {
64
+ extensions.forEach(ext => args.push('-ext', ext));
53
65
  }
54
- // Launch openMSX with stdio control
66
+ diag(`platform=${process.platform} IS_WINDOWS=${IS_WINDOWS}`);
67
+ diag(`spawn: "${executable}" ${args.join(' ')}`);
68
+ // Windows: stdin+stdout ignored (GUI subsystem — pipes don't work).
69
+ // Linux/macOS: all three streams piped.
55
70
  this.process = spawn(executable, args, {
56
- stdio: ['pipe', 'pipe', 'pipe']
71
+ stdio: IS_WINDOWS ? ['ignore', 'ignore', 'pipe'] : ['pipe', 'pipe', 'pipe'],
72
+ windowsHide: false
57
73
  });
58
- if (!this.process.stdin || !this.process.stdout || !this.process.stderr) {
74
+ if (IS_WINDOWS && !this.process.stderr) {
75
+ safeResolve('Error: Failed to create stderr pipe');
76
+ return;
77
+ }
78
+ if (!IS_WINDOWS && (!this.process.stdout || !this.process.stderr || !this.process.stdin)) {
59
79
  safeResolve('Error: Failed to create stdio pipes');
60
80
  return;
61
81
  }
62
- // Check if process was launched successfully
63
82
  if (!this.process.pid || this.process.killed) {
64
- const stderrMessage = this.process.stderr.read()?.toString() || 'Failed to launch openMSX process';
65
- this.process = null; // Reset process to null on failure
66
- this.isConnected = false;
67
- safeResolve(`Error: ${stderrMessage}`);
83
+ this.process = null;
84
+ safeResolve('Error: Failed to launch openMSX process');
68
85
  return;
69
86
  }
70
- // Handle process events
87
+ diag(`process spawned PID=${this.process.pid}`);
71
88
  this.process.on('error', (error) => {
72
- console.error('openMSX process error:', error);
73
- safeResolve(`Error: ${error.message}`);
89
+ diag(`process error: code=${error.code} ${error.message}`);
90
+ if (error.code === 'ENOENT') {
91
+ safeResolve(`Error: openMSX executable not found: "${executable}". ` +
92
+ `Set OPENMSX_EXECUTABLE to the full path. ` +
93
+ `On Windows: C:\\Users\\<user>\\openMSX\\openmsx.exe or ` +
94
+ `C:\\Program Files\\openMSX\\openmsx.exe; ` +
95
+ `on macOS: /Applications/openMSX.app/Contents/MacOS/openmsx; ` +
96
+ `on Linux: openmsx (in PATH).`);
97
+ }
98
+ else {
99
+ safeResolve(`Error: ${error.message}`);
100
+ }
74
101
  });
75
102
  this.process.on('exit', (code, signal) => {
103
+ diag(`process exit: code=${code} signal=${signal} isConnected=${this.isConnected}`);
104
+ if (!resolved) {
105
+ safeResolve(`Error: openMSX process exited unexpectedly (code=${code}, signal=${signal}). ` +
106
+ `Diagnostics: ${diagLog.join(' | ')}`);
107
+ }
76
108
  this.isConnected = false;
77
109
  this.process = null;
110
+ this.resetIO();
78
111
  });
79
- // Wait for the opening XML tag to confirm connection
80
- this.process.stdout.on('data', (data) => {
81
- const output = data.toString();
82
- if (output.includes('<openmsx-output>')) {
83
- this.isConnected = true;
84
- connectionTime = Date.now();
85
- // Don't resolve immediately, wait for potential fatal errors
86
- setTimeout(() => {
87
- // Only resolve if no fatal error occurred during grace period
88
- if (!resolved) {
89
- try {
90
- this.writeData('<openmsx-control>\n');
91
- // Set save settings on exit off
92
- this.sendCommand('set save_settings_on_exit off');
93
- // Set renderer to SDL
94
- this.sendCommand('set renderer SDLGL-PP');
95
- // set machine on
96
- this.sendCommand('set power on');
97
- // start reverse replay mode
98
- this.sendCommand('reverse start');
99
- // Return success message
100
- let result = 'Ok: openMSX emulator launched successfully';
101
- if (machine) {
102
- result += ` with machine "${machine}"`;
103
- }
104
- if (extensions && extensions.length > 0) {
105
- if (machine) {
106
- result += ' and';
107
- }
108
- result += ` with extensions: "${extensions.join('", "')}"`;
109
- }
110
- result += ', is powered on, and replay mode is started.';
111
- safeResolve(result);
112
- }
113
- catch (error) {
114
- safeResolve(`Error: Failed to send control commands - ${error instanceof Error ? error.message : 'Unknown error'}`);
115
- }
116
- }
117
- }, FATAL_ERROR_GRACE_PERIOD);
118
- }
119
- });
120
- // Handle stderr - check for fatal errors during grace period
121
112
  this.process.stderr.on('data', (data) => {
122
- const errorOutput = data.toString();
123
- // Check for fatal errors before connection or during grace period
124
- const isInGracePeriod = connectionTime && (Date.now() - connectionTime) < FATAL_ERROR_GRACE_PERIOD;
125
- if (errorOutput.includes('Fatal error:') && (!this.isConnected || isInGracePeriod)) {
113
+ const msg = data.toString().trim();
114
+ if (!this.isConnected)
115
+ diag(`stderr: ${msg.substring(0, 300)}`);
116
+ if (msg.includes('Fatal error:') && !this.isConnected) {
126
117
  this.forceClose();
127
- safeResolve(`Error: ${errorOutput.trim()}`);
128
- return;
118
+ safeResolve(`Error: ${msg}`);
129
119
  }
130
120
  });
131
- // Set timeout for connection
121
+ const onOpenMSXReady = async (sendControlTag) => {
122
+ diag('onOpenMSXReady: sending initial commands');
123
+ // On Linux/macOS: we receive <openmsx-output> first unprompted,
124
+ // then we must send <openmsx-control> to start the session.
125
+ // On Windows: we already sent <openmsx-control> to trigger <openmsx-output>,
126
+ // so we must NOT send it again.
127
+ if (sendControlTag) {
128
+ this.writeData('<openmsx-control>\n');
129
+ }
130
+ // await each command so replies are consumed in order and don't
131
+ // contaminate ioBuffer for subsequent user commands
132
+ await this.sendCommand('set save_settings_on_exit off');
133
+ await this.sendCommand('set renderer SDLGL-PP');
134
+ await this.sendCommand('set power on');
135
+ await this.sendCommand('reverse start');
136
+ let result = 'Ok: openMSX emulator launched successfully';
137
+ if (machine)
138
+ result += ` with machine "${machine}"`;
139
+ if (extensions?.length > 0) {
140
+ if (machine)
141
+ result += ' and';
142
+ result += ` with extensions: "${extensions.join('", "')}"`;
143
+ }
144
+ result += ', is powered on, and replay mode is started.';
145
+ safeResolve(result);
146
+ };
147
+ const ctx = {
148
+ diag, safeResolve,
149
+ isResolved: () => resolved,
150
+ onReady: onOpenMSXReady,
151
+ };
152
+ if (IS_WINDOWS) {
153
+ this.launchConnectWindows(ctx);
154
+ }
155
+ else {
156
+ this.launchConnectLinux(ctx);
157
+ }
158
+ // Global timeout — 20s to allow for SSPI auth + slow VMs
132
159
  setTimeout(() => {
133
160
  if (!this.isConnected) {
134
161
  this.emu_close();
135
- safeResolve('Error: Timeout waiting for openMSX to start');
162
+ safeResolve(`Error: Timeout waiting for openMSX to start. Diagnostics: ${diagLog.join(' | ')}`);
136
163
  }
137
- }, 5000); // 5 second timeout
164
+ }, 20000);
138
165
  }
139
166
  catch (error) {
140
167
  safeResolve(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -142,74 +169,341 @@ export class OpenMSX {
142
169
  });
143
170
  }
144
171
  /**
145
- * Close the openMSX emulator process
146
- * @returns Promise that resolves when the process is closed
172
+ * Windows connection: TCP socket + SSPI authentication.
173
+ * Polls for the socket file, connects, authenticates, and starts the XML session.
174
+ */
175
+ launchConnectWindows(ctx) {
176
+ const tmpDir = process.env.TEMP ?? process.env.TMP ??
177
+ path.join(os.homedir(), 'AppData', 'Local', 'Temp');
178
+ const socketFile = path.join(tmpDir, 'openmsx-default', `socket.${this.process.pid}`);
179
+ ctx.diag(`waiting for socket file: ${socketFile}`);
180
+ this.connectWindowsTCP(socketFile).then(async ({ socket, port }) => {
181
+ ctx.diag(`TCP connected on port ${port}`);
182
+ this.tcpSocket = socket;
183
+ this.ioBuffer = '';
184
+ socket.on('error', e => {
185
+ ctx.diag(`TCP socket error: ${e.message}`);
186
+ if (!ctx.isResolved())
187
+ ctx.safeResolve(`Error: TCP socket error: ${e.message}`);
188
+ });
189
+ socket.on('close', () => {
190
+ ctx.diag('TCP socket closed');
191
+ this.tcpSocket = null;
192
+ this.isConnected = false;
193
+ });
194
+ // Register the main data handler BEFORE performSspiAuth.
195
+ // This prevents any gap where data could be discarded:
196
+ // performSspiAuth adds its own 'data' listener that runs in
197
+ // parallel — both handlers receive data, but:
198
+ // - during SSPI: ioBuffer accumulates binary SSPI tokens
199
+ // (harmless, won't contain '<openmsx-output>')
200
+ // - after SSPI: ioBuffer is cleared, then <openmsx-control>
201
+ // is sent, and <openmsx-output> is detected normally.
202
+ socket.on('data', (data) => {
203
+ const chunk = data.toString();
204
+ // Accumulate all incoming data
205
+ this.ioBuffer += chunk;
206
+ // Signal readData() that new data arrived (notify pattern)
207
+ if (this.ioNotify) {
208
+ const notify = this.ioNotify;
209
+ this.ioNotify = null;
210
+ notify();
211
+ }
212
+ // During launch: detect <openmsx-output> in the accumulated stream
213
+ if (!this.isConnected && this.ioBuffer.includes('<openmsx-output>')) {
214
+ this.isConnected = true;
215
+ // Discard everything up to and including <openmsx-output>
216
+ this.ioBuffer = this.ioBuffer.substring(this.ioBuffer.indexOf('<openmsx-output>') + '<openmsx-output>'.length);
217
+ setTimeout(async () => {
218
+ if (!ctx.isResolved()) {
219
+ try {
220
+ await ctx.onReady(false);
221
+ } // Windows: don't resend <openmsx-control>
222
+ catch (e) {
223
+ ctx.safeResolve(`Error: Failed to send control commands - ${e instanceof Error ? e.message : e}`);
224
+ }
225
+ }
226
+ }, 300);
227
+ }
228
+ });
229
+ // SSPI authentication (adds its own parallel 'data' listener internally)
230
+ try {
231
+ ctx.diag('starting SSPI authentication...');
232
+ await this.performSspiAuth(socket);
233
+ ctx.diag('SSPI authentication successful');
234
+ }
235
+ catch (e) {
236
+ ctx.safeResolve(`Error: SSPI authentication failed: ${e instanceof Error ? e.message : e}. ` +
237
+ `Make sure 'node-expose-sspi' is installed: npm install node-expose-sspi`);
238
+ return;
239
+ }
240
+ // Clear SSPI binary garbage from ioBuffer before XML session
241
+ this.ioBuffer = '';
242
+ // Send <openmsx-control> to initiate the XML session.
243
+ // On Windows/TCP, openMSX sends <openmsx-output> in response.
244
+ ctx.diag('sending <openmsx-control> to start XML session');
245
+ socket.write('<openmsx-control>\n');
246
+ }).catch(err => {
247
+ ctx.diag(`connectWindowsTCP failed: ${err.message}`);
248
+ ctx.safeResolve(`Error: ${err.message}`);
249
+ });
250
+ }
251
+ /**
252
+ * Linux/macOS connection: stdio pipes.
253
+ * Registers stdout handler for ioBuffer accumulation and <openmsx-output> detection.
254
+ */
255
+ launchConnectLinux(ctx) {
256
+ const FATAL_ERROR_GRACE_PERIOD = 500;
257
+ let connectionTime = null;
258
+ this.process.stdout.on('data', (data) => {
259
+ const output = data.toString();
260
+ if (!this.isConnected) {
261
+ if (output.includes('<openmsx-output>')) {
262
+ ctx.diag('<openmsx-output> detected on stdout');
263
+ this.isConnected = true;
264
+ this.ioBuffer = '';
265
+ connectionTime = Date.now();
266
+ setTimeout(async () => {
267
+ if (!ctx.isResolved()) {
268
+ try {
269
+ await ctx.onReady(true);
270
+ } // Linux/macOS: must send <openmsx-control>
271
+ catch (e) {
272
+ ctx.safeResolve(`Error: Failed to send control commands - ${e instanceof Error ? e.message : e}`);
273
+ }
274
+ }
275
+ }, FATAL_ERROR_GRACE_PERIOD);
276
+ }
277
+ }
278
+ else {
279
+ // Accumulate for readData() — persistent shared ioBuffer
280
+ this.ioBuffer += output;
281
+ if (this.ioNotify) {
282
+ const notify = this.ioNotify;
283
+ this.ioNotify = null;
284
+ notify();
285
+ }
286
+ }
287
+ });
288
+ this.process.stderr.on('data', (data) => {
289
+ const msg = data.toString().trim();
290
+ const isInGracePeriod = connectionTime && (Date.now() - connectionTime) < FATAL_ERROR_GRACE_PERIOD;
291
+ if (msg.includes('Fatal error:') && (!this.isConnected || isInGracePeriod)) {
292
+ this.forceClose();
293
+ ctx.safeResolve(`Error: ${msg}`);
294
+ }
295
+ });
296
+ }
297
+ /**
298
+ * SSPI (Negotiate/NTLM) authentication handshake — Windows only.
299
+ * Required since openMSX 0.7.1 for TCP socket connections.
300
+ * Uses `node-expose-sspi` v0.1.x optional npm package.
301
+ *
302
+ * Reference C++ implementation: openMSX debugger SspiNegotiateClient.cpp
303
+ * Protocol: loop until SEC_E_OK — each round:
304
+ * client → [4-byte BE length][SSPI token]
305
+ * server → [4-byte BE length][SSPI response] (if SEC_I_CONTINUE_NEEDED)
306
+ * After SEC_E_OK: no server read — proceed directly with XML protocol.
147
307
  */
308
+ async performSspiAuth(socket) {
309
+ let nes;
310
+ try {
311
+ const { createRequire } = await import('module');
312
+ const req = createRequire(import.meta.url);
313
+ nes = req('node-expose-sspi');
314
+ }
315
+ catch (e) {
316
+ throw new Error(`node-expose-sspi not available (${e instanceof Error ? e.message : e}). ` +
317
+ `Install with: npm install node-expose-sspi`);
318
+ }
319
+ // Accumulate TCP data for length-prefixed reads during SSPI phase.
320
+ // Pattern: onSspiData appends to buffer and NOTIFIES (does not clear).
321
+ // readLengthPrefixed checks the buffer size after each notification.
322
+ let sspiBuffer = Buffer.alloc(0);
323
+ let sspiNotify = null;
324
+ const onSspiData = (chunk) => {
325
+ sspiBuffer = Buffer.concat([sspiBuffer, chunk]);
326
+ if (sspiNotify) {
327
+ const notify = sspiNotify;
328
+ sspiNotify = null;
329
+ notify(); // just signal — buffer stays intact
330
+ }
331
+ };
332
+ socket.on('data', onSspiData);
333
+ // Wait until new data arrives (doesn't clear the buffer)
334
+ const waitMore = () => new Promise(resolve => {
335
+ sspiNotify = resolve;
336
+ });
337
+ const readLengthPrefixed = async () => {
338
+ // Wait until we have at least the 4-byte length prefix
339
+ while (sspiBuffer.length < 4)
340
+ await waitMore();
341
+ const len = sspiBuffer.readUInt32BE(0);
342
+ const total = 4 + len;
343
+ // Wait until the full payload has arrived
344
+ while (sspiBuffer.length < total)
345
+ await waitMore();
346
+ const result = sspiBuffer.slice(4, total);
347
+ sspiBuffer = sspiBuffer.slice(total); // consume only what we read
348
+ return result;
349
+ };
350
+ const sendToken = (token) => {
351
+ const buf = Buffer.from(token);
352
+ const lenBuf = Buffer.alloc(4);
353
+ lenBuf.writeUInt32BE(buf.length, 0);
354
+ socket.write(lenBuf);
355
+ socket.write(buf);
356
+ };
357
+ try {
358
+ // node-expose-sspi v0.1.x API:
359
+ // AcquireCredentialsHandle → CredentialWithExpiry { credential, tsExpiry }
360
+ // InitializeSecurityContextInput.credential = CredHandle (the .credential property)
361
+ // InitializeSecurityContextInput.SecBufferDesc = server's token (NOT serverSecurityContext)
362
+ // InitializeSecurityContextInput.contextReq = string[] of ISC_REQ_* flags
363
+ // Flags from openMSX C++ ref: ISC_REQ_ALLOCATE_MEMORY | ISC_REQ_CONNECTION | ISC_REQ_STREAM
364
+ const credWithExpiry = nes.sspi.AcquireCredentialsHandle({
365
+ packageName: 'Negotiate',
366
+ credentialUse: 'SECPKG_CRED_OUTBOUND',
367
+ });
368
+ const credential = credWithExpiry.credential;
369
+ const packageInfo = nes.sspi.QuerySecurityPackageInfo('Negotiate');
370
+ const ISC_FLAGS = ['ISC_REQ_ALLOCATE_MEMORY', 'ISC_REQ_CONNECTION', 'ISC_REQ_STREAM'];
371
+ let contextHandle = undefined;
372
+ let serverSecBufDesc = undefined;
373
+ // Loop until SEC_E_OK (mirrors C++ reference implementation)
374
+ while (true) {
375
+ const ctxInput = {
376
+ credential,
377
+ targetName: '',
378
+ cbMaxToken: packageInfo.cbMaxToken,
379
+ contextReq: ISC_FLAGS,
380
+ targetDataRep: 'SECURITY_NETWORK_DREP',
381
+ };
382
+ if (contextHandle !== undefined)
383
+ ctxInput.contextHandle = contextHandle;
384
+ if (serverSecBufDesc !== undefined)
385
+ ctxInput.SecBufferDesc = serverSecBufDesc;
386
+ const clientCtx = nes.sspi.InitializeSecurityContext(ctxInput);
387
+ contextHandle = clientCtx.contextHandle;
388
+ // Send our token to the server (if non-empty)
389
+ const tokenBuf = clientCtx.SecBufferDesc?.buffers?.[0];
390
+ if (tokenBuf && tokenBuf.byteLength > 0) {
391
+ sendToken(tokenBuf);
392
+ }
393
+ if (clientCtx.SECURITY_STATUS === 'SEC_E_OK') {
394
+ // Auth complete — no final read from server, proceed to XML
395
+ break;
396
+ }
397
+ if (clientCtx.SECURITY_STATUS !== 'SEC_I_CONTINUE_NEEDED') {
398
+ throw new Error(`SSPI error: ${clientCtx.SECURITY_STATUS}`);
399
+ }
400
+ // Read server's response token
401
+ const response = await readLengthPrefixed();
402
+ const responseAB = response.buffer.slice(response.byteOffset, response.byteOffset + response.byteLength);
403
+ serverSecBufDesc = { ulVersion: 0, buffers: [responseAB] };
404
+ }
405
+ }
406
+ finally {
407
+ // Remove SSPI data handler — main handler will be added after this returns
408
+ socket.removeListener('data', onSspiData);
409
+ }
410
+ }
411
+ /**
412
+ * Poll for the TCP socket file, read the port, connect.
413
+ */
414
+ connectWindowsTCP(socketFile) {
415
+ return new Promise((resolve, reject) => {
416
+ const maxWaitMs = 8000;
417
+ const pollMs = 200;
418
+ let elapsed = 0;
419
+ const poll = () => {
420
+ if (fsSync.existsSync(socketFile)) {
421
+ let port;
422
+ try {
423
+ port = parseInt(fsSync.readFileSync(socketFile, 'utf8').trim(), 10);
424
+ }
425
+ catch (e) {
426
+ return reject(new Error(`Cannot read socket file: ${e}`));
427
+ }
428
+ if (!port || isNaN(port))
429
+ return reject(new Error(`Invalid port in socket file`));
430
+ const sock = net.createConnection(port, '127.0.0.1');
431
+ sock.once('connect', () => resolve({ socket: sock, port }));
432
+ sock.once('error', err => { sock.destroy(); reject(new Error(`TCP connect to ${port} failed: ${err.message}`)); });
433
+ }
434
+ else {
435
+ elapsed += pollMs;
436
+ if (elapsed >= maxWaitMs) {
437
+ reject(new Error(`openMSX socket file not found after ${maxWaitMs}ms: ${socketFile}`));
438
+ }
439
+ else {
440
+ setTimeout(poll, pollMs);
441
+ }
442
+ }
443
+ };
444
+ poll();
445
+ });
446
+ }
447
+ resetIO() {
448
+ if (this.tcpSocket) {
449
+ try {
450
+ this.tcpSocket.destroy();
451
+ }
452
+ catch (_) { /* ignore */ }
453
+ this.tcpSocket = null;
454
+ }
455
+ this.ioBuffer = '';
456
+ this.ioNotify = null;
457
+ }
148
458
  async emu_close() {
149
459
  return new Promise((resolve) => {
460
+ let resolved = false;
461
+ const safeResolve = (message) => {
462
+ if (!resolved) {
463
+ resolved = true;
464
+ resolve(message);
465
+ }
466
+ };
150
467
  if (!this.process) {
151
- resolve("Error: No emulator process running");
468
+ safeResolve("Error: No emulator process running");
152
469
  return;
153
470
  }
154
471
  this.process.on('exit', () => {
155
- this.lastMachine = null; // Clear last machine on exit
472
+ this.lastMachine = null;
156
473
  this.isConnected = false;
157
474
  this.process = null;
158
- resolve("Ok: Emulator process closed successfully");
475
+ this.resetIO();
476
+ safeResolve("Ok: Emulator process closed successfully");
159
477
  });
160
- this.process.on('error', (error) => {
161
- resolve(`Error: error closing emulator: ${error.message}`);
162
- });
163
- // Try graceful shutdown first
478
+ this.process.on('error', (error) => safeResolve(`Error: error closing emulator: ${error.message}`));
164
479
  if (this.isConnected) {
165
- try {
166
- this.sendCommand('exit');
167
- }
168
- catch (error) {
169
- // If writing fails, force kill.
170
- // Use no-argument kill() for cross-platform safety:
171
- // on POSIX it sends SIGTERM; on Windows it calls TerminateProcess().
480
+ this.sendCommand('exit').catch(() => {
172
481
  try {
173
- this.process.kill();
482
+ this.process?.kill();
174
483
  }
175
484
  catch (_) { /* ignore */ }
176
- }
485
+ });
177
486
  }
178
487
  else {
179
488
  this.forceClose();
180
- resolve("Error: Emulator process had to be force killed");
489
+ safeResolve("Error: Emulator process had to be force killed");
181
490
  }
182
- // Force kill after timeout
183
- setTimeout(() => {
184
- this.forceClose();
185
- resolve("Error: Timeout. Emulator process had to be force killed");
186
- }, 1000);
491
+ setTimeout(() => { this.forceClose(); safeResolve("Error: Timeout. Emulator process had to be force killed"); }, 1000);
187
492
  });
188
493
  }
189
- /**
190
- * Get the status of the openMSX emulator using machine_info command
191
- * @returns Promise<string> - JSON string with machine information or error message
192
- */
193
494
  async emu_status() {
194
495
  try {
195
496
  const response = await this.sendCommand('machine_info');
196
- if (response.startsWith('Error:')) {
497
+ if (response.startsWith('Error:'))
197
498
  return response;
198
- }
199
- // Parse machine_info output into key-value pairs
200
499
  const skipInfo = ['issubslotted', 'input_port', 'slot', 'isexternalslot', 'output_port'];
201
500
  const parameters = response.trim().split(' ');
202
501
  const machineInfo = {};
203
502
  for (const param of parameters) {
204
- const trimmedLine = param.trim();
205
- // Skip certain parameters that are not useful
206
- if (skipInfo.includes(trimmedLine)) {
503
+ const trimmed = param.trim();
504
+ if (skipInfo.includes(trimmed) || !trimmed)
207
505
  continue;
208
- }
209
- if (trimmedLine) {
210
- const value = await this.sendCommand(`machine_info ${trimmedLine}`);
211
- machineInfo[trimmedLine] = value.trim();
212
- }
506
+ machineInfo[trimmed] = (await this.sendCommand(`machine_info ${trimmed}`)).trim();
213
507
  }
214
508
  return JSON.stringify(machineInfo, null, 2);
215
509
  }
@@ -222,151 +516,128 @@ export class OpenMSX {
222
516
  const response = await this.sendCommand('slotselect');
223
517
  return response.includes('0000: slot 0') && response.includes('4000: slot 0');
224
518
  }
225
- catch (error) {
519
+ catch (_) {
226
520
  return false;
227
521
  }
228
522
  }
229
- /**
230
- * Get the list of machines available in the openMSX emulator
231
- * @returns Promise<object> - object with machine names and descriptions or error message
232
- */
233
523
  async getMachineList(machinesDirectory) {
234
- // Read the machines directory
235
- let machines = [];
236
- let machinesList = "Error: No machines found.";
237
- try {
238
- const allFiles = await fs.readdir(machinesDirectory);
239
- machines = await Promise.all(allFiles
240
- .filter((file) => file.endsWith('.xml'))
241
- .map(async (file) => {
242
- return {
243
- name: file.replace('.xml', ''),
244
- description: await extractDescriptionFromXML(path.join(machinesDirectory, file))
245
- };
246
- }));
247
- if (machines.length !== 0) {
248
- machinesList = JSON.stringify(machines, null, 2);
249
- }
250
- return machinesList;
251
- }
252
- catch (error) {
253
- return `Error: error reading machines directory - ${error instanceof Error ? error.message : error}`;
254
- }
524
+ return this.getXMLList(machinesDirectory, 'machines');
255
525
  }
256
- /**
257
- * Get the list of extensions available in the openMSX emulator
258
- * @returns Promise<object> - object with extension names and descriptions or error message
259
- */
260
526
  async getExtensionList(extensionDirectory) {
261
- // Read the extensions directory
262
- let extensions = [];
263
- let extensionsList = "Error: No extensions found.";
527
+ return this.getXMLList(extensionDirectory, 'extensions');
528
+ }
529
+ async getXMLList(directory, entityName) {
264
530
  try {
265
- const allFiles = await fs.readdir(extensionDirectory);
266
- extensions = await Promise.all(allFiles
267
- .filter((file) => file.endsWith('.xml'))
268
- .map(async (file) => {
269
- return {
270
- name: file.replace('.xml', ''),
271
- description: await extractDescriptionFromXML(path.join(extensionDirectory, file))
272
- };
273
- }));
274
- if (extensions.length !== 0) {
275
- extensionsList = JSON.stringify(extensions, null, 2);
276
- }
277
- return extensionsList;
531
+ const allFiles = await fs.readdir(directory);
532
+ const items = await Promise.all(allFiles.filter(f => f.endsWith('.xml')).map(async (f) => ({
533
+ name: f.replace('.xml', ''),
534
+ description: await extractDescriptionFromXML(path.join(directory, f))
535
+ })));
536
+ return items.length ? JSON.stringify(items, null, 2) : `Error: No ${entityName} found.`;
278
537
  }
279
538
  catch (error) {
280
- return `Error: error reading extensions directory - ${error instanceof Error ? error.message : error}`;
539
+ return `Error: error reading ${entityName} directory - ${error instanceof Error ? error.message : error}`;
281
540
  }
282
541
  }
283
- ;
284
542
  /**
285
- * Send a command to the openMSX emulator and return the response
286
- * @param command - XML command to send to the emulator
287
- * @returns string - resulting response from the emulator or an error message
543
+ * Send a TCL command to openMSX and return the response.
544
+ *
545
+ * Internally serialized via a promise queue so concurrent callers (with or
546
+ * without `await`) never overlap — each command waits for the previous one
547
+ * to complete before writing to the channel and reading the reply.
548
+ * This is safe on all platforms (Linux/macOS stdio and Windows TCP).
288
549
  */
289
550
  async sendCommand(command) {
290
- try {
291
- // Send command
292
- this.writeData(`<command>${encodeHtmlEntities(command)}</command>\n`);
293
- // Read response using readData()
294
- const output = (await this.readData()).trim();
295
- // Look for reply tags in the output
296
- const replyMatch = output.match(/<reply result="(ok|nok)"[^>]*>(.*?)<\/reply>/s);
297
- if (replyMatch) {
298
- const outputContent = decodeHtmlEntities(replyMatch[2].trim());
299
- if (replyMatch[1] === 'ok') {
300
- return outputContent;
301
- }
302
- else {
303
- return `Error: ${outputContent}`;
551
+ const execute = async () => {
552
+ try {
553
+ this.writeData(`<command>${encodeHtmlEntities(command)}</command>\n`);
554
+ const output = (await this.readData()).trim();
555
+ const replyMatch = output.match(/<reply result="(ok|nok)"[^>]*>(.*?)<\/reply>/s);
556
+ if (replyMatch) {
557
+ const content = decodeHtmlEntities(replyMatch[2].trim());
558
+ return replyMatch[1] === 'ok' ? content : `Error: ${content}`;
304
559
  }
560
+ return decodeHtmlEntities(output);
305
561
  }
306
- // Return raw output with HTML entities decoded
307
- return decodeHtmlEntities(output.trim());
308
- }
309
- catch (error) {
310
- return `Error: ${error instanceof Error ? error.message : error}`;
311
- }
562
+ catch (error) {
563
+ return `Error: ${error instanceof Error ? error.message : error}`;
564
+ }
565
+ };
566
+ // Chain onto the queue: wait for the previous command to finish, then run
567
+ const result = this.commandQueue.then(execute, execute);
568
+ // Update the queue tail — swallow errors so the chain never breaks
569
+ this.commandQueue = result.then(() => '', () => '');
570
+ return result;
312
571
  }
313
- /**
314
- * Write data to the openMSX process stdin
315
- * @param data - XML command or data to send
316
- */
317
572
  writeData(data) {
318
- if (!this.process || !this.process.stdin || !this.isConnected) {
573
+ if (!this.process || !this.isConnected)
319
574
  throw new Error('openMSX process not running or not connected');
575
+ if (IS_WINDOWS) {
576
+ if (!this.tcpSocket || this.tcpSocket.destroyed)
577
+ throw new Error('Windows TCP socket not open');
578
+ this.tcpSocket.write(data);
579
+ }
580
+ else {
581
+ if (!this.process.stdin)
582
+ throw new Error('openMSX stdin not available');
583
+ this.process.stdin.write(data);
320
584
  }
321
- this.process.stdin.write(data);
322
585
  }
323
- /**
324
- * Read data from openMSX process stdout
325
- * @returns Promise<string> - The data received from stdout
326
- */
327
586
  readData() {
328
587
  return new Promise((resolve, reject) => {
329
- if (!this.process || !this.process.stdout || !this.isConnected) {
588
+ if (!this.isConnected) {
330
589
  reject(new Error('openMSX process not running or not connected'));
331
590
  return;
332
591
  }
333
- const onData = (data) => {
334
- this.process.stdout.removeListener('data', onData);
335
- resolve(data.toString());
592
+ if (IS_WINDOWS) {
593
+ if (!this.tcpSocket || this.tcpSocket.destroyed) {
594
+ reject(new Error('Windows TCP socket not open'));
595
+ return;
596
+ }
597
+ }
598
+ else {
599
+ if (!this.process?.stdout) {
600
+ reject(new Error('openMSX stdout not available'));
601
+ return;
602
+ }
603
+ }
604
+ // Unified for both platforms: accumulate in ioBuffer until a complete
605
+ // <reply>…</reply> block, then extract and return it.
606
+ const RESPONSE_TIMEOUT = 10000;
607
+ const timer = setTimeout(() => {
608
+ this.ioNotify = null;
609
+ this.ioBuffer = '';
610
+ reject(new Error('Timeout waiting for openMSX response'));
611
+ }, RESPONSE_TIMEOUT);
612
+ const tryExtractReply = () => {
613
+ const end = this.ioBuffer.indexOf('</reply>');
614
+ if (end !== -1) {
615
+ clearTimeout(timer);
616
+ const full = this.ioBuffer.substring(0, end + '</reply>'.length);
617
+ this.ioBuffer = this.ioBuffer.substring(end + '</reply>'.length);
618
+ resolve(full);
619
+ }
620
+ else {
621
+ this.ioNotify = tryExtractReply;
622
+ }
336
623
  };
337
- this.process.stdout.on('data', onData);
624
+ tryExtractReply();
338
625
  });
339
626
  }
340
- /**
341
- * Destructor - Clean up resources and close emulator if running
342
- * This method should be called when the instance is no longer needed
343
- */
344
627
  async destroy() {
345
- if (this.process && !this.process.killed) {
628
+ if (this.process && !this.process.killed)
346
629
  await this.emu_close();
347
- }
348
630
  }
349
- /**
350
- * Force close the emulator immediately (synchronous)
351
- * Used for emergency shutdown when async methods may not work
352
- */
353
631
  forceClose() {
354
632
  if (this.process && !this.process.killed) {
355
633
  try {
356
- // 'SIGKILL' is accepted on Windows too (maps to TerminateProcess).
357
- // No-argument kill() is also acceptable here, but SIGKILL makes
358
- // the intent explicit: we want unconditional termination.
359
634
  this.process.kill('SIGKILL');
360
635
  }
361
- catch (error) {
362
- // Ignore errors during force close
363
- }
636
+ catch (_) { /* ignore */ }
364
637
  this.process = null;
365
638
  this.isConnected = false;
366
639
  }
640
+ this.resetIO();
367
641
  }
368
642
  }
369
- /**
370
- * Global instance of OpenMSX for emulator control
371
- */
372
643
  export const openMSXInstance = new OpenMSX();
package/dist/server.js CHANGED
@@ -20,7 +20,7 @@ import { fileURLToPath } from 'node:url';
20
20
  import { createRequire } from 'module';
21
21
  import { openMSXInstance } from "./openmsx.js";
22
22
  import { VectorDB } from "./vectordb.js";
23
- import { detectOpenMSXShareDir } from "./utils.js";
23
+ import { detectOpenMSXExecutable, detectOpenMSXShareDir } from "./utils.js";
24
24
  import { registerTools } from "./server_tools.js";
25
25
  import { registerResources } from "./server_resources.js";
26
26
  import { registerPrompts } from "./server_prompts.js";
@@ -32,9 +32,7 @@ const resourcesDir = path.join(__dirname, "../resources");
32
32
  const vectorDbDir = path.join(__dirname, "../vector-db");
33
33
  export const emuDirectories = {
34
34
  OPENMSX_SHARE_DIR: '',
35
- // On Windows, Node.js spawn() may not resolve 'openmsx' to 'openmsx.exe' unless it is in PATH.
36
- // Using the platform-aware default reduces friction for Windows users who have openMSX in PATH.
37
- OPENMSX_EXECUTABLE: process.platform === 'win32' ? 'openmsx.exe' : 'openmsx',
35
+ OPENMSX_EXECUTABLE: detectOpenMSXExecutable(),
38
36
  OPENMSX_REPLAYS_DIR: '',
39
37
  OPENMSX_SCREENSHOT_DIR: '',
40
38
  OPENMSX_SCREENDUMP_DIR: '',
@@ -232,6 +230,15 @@ async function main() {
232
230
  }
233
231
  emuDirectories.MACHINES_DIR = path.join(emuDirectories.OPENMSX_SHARE_DIR, 'machines');
234
232
  emuDirectories.EXTENSIONS_DIR = path.join(emuDirectories.OPENMSX_SHARE_DIR, 'extensions');
233
+ if (!emuDirectories.OPENMSX_SCREENSHOT_DIR) {
234
+ emuDirectories.OPENMSX_SCREENSHOT_DIR = path.join(emuDirectories.OPENMSX_SHARE_DIR, 'screenshots');
235
+ }
236
+ if (!emuDirectories.OPENMSX_SCREENDUMP_DIR) {
237
+ emuDirectories.OPENMSX_SCREENDUMP_DIR = path.join(emuDirectories.OPENMSX_SHARE_DIR, 'screenshots');
238
+ }
239
+ if (!emuDirectories.OPENMSX_REPLAYS_DIR) {
240
+ emuDirectories.OPENMSX_REPLAYS_DIR = path.join(emuDirectories.OPENMSX_SHARE_DIR, 'replays');
241
+ }
235
242
  VectorDB.setIndexDirectory(vectorDbDir);
236
243
  // Detect transport type from environment or command line
237
244
  const transportType = process.env.MCP_TRANSPORT || process.argv[2] || 'stdio';
@@ -3,7 +3,7 @@ import fs from "fs/promises";
3
3
  import path from "path";
4
4
  import { openMSXInstance } from "./openmsx.js";
5
5
  import { VectorDB } from "./vectordb.js";
6
- import { encodeTypeText, buildKeyComboCommand, isErrorResponse, getResponseContent, parseCpuRegs, is16bitRegister, parseVdpRegs, parsePalette, parseBreakpoints, parseReplayStatus, sleepWithAbort } from "./utils.js";
6
+ import { encodeTypeText, buildKeyComboCommand, isErrorResponse, getResponseContent, parseCpuRegs, is16bitRegister, parseVdpRegs, parsePalette, parseBreakpoints, parseReplayStatus, sleepWithAbort, ensureDirectoryExists, tclPath } from "./utils.js";
7
7
  import { getRegisteredResourcesList } from "./server_resources.js";
8
8
  import { resolveLaunchParams } from "./server_elicitations.js";
9
9
  // ============================================================================
@@ -1246,13 +1246,19 @@ export async function registerTools(server, emuDirectories) {
1246
1246
  tclCommand = `reverse_frame ${frames}`;
1247
1247
  break;
1248
1248
  case "saveReplay":
1249
- if (filename)
1250
- filename = path.join(emuDirectories.OPENMSX_REPLAYS_DIR, filename);
1249
+ if (filename) {
1250
+ if (!filename.endsWith('.omr'))
1251
+ filename += '.omr';
1252
+ filename = tclPath(path.join(emuDirectories.OPENMSX_REPLAYS_DIR, filename));
1253
+ }
1251
1254
  tclCommand = `reverse savereplay ${filename || ''}`;
1252
1255
  break;
1253
1256
  case "loadReplay":
1254
- if (filename)
1255
- filename = path.join(emuDirectories.OPENMSX_REPLAYS_DIR, filename);
1257
+ if (filename) {
1258
+ if (!filename.endsWith('.omr'))
1259
+ filename += '.omr';
1260
+ filename = tclPath(path.join(emuDirectories.OPENMSX_REPLAYS_DIR, filename));
1261
+ }
1256
1262
  tclCommand = `reverse loadreplay ${filename}`;
1257
1263
  break;
1258
1264
  default:
@@ -1394,20 +1400,46 @@ export async function registerTools(server, emuDirectories) {
1394
1400
  },
1395
1401
  // Handler for the tool (function to be executed when the tool is called)
1396
1402
  async ({ command }) => {
1397
- const openmsxCommand = `screenshot -raw -doublesize -prefix "${path.join(emuDirectories.OPENMSX_SCREENSHOT_DIR, 'mcp_')}"`;
1403
+ // Ensure the screenshot directory exists before saving
1404
+ const screenshotDirError = await ensureDirectoryExists(emuDirectories.OPENMSX_SCREENSHOT_DIR);
1405
+ if (screenshotDirError) {
1406
+ return getResponseContent([`Error: ${screenshotDirError}`], true);
1407
+ }
1408
+ // Use a unique prefix so we can locate the file even if openMSX
1409
+ // doesn't return the filename (some versions return empty on success).
1410
+ const uniqueId = `mcp_${Date.now()}_`;
1411
+ const screenshotPrefix = tclPath(path.join(emuDirectories.OPENMSX_SCREENSHOT_DIR, uniqueId));
1412
+ const openmsxCommand = `screenshot -raw -doublesize -prefix "${screenshotPrefix}"`;
1398
1413
  const response = await openMSXInstance.sendCommand(openmsxCommand);
1414
+ if (isErrorResponse(response)) {
1415
+ return getResponseContent([response], true);
1416
+ }
1417
+ // Resolve the screenshot file path: use the response if it contains
1418
+ // a valid path, otherwise scan the directory for the generated file.
1419
+ let screenshotPath = response;
1420
+ if (!screenshotPath || !screenshotPath.endsWith('.png')) {
1421
+ try {
1422
+ const files = await fs.readdir(emuDirectories.OPENMSX_SCREENSHOT_DIR);
1423
+ const match = files.find(f => f.startsWith(uniqueId) && f.endsWith('.png'));
1424
+ if (match) {
1425
+ screenshotPath = path.join(emuDirectories.OPENMSX_SCREENSHOT_DIR, match);
1426
+ }
1427
+ }
1428
+ catch (_) { /* directory read failed, screenshotPath stays empty */ }
1429
+ }
1430
+ if (!screenshotPath || !screenshotPath.endsWith('.png')) {
1431
+ return getResponseContent([
1432
+ `Error: Screenshot command succeeded but no file was found (prefix: "${uniqueId}")`,
1433
+ ], true);
1434
+ }
1399
1435
  switch (command) {
1400
1436
  case "as_image":
1401
1437
  try {
1402
- // Check if the response is a file path
1403
- if (!response || !response.startsWith(emuDirectories.OPENMSX_SCREENSHOT_DIR) || !response.endsWith('.png')) {
1404
- throw new Error(`Invalid screenshot "${response}"`);
1405
- }
1406
1438
  // Read the screenshot file
1407
- const imageBuffer = await fs.readFile(response);
1439
+ const imageBuffer = await fs.readFile(screenshotPath);
1408
1440
  const base64image = imageBuffer.toString('base64');
1409
1441
  // Remove the file after reading it
1410
- await fs.unlink(response);
1442
+ await fs.unlink(screenshotPath);
1411
1443
  // Return the image in the response
1412
1444
  return {
1413
1445
  content: [{
@@ -1422,13 +1454,13 @@ export async function registerTools(server, emuDirectories) {
1422
1454
  }
1423
1455
  catch (error) {
1424
1456
  return getResponseContent([
1425
- 'Error creating screenshot: ' + response,
1457
+ 'Error reading screenshot file: ' + screenshotPath,
1426
1458
  error instanceof Error ? error.message : String(error),
1427
1459
  ], true);
1428
1460
  }
1429
1461
  case "to_file":
1430
1462
  return getResponseContent([
1431
- isErrorResponse(response) ? response : 'Screenshot taken in file: ' + response
1463
+ 'Screenshot taken in file: ' + screenshotPath
1432
1464
  ]);
1433
1465
  }
1434
1466
  return getResponseContent([
@@ -1461,7 +1493,12 @@ The parameter scrbasename is the name of the filename (without path) to save the
1461
1493
  },
1462
1494
  // Handler for the tool (function to be executed when the tool is called)
1463
1495
  async ({ scrbasename }) => {
1464
- const openmsxCommand = `save_msx_screen "${path.join(emuDirectories.OPENMSX_SCREENDUMP_DIR, scrbasename)}"`;
1496
+ // Ensure the screendump directory exists before saving
1497
+ const screendumpDirError = await ensureDirectoryExists(emuDirectories.OPENMSX_SCREENDUMP_DIR);
1498
+ if (screendumpDirError) {
1499
+ return getResponseContent([`Error: ${screendumpDirError}`], true);
1500
+ }
1501
+ const openmsxCommand = `save_msx_screen "${tclPath(path.join(emuDirectories.OPENMSX_SCREENDUMP_DIR, scrbasename))}"`;
1465
1502
  const response = await openMSXInstance.sendCommand(openmsxCommand);
1466
1503
  return getResponseContent([
1467
1504
  isErrorResponse(response) ? 'Fail:' : 'Screendump file saved as:',
package/dist/utils.js CHANGED
@@ -12,6 +12,32 @@ import { PACKAGE_VERSION } from "./server.js";
12
12
  import sanitizeHtml from 'sanitize-html';
13
13
  import { existsSync } from "fs";
14
14
  import os from "os";
15
+ /**
16
+ * Detect the openMSX executable path for the current platform.
17
+ *
18
+ * - Linux: 'openmsx' (expected in PATH after package install)
19
+ * - Windows: 'openmsx.exe' (Node spawn() needs the .exe extension on Windows)
20
+ * - macOS: probes the standard .app bundle path first; falls back to 'openmsx'
21
+ * in case the user has it in PATH (e.g. via Homebrew or manual install).
22
+ *
23
+ * The standard macOS bundle path is /Applications/openMSX.app/Contents/MacOS/openmsx.
24
+ * This is the path documented by the openMSX project (and its Catapult launcher).
25
+ */
26
+ export function detectOpenMSXExecutable() {
27
+ if (process.platform === 'win32') {
28
+ return 'openmsx.exe';
29
+ }
30
+ if (process.platform === 'darwin') {
31
+ const appBundlePath = '/Applications/openMSX.app/Contents/MacOS/openmsx';
32
+ if (existsSync(appBundlePath)) {
33
+ return appBundlePath;
34
+ }
35
+ // Fallback: user may have openMSX in PATH (e.g. via Homebrew)
36
+ return 'openmsx';
37
+ }
38
+ // Linux / other POSIX
39
+ return 'openmsx';
40
+ }
15
41
  /**
16
42
  * Detect the openMSX share directory by checking various methods
17
43
  * @returns string - The detected share directory or an empty string if not found
@@ -24,9 +50,13 @@ export function detectOpenMSXShareDir() {
24
50
  path.join(os.homedir(), '.openMSX', 'share'),
25
51
  '/usr/local/share/openmsx',
26
52
  '/usr/share/openmsx',
27
- // Windows paths
53
+ // Windows paths (appdata, documents, program files, portable)
54
+ ...(process.env.APPDATA ? [path.join(process.env.APPDATA, 'openMSX', 'share')] : []),
55
+ ...(process.env.LOCALAPPDATA ? [path.join(process.env.LOCALAPPDATA, 'openMSX', 'share')] : []),
28
56
  path.join(os.homedir(), 'Documents', 'openMSX', 'share'),
57
+ path.join(os.homedir(), 'openMSX', 'share'),
29
58
  path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'openMSX', 'share'),
59
+ ...(process.env['PROGRAMFILES(X86)'] ? [path.join(process.env['PROGRAMFILES(X86)'], 'openMSX', 'share')] : []),
30
60
  // macOS paths
31
61
  path.join(os.homedir(), 'Library', 'Application Support', 'openMSX', 'share'),
32
62
  '/Applications/openMSX.app/Contents/Resources/share',
@@ -466,6 +496,31 @@ export function parseReplayStatus(response) {
466
496
  snapshotCount,
467
497
  };
468
498
  }
499
+ /**
500
+ * Ensure a directory exists, creating it (and any parents) if necessary.
501
+ * @param dirPath - Absolute path to the directory
502
+ * @returns null on success, or an error message string if creation failed
503
+ */
504
+ export async function ensureDirectoryExists(dirPath) {
505
+ try {
506
+ await fs.mkdir(dirPath, { recursive: true });
507
+ return null;
508
+ }
509
+ catch (error) {
510
+ return `Cannot create directory "${dirPath}": ` +
511
+ (error instanceof Error ? error.message : String(error));
512
+ }
513
+ }
514
+ /**
515
+ * Normalize a file path for use inside openMSX TCL commands.
516
+ * Converts Windows backslashes to forward slashes, which TCL accepts on all platforms.
517
+ * On Linux/macOS this is a no-op (no backslashes to replace).
518
+ * @param filePath - File path to normalize
519
+ * @returns Path with forward slashes only
520
+ */
521
+ export function tclPath(filePath) {
522
+ return filePath.replace(/\\/g, '/');
523
+ }
469
524
  /*
470
525
  * Sleep for a specified number of milliseconds
471
526
  * @param ms - Number of milliseconds to sleep
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nataliapc/mcp-openmsx",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
4
4
  "description": "Model context protocol server for openMSX automation and control",
5
5
  "main": "dist/server.js",
6
6
  "type": "module",
@@ -30,13 +30,22 @@
30
30
  "node": ">=18.0.0"
31
31
  },
32
32
  "scripts": {
33
- "build": "tsc && shx chmod +x dist/server.js",
34
- "watch": "tsc --watch",
33
+ "build": "node_modules/.bin/tsc && shx chmod +x dist/server.js",
34
+ "watch": "node_modules/.bin/tsc --watch",
35
35
  "start": "node dist/server.js",
36
36
  "dev": "tsx src/server.ts",
37
37
  "prepublishOnly": "npm run build",
38
38
  "test": "echo \"Error: no test specified\" && exit 1"
39
39
  },
40
+ "optionalDependencies": {
41
+ "node-expose-sspi": "^0.1.60",
42
+ "sharp": ">=0.32.0"
43
+ },
44
+ "pnpm": {
45
+ "overrides": {
46
+ "sharp": "$sharp"
47
+ }
48
+ },
40
49
  "dependencies": {
41
50
  "@modelcontextprotocol/sdk": "^1.26.0",
42
51
  "@themaximalist/embeddings.js": "^0.1.3",