@nataliapc/mcp-openmsx 1.2.7 → 1.2.9

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
@@ -14,7 +14,7 @@ A [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) s
14
14
  This server provides comprehensive tools for MSX software development, testing, and automation through standardized MCP protocols.
15
15
 
16
16
 
17
- > 🎁🎁 _If you find this project useful, please consider making a donation: [PAYPAL Link](https://www.paypal.com/donate/?hosted_button_id=9X268YDDS9SYC)_
17
+ > 🎁🎁 _If you find this project useful, please consider making a donation by [PAYPAL Link](https://www.paypal.com/donate/?hosted_button_id=9X268YDDS9SYC) or [GitHub Sponsors](https://github.com/sponsors/nataliapc)_
18
18
 
19
19
  ---
20
20
 
package/dist/openmsx.js CHANGED
@@ -5,48 +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';
10
- import { createWriteStream } from 'fs';
11
+ import net from 'net';
12
+ import os from 'os';
11
13
  import path from 'path';
12
14
  /** True when running on Windows. Evaluated once at module load. */
13
15
  const IS_WINDOWS = process.platform === 'win32';
14
- /**
15
- * OpenMSX class for controlling the openMSX emulator via TCL commands over stdio (Linux/macOS)
16
- * or a Windows named pipe (Windows).
17
- *
18
- * Protocol summary (same XML protocol on all platforms):
19
- * openMSX → stdout : XML output including <openmsx-output>, <reply>, <log>, <update>
20
- * controller → openMSX : XML commands via stdin (stdio mode) or named pipe (pipe mode)
21
- *
22
- * On Linux/macOS: openmsx -control stdio
23
- * Commands are sent via the child process stdin; replies come on stdout.
24
- *
25
- * On Windows: openmsx -control pipe:<pipename>
26
- * openMSX reads commands from \\.\pipe\<pipename> (a Windows named pipe).
27
- * Replies/output are still written to stdout (captured by Node's stdio pipes).
28
- * We write commands to the named pipe using a WriteStream opened on the pipe path.
29
- *
30
- * Reference: https://openmsx.org/manual/openmsx-control.html
31
- */
32
16
  export class OpenMSX {
33
17
  lastMachine = null;
34
18
  process = null;
35
19
  isConnected = false;
36
- // Windows named pipe write stream (only used on Windows with -control pipe)
37
- pipeWriter = null;
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('');
38
28
  /**
39
- * Launch the openMSX emulator in stdio control mode (Linux/macOS)
40
- * or pipe control mode (Windows).
41
- * @param machine - MSX machine to emulate (e.g., 'Panasonic_FS-A1GT', 'C-BIOS_MSX2+')
42
- * @param extensions - Array of extensions to load (e.g., ['fmpac', 'ide'])
43
- * @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.
44
32
  */
45
33
  async emu_launch(executable, machine, extensions) {
46
34
  return new Promise((resolve) => {
47
35
  let resolved = false;
48
- let connectionTime = null;
49
- 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
+ };
50
41
  const safeResolve = (message) => {
51
42
  if (!resolved) {
52
43
  resolved = true;
@@ -54,144 +45,123 @@ export class OpenMSX {
54
45
  }
55
46
  };
56
47
  try {
57
- // Check if emulator is already running
58
48
  if (this.process && !this.process.killed) {
59
- 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.`);
60
50
  return;
61
51
  }
62
- // Clean up any leftover pipe writer from a previous session
63
- // (e.g. if the process crashed and the exit handler didn't run)
64
- this.closePipeWriter();
65
- // Build command line arguments.
66
- // On Windows, openMSX -control stdio has a known issue: the stdin reader
67
- // thread blocks on read() and never unblocks cleanly when the pipe closes,
68
- // causing openMSX to hang on exit. The correct mode for Windows is
69
- // -control pipe:<name>, which uses a Windows named pipe for input and
70
- // still writes replies/output to stdout.
71
- // On Linux/macOS, -control stdio is correct and uses stdin/stdout directly.
72
- // On Windows, use a unique pipe name based on PID to avoid collisions
73
- // when multiple MCP server instances run simultaneously.
74
- const pipeName = IS_WINDOWS ? `openmsx-mcp-${process.pid}` : '';
75
- const controlArg = IS_WINDOWS ? `pipe:${pipeName}` : 'stdio';
76
- const args = ['-control', controlArg];
77
- // 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
+ }
78
59
  if (machine) {
79
- this.lastMachine = machine; // Store last machine for future reference
60
+ this.lastMachine = machine;
80
61
  args.push('-machine', machine);
81
62
  }
82
- // Add extensions if specified
83
- if (extensions && extensions.length > 0) {
84
- extensions.forEach(ext => {
85
- args.push('-ext', ext);
86
- });
63
+ if (extensions?.length > 0) {
64
+ extensions.forEach(ext => args.push('-ext', ext));
87
65
  }
88
- // Launch openMSX.
89
- // On both modes, stdout/stderr are piped so we can read replies and errors.
90
- // On stdio mode, stdin is also piped (we write commands there).
91
- // On pipe mode, stdin is ignored ('ignore') — openMSX reads from the named pipe.
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.
92
70
  this.process = spawn(executable, args, {
93
- stdio: IS_WINDOWS ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe']
71
+ stdio: IS_WINDOWS ? ['ignore', 'ignore', 'pipe'] : ['pipe', 'pipe', 'pipe'],
72
+ windowsHide: false
94
73
  });
95
- if (!this.process.stdout || !this.process.stderr) {
96
- safeResolve('Error: Failed to create stdio pipes');
74
+ if (IS_WINDOWS && !this.process.stderr) {
75
+ safeResolve('Error: Failed to create stderr pipe');
97
76
  return;
98
77
  }
99
- if (!IS_WINDOWS && !this.process.stdin) {
100
- safeResolve('Error: Failed to create stdin pipe');
78
+ if (!IS_WINDOWS && (!this.process.stdout || !this.process.stderr || !this.process.stdin)) {
79
+ safeResolve('Error: Failed to create stdio pipes');
101
80
  return;
102
81
  }
103
- // Check if process was launched successfully
104
82
  if (!this.process.pid || this.process.killed) {
105
- const stderrMessage = this.process.stderr.read()?.toString() || 'Failed to launch openMSX process';
106
- this.process = null; // Reset process to null on failure
107
- this.isConnected = false;
108
- safeResolve(`Error: ${stderrMessage}`);
83
+ this.process = null;
84
+ safeResolve('Error: Failed to launch openMSX process');
109
85
  return;
110
86
  }
111
- // Handle process events
87
+ diag(`process spawned PID=${this.process.pid}`);
112
88
  this.process.on('error', (error) => {
113
- console.error('openMSX process error:', error);
89
+ diag(`process error: code=${error.code} ${error.message}`);
114
90
  if (error.code === 'ENOENT') {
115
91
  safeResolve(`Error: openMSX executable not found: "${executable}". ` +
116
- `Set the OPENMSX_EXECUTABLE environment variable to the full path of the openMSX binary. ` +
117
- `On macOS the standard path is /Applications/openMSX.app/Contents/MacOS/openmsx; ` +
118
- `on Windows it is typically C:\\Program Files\\openMSX\\openmsx.exe; ` +
119
- `on Linux it is usually 'openmsx' (in PATH after package install).`);
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).`);
120
97
  }
121
98
  else {
122
99
  safeResolve(`Error: ${error.message}`);
123
100
  }
124
101
  });
125
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
+ }
126
108
  this.isConnected = false;
127
109
  this.process = null;
128
- this.closePipeWriter();
110
+ this.resetIO();
129
111
  });
130
- // Wait for the opening XML tag to confirm connection
131
- this.process.stdout.on('data', (data) => {
132
- const output = data.toString();
133
- if (output.includes('<openmsx-output>')) {
134
- this.isConnected = true;
135
- connectionTime = Date.now();
136
- // Don't resolve immediately, wait for potential fatal errors
137
- setTimeout(async () => {
138
- // Only resolve if no fatal error occurred during grace period
139
- if (!resolved) {
140
- try {
141
- // On Windows, open the named pipe for writing commands.
142
- // openMSX has already created the pipe server side by now.
143
- if (IS_WINDOWS) {
144
- const pipePath = `\\\\.\\pipe\\${pipeName}`;
145
- await this.openWindowsPipe(pipePath);
146
- }
147
- this.writeData('<openmsx-control>\n');
148
- // Set save settings on exit off
149
- this.sendCommand('set save_settings_on_exit off');
150
- // Set renderer to SDL
151
- this.sendCommand('set renderer SDLGL-PP');
152
- // set machine on
153
- this.sendCommand('set power on');
154
- // start reverse replay mode
155
- this.sendCommand('reverse start');
156
- // Return success message
157
- let result = 'Ok: openMSX emulator launched successfully';
158
- if (machine) {
159
- result += ` with machine "${machine}"`;
160
- }
161
- if (extensions && extensions.length > 0) {
162
- if (machine) {
163
- result += ' and';
164
- }
165
- result += ` with extensions: "${extensions.join('", "')}"`;
166
- }
167
- result += ', is powered on, and replay mode is started.';
168
- safeResolve(result);
169
- }
170
- catch (error) {
171
- safeResolve(`Error: Failed to send control commands - ${error instanceof Error ? error.message : 'Unknown error'}`);
172
- }
173
- }
174
- }, FATAL_ERROR_GRACE_PERIOD);
175
- }
176
- });
177
- // Handle stderr - check for fatal errors during grace period
178
112
  this.process.stderr.on('data', (data) => {
179
- const errorOutput = data.toString();
180
- // Check for fatal errors before connection or during grace period
181
- const isInGracePeriod = connectionTime && (Date.now() - connectionTime) < FATAL_ERROR_GRACE_PERIOD;
182
- 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) {
183
117
  this.forceClose();
184
- safeResolve(`Error: ${errorOutput.trim()}`);
185
- return;
118
+ safeResolve(`Error: ${msg}`);
186
119
  }
187
120
  });
