@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 +1 -1
- package/dist/openmsx.js +471 -311
- package/dist/server.js +9 -0
- package/dist/server_tools.js +52 -15
- package/dist/utils.js +30 -1
- package/package.json +18 -5
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
|
|
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
|
|
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
|
|
37
|
-
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
|
|
49
|
-
const
|
|
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 (
|
|
49
|
+
safeResolve(`Error: openMSX emulator instance is already running (current machine: ${this.lastMachine}). Close it first.`);
|
|
60
50
|
return;
|
|
61
51
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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;
|
|
60
|
+
this.lastMachine = machine;
|
|
80
61
|
args.push('-machine', machine);
|
|
81
62
|
}
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
//
|
|
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', '
|
|
71
|
+
stdio: IS_WINDOWS ? ['ignore', 'ignore', 'pipe'] : ['pipe', 'pipe', 'pipe'],
|
|
72
|
+
windowsHide: false
|
|
94
73
|
});
|
|
95
|
-
if (
|
|
96
|
-
safeResolve('Error: Failed to create
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
87
|
+
diag(`process spawned PID=${this.process.pid}`);
|
|
112
88
|
this.process.on('error', (error) => {
|
|
113
|
-
|
|
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
|
|
117
|
-
`On
|
|
118
|
-
`
|
|
119
|
-
`on
|
|
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.
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (
|
|
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: ${
|
|
185
|
-
return;
|
|
118
|
+
safeResolve(`Error: ${msg}`);
|
|
186
119
|
}
|
|
187
120
|
});
|
|
188
|
-
|
|
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(
|
|
162
|
+
safeResolve(`Error: Timeout waiting for openMSX to start. Diagnostics: ${diagLog.join(' | ')}`);
|
|
193
163
|
}
|
|
194
|
-
},
|
|
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
|
-
*
|
|
203
|
-
*
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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 (
|
|
222
|
-
|
|
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
|
-
*
|
|
252
|
+
* Linux/macOS connection: stdio pipes.
|
|
253
|
+
* Registers stdout handler for ioBuffer accumulation and <openmsx-output> detection.
|
|
228
254
|
*/
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
468
|
+
safeResolve("Error: No emulator process running");
|
|
246
469
|
return;
|
|
247
470
|
}
|
|
248
471
|
this.process.on('exit', () => {
|
|
249
|
-
this.lastMachine = null;
|
|
472
|
+
this.lastMachine = null;
|
|
250
473
|
this.isConnected = false;
|
|
251
474
|
this.process = null;
|
|
252
|
-
this.
|
|
253
|
-
|
|
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
|
-
|
|
478
|
+
this.process.on('error', (error) => safeResolve(`Error: error closing emulator: ${error.message}`));
|
|
259
479
|
if (this.isConnected) {
|
|
260
|
-
|
|
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
|
|
482
|
+
this.process?.kill();
|
|
269
483
|
}
|
|
270
484
|
catch (_) { /* ignore */ }
|
|
271
|
-
}
|
|
485
|
+
});
|
|
272
486
|
}
|
|
273
487
|
else {
|
|
274
488
|
this.forceClose();
|
|
275
|
-
|
|
489
|
+
safeResolve("Error: Emulator process had to be force killed");
|
|
276
490
|
}
|
|
277
|
-
|
|
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
|
|
300
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
527
|
+
return this.getXMLList(extensionDirectory, 'extensions');
|
|
528
|
+
}
|
|
529
|
+
async getXMLList(directory, entityName) {
|
|
359
530
|
try {
|
|
360
|
-
const allFiles = await fs.readdir(
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
.
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
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
|
|
381
|
-
*
|
|
382
|
-
*
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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.
|
|
588
|
+
if (!this.isConnected) {
|
|
440
589
|
reject(new Error('openMSX process not running or not connected'));
|
|
441
590
|
return;
|
|
442
591
|
}
|
|
443
|
-
|
|
444
|
-
this.
|
|
445
|
-
|
|
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
|
-
|
|
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 (
|
|
472
|
-
// Ignore errors during force close
|
|
473
|
-
}
|
|
636
|
+
catch (_) { /* ignore */ }
|
|
474
637
|
this.process = null;
|
|
475
638
|
this.isConnected = false;
|
|
476
639
|
}
|
|
477
|
-
this.
|
|
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';
|
package/dist/server_tools.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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/**/*",
|