188
- // 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
189
159
  setTimeout(() => {
190
160
  if (!this.isConnected) {
191
161
  this.emu_close();
192
- safeResolve('Error: Timeout waiting for openMSX to start');
162
+ safeResolve(`Error: Timeout waiting for openMSX to start. Diagnostics: ${diagLog.join(' | ')}`);
193
163
  }
194
- }, 5000); // 5 second timeout
164
+ }, 20000);
195
165
  }
196
166
  catch (error) {
197
167
  safeResolve(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -199,112 +169,341 @@ export class OpenMSX {
199
169
  });
200
170
  }
201
171
  /**
202
- * Open a Windows named pipe for writing commands to openMSX.
203
- * openMSX creates the pipe server when launched with -control pipe:<name>.
204
- * We connect as a client (write-only) after openMSX signals it's ready.
205
- * @param pipePath - Full Windows named pipe path, e.g. \\.\pipe\openmsx-mcp-1234
172
+ * Windows connection: TCP socket + SSPI authentication.
173
+ * Polls for the socket file, connects, authenticates, and starts the XML session.
206
174
  */
207
- openWindowsPipe(pipePath) {
208
- return new Promise((resolve, reject) => {
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)
209
230
  try {
210
- // Node.js fs.createWriteStream supports Windows named pipe paths.
211
- // The pipe must already exist (created by openMSX) at this point.
212
- const writer = createWriteStream(pipePath, { flags: 'r+' });
213
- writer.on('open', () => {
214
- this.pipeWriter = writer;
215
- resolve();
216
- });
217
- writer.on('error', (err) => {
218
- reject(new Error(`Failed to open Windows named pipe "${pipePath}": ${err.message}`));
219
- });
231
+ ctx.diag('starting SSPI authentication...');
232
+ await this.performSspiAuth(socket);
233
+ ctx.diag('SSPI authentication successful');
220
234
  }
221
- catch (err) {
222
- reject(new Error(`Failed to create Windows named pipe writer: ${err instanceof Error ? err.message : err}`));
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;
223
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}`);
224
249
  });
225
250
  }
226
251
  /**
227
- * Close and destroy the Windows named pipe writer if open.
252
+ * Linux/macOS connection: stdio pipes.
253
+ * Registers stdout handler for ioBuffer accumulation and <openmsx-output> detection.
228
254
  */
229
- closePipeWriter() {
230
- if (this.pipeWriter) {
231
- try {
232
- this.pipeWriter.destroy();
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
+ }
233
277
  }
234
- catch (_) { /* ignore */ }
235
- this.pipeWriter = null;
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.
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);
236
409
  }
237
410
  }
238
411
  /**
239
- * Close the openMSX emulator process
240
- * @returns Promise that resolves when the process is closed
412
+ * Poll for the TCP socket file, read the port, connect.
241
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
+ }
242
458
  async emu_close() {
243
459
  return new Promise((resolve) => {
460
+ let resolved = false;
461
+ const safeResolve = (message) => {
462
+ if (!resolved) {
463
+ resolved = true;
464
+ resolve(message);
465
+ }
466
+ };
244
467
  if (!this.process) {
245
- resolve("Error: No emulator process running");
468
+ safeResolve("Error: No emulator process running");
246
469
  return;
247
470
  }
248
471
  this.process.on('exit', () => {
249
- this.lastMachine = null; // Clear last machine on exit
472
+ this.lastMachine = null;
250
473
  this.isConnected = false;
251
474
  this.process = null;
252
- this.closePipeWriter();
253
- resolve("Ok: Emulator process closed successfully");
254
- });
255
- this.process.on('error', (error) => {
256
- resolve(`Error: error closing emulator: ${error.message}`);
475
+ this.resetIO();
476
+ safeResolve("Ok: Emulator process closed successfully");
257
477
  });
258
- // Try graceful shutdown first
478
+ this.process.on('error', (error) => safeResolve(`Error: error closing emulator: ${error.message}`));
259
479
  if (this.isConnected) {
260
- try {
261
- this.sendCommand('exit');
262
- }
263
- catch (error) {
264
- // If writing fails, force kill.
265
- // Use no-argument kill() for cross-platform safety:
266
- // on POSIX it sends SIGTERM; on Windows it calls TerminateProcess().
480
+ this.sendCommand('exit').catch(() => {
267
481
  try {
268
- this.process.kill();
482
+ this.process?.kill();
269
483
  }
270
484
  catch (_) { /* ignore */ }
271
- }
485
+ });
272
486
  }
273
487
  else {
274
488
  this.forceClose();
275
- resolve("Error: Emulator process had to be force killed");
489
+ safeResolve("Error: Emulator process had to be force killed");
276
490
  }
277
- // Force kill after timeout
278
- setTimeout(() => {
279
- this.forceClose();
280
- resolve("Error: Timeout. Emulator process had to be force killed");
281
- }, 1000);
491
+ setTimeout(() => { this.forceClose(); safeResolve("Error: Timeout. Emulator process had to be force killed"); }, 1000);
282
492
  });
283
493
  }
284
- /**
285
- * Get the status of the openMSX emulator using machine_info command
286
- * @returns Promise<string> - JSON string with machine information or error message
287
- */
288
494
  async emu_status() {
289
495
  try {
290
496
  const response = await this.sendCommand('machine_info');
291
- if (response.startsWith('Error:')) {
497
+ if (response.startsWith('Error:'))
292
498
  return response;
293
- }
294
- // Parse machine_info output into key-value pairs
295
499
  const skipInfo = ['issubslotted', 'input_port', 'slot', 'isexternalslot', 'output_port'];
296
500
  const parameters = response.trim().split(' ');
297
501
  const machineInfo = {};
298
502
  for (const param of parameters) {
299
- const trimmedLine = param.trim();
300
- // Skip certain parameters that are not useful
301
- if (skipInfo.includes(trimmedLine)) {
503
+ const trimmed = param.trim();
504
+ if (skipInfo.includes(trimmed) || !trimmed)
302
505
  continue;
303
- }
304
- if (trimmedLine) {
305
- const value = await this.sendCommand(`machine_info ${trimmedLine}`);
306
- machineInfo[trimmedLine] = value.trim();
307
- }
506
+ machineInfo[trimmed] = (await this.sendCommand(`machine_info ${trimmed}`)).trim();
308
507
  }
309
508
  return JSON.stringify(machineInfo, null, 2);
310
509
  }
@@ -317,167 +516,128 @@ export class OpenMSX {
317
516
  const response = await this.sendCommand('slotselect');
318
517
  return response.includes('0000: slot 0') && response.includes('4000: slot 0');
319
518
  }
320
- catch (error) {
519
+ catch (_) {
321
520
  return false;
322
521
  }
323
522
  }
324
- /**
325
- * Get the list of machines available in the openMSX emulator
326
- * @returns Promise<object> - object with machine names and descriptions or error message
327
- */
328
523
  async getMachineList(machinesDirectory) {
329
- // Read the machines directory
330
- let machines = [];
331
- let machinesList = "Error: No machines found.";
332
- try {
333
- const allFiles = await fs.readdir(machinesDirectory);
334
- machines = await Promise.all(allFiles
335
- .filter((file) => file.endsWith('.xml'))
336
- .map(async (file) => {
337
- return {
338
- name: file.replace('.xml', ''),
339
- description: await extractDescriptionFromXML(path.join(machinesDirectory, file))
340
- };
341
- }));
342
- if (machines.length !== 0) {
343
- machinesList = JSON.stringify(machines, null, 2);
344
- }
345
- return machinesList;
346
- }
347
- catch (error) {
348
- return `Error: error reading machines directory - ${error instanceof Error ? error.message : error}`;
349
- }
524
+ return this.getXMLList(machinesDirectory, 'machines');
350
525
  }
351
- /**
352
- * Get the list of extensions available in the openMSX emulator
353
- * @returns Promise<object> - object with extension names and descriptions or error message
354
- */
355
526
  async getExtensionList(extensionDirectory) {
356
- // Read the extensions directory
357
- let extensions = [];
358
- let extensionsList = "Error: No extensions found.";
527
+ return this.getXMLList(extensionDirectory, 'extensions');
528
+ }
529
+ async getXMLList(directory, entityName) {
359
530
  try {
360
- const allFiles = await fs.readdir(extensionDirectory);
361
- extensions = await Promise.all(allFiles
362
- .filter((file) => file.endsWith('.xml'))
363
- .map(async (file) => {
364
- return {
365
- name: file.replace('.xml', ''),
366
- description: await extractDescriptionFromXML(path.join(extensionDirectory, file))
367
- };
368
- }));
369
- if (extensions.length !== 0) {
370
- extensionsList = JSON.stringify(extensions, null, 2);
371
- }
372
- 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.`;
373
537
  }
374
538
  catch (error) {
375
- 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}`;
376
540
  }
377
541
  }
378
- ;
379
542
  /**
380
- * Send a command to the openMSX emulator and return the response
381
- * @param command - XML command to send to the emulator
382
- * @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).
383
549
  */
384
550
  async sendCommand(command) {
385
- try {
386
- // Send command
387
- this.writeData(`<command>${encodeHtmlEntities(command)}</command>\n`);
388
- // Read response using readData()
389
- const output = (await this.readData()).trim();
390
- // Look for reply tags in the output
391
- const replyMatch = output.match(/<reply result="(ok|nok)"[^>]*>(.*?)<\/reply>/s);
392
- if (replyMatch) {
393
- const outputContent = decodeHtmlEntities(replyMatch[2].trim());
394
- if (replyMatch[1] === 'ok') {
395
- return outputContent;
396
- }
397
- else {
398
- 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}`;
399
559
  }
560
+ return decodeHtmlEntities(output);
400
561
  }
401
- // Return raw output with HTML entities decoded
402
- return decodeHtmlEntities(output.trim());
403
- }
404
- catch (error) {
405
- return `Error: ${error instanceof Error ? error.message : error}`;
406
- }
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;
407
571
  }
408
- /**
409
- * Write data to openMSX.
410
- * On Linux/macOS: writes to the child process stdin.
411
- * On Windows: writes to the named pipe (pipeWriter).
412
- * @param data - XML command or data to send
413
- */
414
572
  writeData(data) {
415
- if (!this.process || !this.isConnected) {
573
+ if (!this.process || !this.isConnected)
416
574
  throw new Error('openMSX process not running or not connected');
417
- }
418
575
  if (IS_WINDOWS) {
419
- // Windows: send via named pipe
420
- if (!this.pipeWriter || this.pipeWriter.destroyed) {
421
- throw new Error('Windows named pipe not open');
422
- }
423
- this.pipeWriter.write(data);
576
+ if (!this.tcpSocket || this.tcpSocket.destroyed)
577
+ throw new Error('Windows TCP socket not open');
578
+ this.tcpSocket.write(data);
424
579
  }
425
580
  else {
426
- // Linux/macOS: send via child process stdin
427
- if (!this.process.stdin) {
581
+ if (!this.process.stdin)
428
582
  throw new Error('openMSX stdin not available');
429
- }
430
583
  this.process.stdin.write(data);
431
584
  }
432
585
  }
433
- /**
434
- * Read data from openMSX process stdout
435
- * @returns Promise<string> - The data received from stdout
436
- */
437
586
  readData() {
438
587
  return new Promise((resolve, reject) => {
439
- if (!this.process || !this.process.stdout || !this.isConnected) {
588
+ if (!this.isConnected) {
440
589
  reject(new Error('openMSX process not running or not connected'));
441
590
  return;
442
591
  }
443
- const onData = (data) => {
444
- this.process.stdout.removeListener('data', onData);
445
- 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
+ }
446
623
  };
447
- this.process.stdout.on('data', onData);
624
+ tryExtractReply();
448
625
  });
449
626
  }
450
- /**
451
- * Destructor - Clean up resources and close emulator if running
452
- * This method should be called when the instance is no longer needed
453
- */
454
627
  async destroy() {
455
- if (this.process && !this.process.killed) {
628
+ if (this.process && !this.process.killed)
456
629
  await this.emu_close();
457
- }
458
630
  }
459
- /**
460
- * Force close the emulator immediately (synchronous)
461
- * Used for emergency shutdown when async methods may not work
462
- */
463
631
  forceClose() {
464
632
  if (this.process && !this.process.killed) {
465
633
  try {
466
- // 'SIGKILL' is accepted on Windows too (maps to TerminateProcess).
467
- // No-argument kill() is also acceptable here, but SIGKILL makes
468
- // the intent explicit: we want unconditional termination.
469
634
  this.process.kill('SIGKILL');
470
635
  }
471
- catch (error) {
472
- // Ignore errors during force close
473
- }
636
+ catch (_) { /* ignore */ }
474
637
  this.process = null;
475
638
  this.isConnected = false;
476
639
  }
477
- this.closePipeWriter();
640
+ this.resetIO();
478
641
  }
479
642
  }
480
- /**
481
- * Global instance of OpenMSX for emulator control
482
- */
483
643
  export const openMSXInstance = new OpenMSX();
package/dist/server.js CHANGED
@@ -230,6 +230,15 @@ async function main() {
230
230
  }
231
231
  emuDirectories.MACHINES_DIR = path.join(emuDirectories.OPENMSX_SHARE_DIR, 'machines');
232
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
+ }
233
242
  VectorDB.setIndexDirectory(vectorDbDir);
234
243
  // Detect transport type from environment or command line
235
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
@@ -50,9 +50,13 @@ export function detectOpenMSXShareDir() {
50
50
  path.join(os.homedir(), '.openMSX', 'share'),
51
51
  '/usr/local/share/openmsx',
52
52
  '/usr/share/openmsx',
53
- // 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')] : []),
54
56
  path.join(os.homedir(), 'Documents', 'openMSX', 'share'),
57
+ path.join(os.homedir(), 'openMSX', 'share'),
55
58
  path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'openMSX', 'share'),
59
+ ...(process.env['PROGRAMFILES(X86)'] ? [path.join(process.env['PROGRAMFILES(X86)'], 'openMSX', 'share')] : []),
56
60
  // macOS paths
57
61
  path.join(os.homedir(), 'Library', 'Application Support', 'openMSX', 'share'),
58
62
  '/Applications/openMSX.app/Contents/Resources/share',
@@ -492,6 +496,31 @@ export function parseReplayStatus(response) {
492
496
  snapshotCount,
493
497
  };
494
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
+ }
495
524
  /*
496
525
  * Sleep for a specified number of milliseconds
497
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.7",
3
+ "version": "1.2.9",
4
4
  "description": "Model context protocol server for openMSX automation and control",
5
5
  "main": "dist/server.js",
6
6
  "type": "module",
@@ -30,12 +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
- "test": "echo \"Error: no test specified\" && exit 1"
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "test:coverage": "vitest run --coverage"
41
+ },
42
+ "optionalDependencies": {
43
+ "node-expose-sspi": "^0.1.60"
44
+ },
45
+ "pnpm": {
46
+ "overrides": {
47
+ "sharp": "$sharp"
48
+ }
39
49
  },
40
50
  "dependencies": {
41
51
  "@modelcontextprotocol/sdk": "^1.26.0",
@@ -47,6 +57,7 @@
47
57
  "express": "^5.2.1",
48
58
  "mime-types": "^3.0.2",
49
59
  "sanitize-html": "^2.17.0",
60
+ "sharp": "^0.34.5",
50
61
  "tsx": "^4.21.0",
51
62
  "vectra": "^0.11.1",
52
63
  "zod": "^3.25.76"
@@ -55,8 +66,10 @@
55
66
  "@modelcontextprotocol/inspector": "^0.20.0",
56
67
  "@types/node": "^25.2.3",
57
68
  "@types/sanitize-html": "^2.16.0",
69
+ "@vitest/coverage-v8": "^4.1.4",
58
70
  "shx": "^0.4.0",
59
- "typescript": "^5.9.3"
71
+ "typescript": "^5.9.3",
72
+ "vitest": "^4.1.4"
60
73
  },
61
74
  "files": [
62
75
  "dist/**/*",