@nataliapc/mcp-openmsx 1.2.9 → 1.2.11
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 +41 -2
- package/bin/win-x64/mcp-openmsx-sspi-proxy.exe +0 -0
- package/dist/chunker.js +187 -0
- package/dist/embedder.js +250 -0
- package/dist/openmsx.js +113 -248
- package/dist/openmsx_windows.js +316 -0
- package/dist/server.js +6 -1
- package/dist/server_tools.js +6 -5
- package/dist/vectordb.js +94 -35
- package/package.json +16 -18
- package/resources/audio/chipsfmpacpr1_en.md +209 -0
- package/resources/audio/chipsfmpacpr2_en.md +170 -0
- package/resources/audio/toc.json +12 -0
- package/resources/book--msx-top-secret-3/MTS3-Appendix-English-Upd2.pdf +0 -0
- package/resources/book--msx-top-secret-3/MTS3-Complete-English.pdf +0 -0
- package/resources/book--msx2-technical-handbook/toc.json +1 -1
- package/resources/book--the-msx-red-book/Chapter1_Programmable_Peripheral_Interface.md +112 -0
- package/resources/book--the-msx-red-book/Chapter2_Video_Display_Processor.md +308 -0
- package/resources/book--the-msx-red-book/Chapter3_Programmable_Sound_Generator.md +168 -0
- package/resources/book--the-msx-red-book/Chapter4_ROM_BIOS.md +2528 -0
- package/resources/book--the-msx-red-book/Chapter5_ROM_BASIC_Interpreter.md +3975 -0
- package/resources/book--the-msx-red-book/Chapter6_Memory_Map.md +1963 -0
- package/resources/book--the-msx-red-book/Chapter7_Machine_Code_Programs.md +1238 -0
- package/resources/book--the-msx-red-book/Introduction.md +104 -0
- package/resources/book--the-msx-red-book/toc.json +38 -3
- package/resources/processors/toc.json +3 -3
- package/resources/processors/z80-undocumented.md +141 -0
- package/resources/sdcc/1_Introduction.md +199 -0
- package/resources/sdcc/2_Installing_SDCC.md +533 -0
- package/resources/sdcc/3_Using_SDCC.md +1758 -0
- package/resources/sdcc/4_Notes_on_supported_Processors.md +1638 -0
- package/resources/sdcc/5_Debugging.md +210 -0
- package/resources/sdcc/6_Tips_and_Support.md +258 -0
- package/resources/sdcc/7_SDCC_Technical_Data.md +489 -0
- package/resources/sdcc/8_Compiler_internals.md +477 -0
- package/resources/sdcc/toc.json +44 -2
- package/vector-db/msxdocs.lance/_indices/4d3bd360-e3c6-408d-b0ff-a4d6bd9580cb/metadata.lance +0 -0
- package/vector-db/msxdocs.lance/_indices/4d3bd360-e3c6-408d-b0ff-a4d6bd9580cb/part_0_docs.lance +0 -0
- package/vector-db/msxdocs.lance/_indices/4d3bd360-e3c6-408d-b0ff-a4d6bd9580cb/part_0_invert.lance +0 -0
- package/vector-db/msxdocs.lance/_indices/4d3bd360-e3c6-408d-b0ff-a4d6bd9580cb/part_0_tokens.lance +0 -0
- package/vector-db/msxdocs.lance/_transactions/0-6f47c9fc-3657-40f0-9dd4-c7226b2a4805.txn +0 -0
- package/vector-db/msxdocs.lance/_transactions/1-2bb7426e-a4b0-40ea-9a58-00c4985fc6a9.txn +0 -0
- package/vector-db/msxdocs.lance/_versions/18446744073709551613.manifest +0 -0
- package/vector-db/msxdocs.lance/_versions/18446744073709551614.manifest +0 -0
- package/vector-db/msxdocs.lance/_versions/latest_version_hint.json +1 -0
- package/vector-db/msxdocs.lance/data/110001110001011010001000876c134b8296fbc47762d1e1ab.lance +0 -0
- package/resources/book--the-msx-red-book/the_msx_red_book.md +0 -10349
- package/resources/processors/z80-undocumented.tex +0 -5617
- package/resources/sdcc/lyx2md.py +0 -745
- package/resources/sdcc/sdccman.lyx +0 -81574
- package/resources/sdcc/sdccman.md +0 -5557
- package/vector-db/index.json +0 -1
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows control-channel connector for openMSX.
|
|
3
|
+
*
|
|
4
|
+
* On Windows, openMSX is a /SUBSYSTEM:WINDOWS GUI app whose stdin/stdout cannot
|
|
5
|
+
* be piped, and its TCP control socket requires SSPI (Negotiate/NTLM) auth since
|
|
6
|
+
* openMSX 0.7.1. This module owns ALL Windows-specific transport details (mode
|
|
7
|
+
* selection, socket-file polling, TCP connect, SSPI handshake, proxy launch) so
|
|
8
|
+
* that `openmsx.ts` keeps only platform-agnostic orchestration: it consumes the
|
|
9
|
+
* returned {@link WindowsControlConnection} and attaches the same generic XML
|
|
10
|
+
* stream handlers it uses on Linux/macOS.
|
|
11
|
+
*
|
|
12
|
+
* Modes (env var `OPENMSX_WINDOWS_CONTROL`):
|
|
13
|
+
* - `stdio-proxy` (default): launch the bundled .NET helper
|
|
14
|
+
* `bin/win-x64/mcp-openmsx-sspi-proxy.exe <port>` which does SSPI and exposes
|
|
15
|
+
* a clean XML stdio channel, exactly like `openmsx -control stdio` on Linux.
|
|
16
|
+
* - `direct-sspi`: Node opens the TCP socket and authenticates with
|
|
17
|
+
* `node-expose-sspi`. Fallback path.
|
|
18
|
+
* - `socket`: alias of `direct-sspi`.
|
|
19
|
+
* - `pipe`: reserved/not implemented.
|
|
20
|
+
*
|
|
21
|
+
* @author Natalia Pujol Cremades (@nataliapc)
|
|
22
|
+
* @license GPL2
|
|
23
|
+
*/
|
|
24
|
+
import fsSync from 'fs';
|
|
25
|
+
import net from 'net';
|
|
26
|
+
import os from 'os';
|
|
27
|
+
import path from 'path';
|
|
28
|
+
import { spawn } from 'child_process';
|
|
29
|
+
import { fileURLToPath } from 'url';
|
|
30
|
+
import { sleep } from './utils.js';
|
|
31
|
+
/** Directory of this module (works under both `src/` (tsx) and `dist/`). */
|
|
32
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
/** Resolve the Windows temp directory openMSX writes its socket file into. */
|
|
34
|
+
function windowsTempDir(env) {
|
|
35
|
+
return env.TEMP ?? env.TMP ?? path.join(os.homedir(), 'AppData', 'Local', 'Temp');
|
|
36
|
+
}
|
|
37
|
+
export class OpenMsxWindowsConnector {
|
|
38
|
+
options;
|
|
39
|
+
constructor(options) {
|
|
40
|
+
this.options = options;
|
|
41
|
+
}
|
|
42
|
+
get env() {
|
|
43
|
+
return this.options.env ?? process.env;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the requested Windows control mode from `OPENMSX_WINDOWS_CONTROL`.
|
|
47
|
+
* Defaults to `stdio-proxy`. `socket` is a legacy alias of `direct-sspi`.
|
|
48
|
+
* Throws on an unrecognised value.
|
|
49
|
+
*/
|
|
50
|
+
static getControlMode(env = process.env) {
|
|
51
|
+
const raw = env.OPENMSX_WINDOWS_CONTROL?.trim().toLowerCase() || 'stdio-proxy';
|
|
52
|
+
if (raw === 'socket')
|
|
53
|
+
return 'direct-sspi';
|
|
54
|
+
if (raw === 'stdio-proxy' || raw === 'direct-sspi' || raw === 'pipe')
|
|
55
|
+
return raw;
|
|
56
|
+
throw new Error(`Invalid OPENMSX_WINDOWS_CONTROL="${raw}". ` +
|
|
57
|
+
`Supported values: stdio-proxy, direct-sspi, socket, pipe.`);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the path to the SSPI proxy executable.
|
|
61
|
+
* Honours `OPENMSX_WINDOWS_PROXY_EXECUTABLE` for development overrides,
|
|
62
|
+
* otherwise points at the bundled `bin/win-x64/mcp-openmsx-sspi-proxy.exe`
|
|
63
|
+
* relative to this module (i.e. `../bin/win-x64/...` from `dist/`).
|
|
64
|
+
*/
|
|
65
|
+
static resolveProxyExecutable(env = process.env) {
|
|
66
|
+
const override = env.OPENMSX_WINDOWS_PROXY_EXECUTABLE?.trim();
|
|
67
|
+
if (override)
|
|
68
|
+
return override;
|
|
69
|
+
return path.resolve(MODULE_DIR, '..', 'bin', 'win-x64', 'mcp-openmsx-sspi-proxy.exe');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Poll for openMSX's socket file and return the TCP control port it contains.
|
|
73
|
+
* The file appears at `%TEMP%\openmsx-default\socket.<pid>` and holds a port
|
|
74
|
+
* number (9938-9958).
|
|
75
|
+
*/
|
|
76
|
+
async waitForWindowsSocketPort(socketFile, maxWaitMs = 8000, pollMs = 200) {
|
|
77
|
+
let elapsed = 0;
|
|
78
|
+
// eslint-disable-next-line no-constant-condition
|
|
79
|
+
while (true) {
|
|
80
|
+
if (fsSync.existsSync(socketFile)) {
|
|
81
|
+
let raw;
|
|
82
|
+
try {
|
|
83
|
+
raw = fsSync.readFileSync(socketFile, 'utf8').trim();
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
throw new Error(`Cannot read openMSX socket file: ${e instanceof Error ? e.message : e}`);
|
|
87
|
+
}
|
|
88
|
+
const port = parseInt(raw, 10);
|
|
89
|
+
if (!port || isNaN(port)) {
|
|
90
|
+
throw new Error(`Invalid port in openMSX socket file: "${raw}"`);
|
|
91
|
+
}
|
|
92
|
+
return port;
|
|
93
|
+
}
|
|
94
|
+
elapsed += pollMs;
|
|
95
|
+
if (elapsed >= maxWaitMs) {
|
|
96
|
+
throw new Error(`openMSX socket file not found after ${maxWaitMs}ms: ${socketFile}`);
|
|
97
|
+
}
|
|
98
|
+
await sleep(pollMs);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Establish the Windows control channel for the configured mode and return a
|
|
103
|
+
* uniform connection. SSPI is fully completed before returning: for
|
|
104
|
+
* `direct-sspi` here in Node; for `stdio-proxy` inside the .NET helper. The
|
|
105
|
+
* caller only has to attach generic XML stream handlers and send
|
|
106
|
+
* `<openmsx-control>`.
|
|
107
|
+
*/
|
|
108
|
+
async connect() {
|
|
109
|
+
const mode = OpenMsxWindowsConnector.getControlMode(this.env);
|
|
110
|
+
if (mode === 'pipe') {
|
|
111
|
+
throw new Error('OPENMSX_WINDOWS_CONTROL=pipe is reserved but not implemented yet');
|
|
112
|
+
}
|
|
113
|
+
const pid = this.options.openmsxProcess.pid;
|
|
114
|
+
if (!pid)
|
|
115
|
+
throw new Error('openMSX process has no pid');
|
|
116
|
+
const socketFile = path.join(windowsTempDir(this.env), 'openmsx-default', `socket.${pid}`);
|
|
117
|
+
this.options.diag(`waiting for openMSX socket file: ${socketFile}`);
|
|
118
|
+
const port = await this.waitForWindowsSocketPort(socketFile);
|
|
119
|
+
this.options.diag(`openMSX control port: ${port}`);
|
|
120
|
+
return mode === 'stdio-proxy'
|
|
121
|
+
? this.connectStdioProxy(port)
|
|
122
|
+
: this.connectDirectSspi(port);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* `stdio-proxy`: launch the bundled .NET helper (it connects to openMSX, does
|
|
126
|
+
* SSPI and pipes raw XML over its stdio). SSPI never touches Node.
|
|
127
|
+
*/
|
|
128
|
+
connectStdioProxy(port) {
|
|
129
|
+
const proxyExe = OpenMsxWindowsConnector.resolveProxyExecutable(this.env);
|
|
130
|
+
if (!fsSync.existsSync(proxyExe)) {
|
|
131
|
+
throw new Error(`SSPI proxy executable not found: ${proxyExe}. ` +
|
|
132
|
+
`Build it with: pnpm build:proxy:win-x64:docker ` +
|
|
133
|
+
`(or set OPENMSX_WINDOWS_PROXY_EXECUTABLE to a built proxy).`);
|
|
134
|
+
}
|
|
135
|
+
this.options.diag(`launching SSPI proxy: "${proxyExe}" ${port}`);
|
|
136
|
+
const proxy = spawn(proxyExe, [String(port)], {
|
|
137
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
138
|
+
windowsHide: true,
|
|
139
|
+
});
|
|
140
|
+
if (!proxy.stdin || !proxy.stdout || !proxy.stderr) {
|
|
141
|
+
throw new Error('Failed to create SSPI proxy stdio pipes');
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
mode: 'stdio-proxy',
|
|
145
|
+
controlProcess: proxy,
|
|
146
|
+
input: proxy.stdin,
|
|
147
|
+
output: proxy.stdout,
|
|
148
|
+
errorOutput: proxy.stderr,
|
|
149
|
+
close: () => { try {
|
|
150
|
+
proxy.stdin?.end();
|
|
151
|
+
}
|
|
152
|
+
catch { /* ignore */ } },
|
|
153
|
+
forceClose: () => { try {
|
|
154
|
+
if (!proxy.killed)
|
|
155
|
+
proxy.kill('SIGKILL');
|
|
156
|
+
}
|
|
157
|
+
catch { /* ignore */ } },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* `direct-sspi`: open the TCP socket and authenticate from Node with
|
|
162
|
+
* `node-expose-sspi`. The returned socket is a Duplex used for both XML
|
|
163
|
+
* directions.
|
|
164
|
+
*/
|
|
165
|
+
async connectDirectSspi(port) {
|
|
166
|
+
const socket = await this.tcpConnect(port);
|
|
167
|
+
this.options.diag('starting SSPI authentication...');
|
|
168
|
+
await this.performSspiAuth(socket);
|
|
169
|
+
this.options.diag('SSPI authentication successful');
|
|
170
|
+
return {
|
|
171
|
+
mode: 'direct-sspi',
|
|
172
|
+
controlProcess: null,
|
|
173
|
+
tcpSocket: socket,
|
|
174
|
+
input: socket,
|
|
175
|
+
output: socket,
|
|
176
|
+
close: () => { try {
|
|
177
|
+
socket.end();
|
|
178
|
+
}
|
|
179
|
+
catch { /* ignore */ } },
|
|
180
|
+
forceClose: () => { try {
|
|
181
|
+
socket.destroy();
|
|
182
|
+
}
|
|
183
|
+
catch { /* ignore */ } },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** Open a TCP connection to openMSX's loopback control port. */
|
|
187
|
+
tcpConnect(port) {
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
const socket = net.createConnection(port, '127.0.0.1');
|
|
190
|
+
socket.once('connect', () => resolve(socket));
|
|
191
|
+
socket.once('error', err => {
|
|
192
|
+
socket.destroy();
|
|
193
|
+
reject(new Error(`TCP connect to ${port} failed: ${err.message}`));
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* SSPI (Negotiate/NTLM) authentication handshake — Windows only.
|
|
199
|
+
* Required since openMSX 0.7.1 for TCP socket connections.
|
|
200
|
+
* Uses the optional `node-expose-sspi` v0.1.x package.
|
|
201
|
+
*
|
|
202
|
+
* Reference C++ implementation: openMSX debugger SspiNegotiateClient.cpp
|
|
203
|
+
* Protocol: loop until SEC_E_OK — each round:
|
|
204
|
+
* client → [4-byte BE length][SSPI token]
|
|
205
|
+
* server → [4-byte BE length][SSPI response] (if SEC_I_CONTINUE_NEEDED)
|
|
206
|
+
* After SEC_E_OK: no server read — the XML protocol begins.
|
|
207
|
+
*
|
|
208
|
+
* The socket is paused before returning so any data openMSX sends after auth
|
|
209
|
+
* buffers until the caller attaches its own `data` handler (no data is lost).
|
|
210
|
+
*/
|
|
211
|
+
async performSspiAuth(socket) {
|
|
212
|
+
let nes;
|
|
213
|
+
try {
|
|
214
|
+
const { createRequire } = await import('module');
|
|
215
|
+
const req = createRequire(import.meta.url);
|
|
216
|
+
nes = req('node-expose-sspi');
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
throw new Error(`node-expose-sspi not available (${e instanceof Error ? e.message : e}). ` +
|
|
220
|
+
`Install with: npm install node-expose-sspi`);
|
|
221
|
+
}
|
|
222
|
+
// Accumulate TCP data for length-prefixed reads during SSPI phase.
|
|
223
|
+
// Pattern: onSspiData appends to buffer and NOTIFIES (does not clear).
|
|
224
|
+
// readLengthPrefixed checks the buffer size after each notification.
|
|
225
|
+
let sspiBuffer = Buffer.alloc(0);
|
|
226
|
+
let sspiNotify = null;
|
|
227
|
+
const onSspiData = (chunk) => {
|
|
228
|
+
sspiBuffer = Buffer.concat([sspiBuffer, chunk]);
|
|
229
|
+
if (sspiNotify) {
|
|
230
|
+
const notify = sspiNotify;
|
|
231
|
+
sspiNotify = null;
|
|
232
|
+
notify(); // just signal — buffer stays intact
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
socket.on('data', onSspiData);
|
|
236
|
+
// Wait until new data arrives (doesn't clear the buffer)
|
|
237
|
+
const waitMore = () => new Promise(resolve => {
|
|
238
|
+
sspiNotify = resolve;
|
|
239
|
+
});
|
|
240
|
+
const readLengthPrefixed = async () => {
|
|
241
|
+
// Wait until we have at least the 4-byte length prefix
|
|
242
|
+
while (sspiBuffer.length < 4)
|
|
243
|
+
await waitMore();
|
|
244
|
+
const len = sspiBuffer.readUInt32BE(0);
|
|
245
|
+
const total = 4 + len;
|
|
246
|
+
// Wait until the full payload has arrived
|
|
247
|
+
while (sspiBuffer.length < total)
|
|
248
|
+
await waitMore();
|
|
249
|
+
const result = sspiBuffer.subarray(4, total);
|
|
250
|
+
sspiBuffer = sspiBuffer.subarray(total); // consume only what we read
|
|
251
|
+
return result;
|
|
252
|
+
};
|
|
253
|
+
const sendToken = (token) => {
|
|
254
|
+
const buf = Buffer.from(token);
|
|
255
|
+
const lenBuf = Buffer.alloc(4);
|
|
256
|
+
lenBuf.writeUInt32BE(buf.length, 0);
|
|
257
|
+
socket.write(lenBuf);
|
|
258
|
+
socket.write(buf);
|
|
259
|
+
};
|
|
260
|
+
try {
|
|
261
|
+
// node-expose-sspi v0.1.x API:
|
|
262
|
+
// AcquireCredentialsHandle → CredentialWithExpiry { credential, tsExpiry }
|
|
263
|
+
// InitializeSecurityContextInput.credential = CredHandle (the .credential property)
|
|
264
|
+
// InitializeSecurityContextInput.SecBufferDesc = server's token (NOT serverSecurityContext)
|
|
265
|
+
// InitializeSecurityContextInput.contextReq = string[] of ISC_REQ_* flags
|
|
266
|
+
// Flags from openMSX C++ ref: ISC_REQ_ALLOCATE_MEMORY | ISC_REQ_CONNECTION | ISC_REQ_STREAM
|
|
267
|
+
const credWithExpiry = nes.sspi.AcquireCredentialsHandle({
|
|
268
|
+
packageName: 'Negotiate',
|
|
269
|
+
credentialUse: 'SECPKG_CRED_OUTBOUND',
|
|
270
|
+
});
|
|
271
|
+
const credential = credWithExpiry.credential;
|
|
272
|
+
const packageInfo = nes.sspi.QuerySecurityPackageInfo('Negotiate');
|
|
273
|
+
const ISC_FLAGS = ['ISC_REQ_ALLOCATE_MEMORY', 'ISC_REQ_CONNECTION', 'ISC_REQ_STREAM'];
|
|
274
|
+
let contextHandle = undefined;
|
|
275
|
+
let serverSecBufDesc = undefined;
|
|
276
|
+
// Loop until SEC_E_OK (mirrors C++ reference implementation)
|
|
277
|
+
while (true) {
|
|
278
|
+
const ctxInput = {
|
|
279
|
+
credential,
|
|
280
|
+
targetName: '',
|
|
281
|
+
cbMaxToken: packageInfo.cbMaxToken,
|
|
282
|
+
contextReq: ISC_FLAGS,
|
|
283
|
+
targetDataRep: 'SECURITY_NETWORK_DREP',
|
|
284
|
+
};
|
|
285
|
+
if (contextHandle !== undefined)
|
|
286
|
+
ctxInput.contextHandle = contextHandle;
|
|
287
|
+
if (serverSecBufDesc !== undefined)
|
|
288
|
+
ctxInput.SecBufferDesc = serverSecBufDesc;
|
|
289
|
+
const clientCtx = nes.sspi.InitializeSecurityContext(ctxInput);
|
|
290
|
+
contextHandle = clientCtx.contextHandle;
|
|
291
|
+
// Send our token to the server (if non-empty)
|
|
292
|
+
const tokenBuf = clientCtx.SecBufferDesc?.buffers?.[0];
|
|
293
|
+
if (tokenBuf && tokenBuf.byteLength > 0) {
|
|
294
|
+
sendToken(tokenBuf);
|
|
295
|
+
}
|
|
296
|
+
if (clientCtx.SECURITY_STATUS === 'SEC_E_OK') {
|
|
297
|
+
// Auth complete — no final read from server, proceed to XML
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
if (clientCtx.SECURITY_STATUS !== 'SEC_I_CONTINUE_NEEDED') {
|
|
301
|
+
throw new Error(`SSPI error: ${clientCtx.SECURITY_STATUS}`);
|
|
302
|
+
}
|
|
303
|
+
// Read server's response token
|
|
304
|
+
const response = await readLengthPrefixed();
|
|
305
|
+
const responseAB = response.buffer.slice(response.byteOffset, response.byteOffset + response.byteLength);
|
|
306
|
+
serverSecBufDesc = { ulVersion: 0, buffers: [responseAB] };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
// Remove the SSPI data listener and pause the socket so any post-auth
|
|
311
|
+
// bytes buffer until the caller attaches its own 'data' handler.
|
|
312
|
+
socket.removeListener('data', onSspiData);
|
|
313
|
+
socket.pause();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -29,7 +29,12 @@ const require = createRequire(import.meta.url);
|
|
|
29
29
|
export const PACKAGE_VERSION = require('../package.json').version;
|
|
30
30
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
31
31
|
const resourcesDir = path.join(__dirname, "../resources");
|
|
32
|
-
|
|
32
|
+
// Index location. Defaults to the bundled `vector-db` next to the build, but can
|
|
33
|
+
// be overridden with OPENMSX_VECTORDB_DIR. LanceDB/lance-io (Rust object_store)
|
|
34
|
+
// cannot read a local index from a Windows *mapped network drive* (it drops the
|
|
35
|
+
// drive letter when converting the path to a file:// URL). When the project lives
|
|
36
|
+
// on a network share, point this at a copy of the index on a local disk.
|
|
37
|
+
const vectorDbDir = process.env.OPENMSX_VECTORDB_DIR?.trim() || path.join(__dirname, "../vector-db");
|
|
33
38
|
export const emuDirectories = {
|
|
34
39
|
OPENMSX_SHARE_DIR: '',
|
|
35
40
|
OPENMSX_EXECUTABLE: detectOpenMSXExecutable(),
|
package/dist/server_tools.js
CHANGED
|
@@ -1677,10 +1677,11 @@ The parameter scrbasename is the name of the filename (without path) to save the
|
|
|
1677
1677
|
"vector_db_query", {
|
|
1678
1678
|
title: "Vector DB query from resources",
|
|
1679
1679
|
// Description of the tool (what it does)
|
|
1680
|
-
description: `Query the
|
|
1681
|
-
|
|
1682
|
-
The
|
|
1683
|
-
|
|
1680
|
+
description: `Query the documentation index to obtain information about MSX system, cartridges, programming, and other development resources.
|
|
1681
|
+
Uses hybrid search: semantic (multilingual embeddings) combined with keyword (BM25) matching, fused with Reciprocal Rank Fusion. Good for both conceptual questions and exact terms (mnemonics, register names, BIOS calls).
|
|
1682
|
+
The query is a string; it is case-insensitive and may contain spaces.
|
|
1683
|
+
The response is the list of the top 10 matching resource chunks, including their relevance score, title, and resource URI, sorted in descending order by score.
|
|
1684
|
+
**Important Note**: The documentation resources are in english, japanese, or dutch (the embedding model is multilingual).
|
|
1684
1685
|
`,
|
|
1685
1686
|
// Schema for the tool (input validation)
|
|
1686
1687
|
inputSchema: {
|
|
@@ -1691,7 +1692,7 @@ The response is the list of the top 10 result resources that match the query, in
|
|
|
1691
1692
|
},
|
|
1692
1693
|
outputSchema: {
|
|
1693
1694
|
results: z.array(z.object({
|
|
1694
|
-
score: z.string().describe("
|
|
1695
|
+
score: z.string().describe("Reciprocal Rank Fusion (RRF) relevance score combining semantic and keyword matches; higher is better. Only the relative ranking is meaningful, not the absolute value (typically ~0.01-0.03)."),
|
|
1695
1696
|
title: z.string().describe("Title of the resource."),
|
|
1696
1697
|
uri: z.string().describe("URI of the resource, which can be used to access the resource."),
|
|
1697
1698
|
document: z.string().describe("Document chunk of the resource, retrieved from the Vector DB."),
|
package/dist/vectordb.js
CHANGED
|
@@ -1,16 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Vector Database wrapper class
|
|
2
|
+
* Vector Database wrapper class.
|
|
3
|
+
*
|
|
4
|
+
* Hybrid search over the MSX documentation corpus:
|
|
5
|
+
* - dense vector search (cosine over 384-d embeddings, see embedder.ts)
|
|
6
|
+
* - full-text search (BM25, LanceDB native FTS index)
|
|
7
|
+
* - fused with Reciprocal Rank Fusion (RRF)
|
|
8
|
+
*
|
|
9
|
+
* Storage: LanceDB (columnar `.lance` table), replacing the previous Vectra
|
|
10
|
+
* `index.json`.
|
|
3
11
|
*
|
|
4
12
|
* @author Natalia Pujol Cremades (@nataliapc)
|
|
5
13
|
* @license GPL2
|
|
6
14
|
*/
|
|
7
15
|
import path from 'path';
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
16
|
+
import { pathToFileURL } from 'url';
|
|
17
|
+
import * as lancedb from '@lancedb/lancedb';
|
|
18
|
+
import { embedQuery } from './embedder.js';
|
|
19
|
+
const TABLE_NAME = 'msxdocs';
|
|
20
|
+
const TOP_K = 10; // final results returned
|
|
21
|
+
const CANDIDATES = 30; // candidates fetched per branch before fusion
|
|
22
|
+
const RRF_K = 60; // RRF constant
|
|
23
|
+
/**
|
|
24
|
+
* Fuse two ranked result lists with Reciprocal Rank Fusion.
|
|
25
|
+
* Each document scores Σ 1/(k + rank) over the lists it appears in
|
|
26
|
+
* (rank is 0-based, so the +1 makes the top item contribute 1/(k+1)).
|
|
27
|
+
*/
|
|
28
|
+
export function fuseRRF(vecRows, ftsRows, k = RRF_K, topK = TOP_K) {
|
|
29
|
+
const acc = new Map();
|
|
30
|
+
const add = (rows) => {
|
|
31
|
+
rows.forEach((row, rank) => {
|
|
32
|
+
const id = String(row.id);
|
|
33
|
+
const inc = 1 / (k + rank + 1);
|
|
34
|
+
const cur = acc.get(id);
|
|
35
|
+
if (cur) {
|
|
36
|
+
cur.score += inc;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
acc.set(id, { score: inc, row });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
add(vecRows);
|
|
44
|
+
add(ftsRows);
|
|
45
|
+
return [...acc.values()].sort((a, b) => b.score - a.score).slice(0, topK);
|
|
46
|
+
}
|
|
10
47
|
export class VectorDB {
|
|
11
|
-
static instance;
|
|
48
|
+
static instance = null;
|
|
12
49
|
static vectorDbDir = path.join('..', 'vector-db');
|
|
13
|
-
|
|
50
|
+
tablePromise = null;
|
|
14
51
|
static getInstance() {
|
|
15
52
|
if (!VectorDB.instance) {
|
|
16
53
|
VectorDB.instance = new VectorDB();
|
|
@@ -20,42 +57,64 @@ export class VectorDB {
|
|
|
20
57
|
static setIndexDirectory(dbDir) {
|
|
21
58
|
VectorDB.vectorDbDir = dbDir;
|
|
22
59
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the directory into a value LanceDB can open.
|
|
62
|
+
*
|
|
63
|
+
* On Windows, LanceDB 0.30 (lance-io 7.0) mishandles drive-letter paths: it
|
|
64
|
+
* builds a malformed `file://` URL that drops the drive
|
|
65
|
+
* (`file:///mcp-server/vector-db/...`) and then fails to convert it back to a
|
|
66
|
+
* filesystem path. Passing an explicit, well-formed `file://` URI built by Node
|
|
67
|
+
* (`file:///M:/mcp-server/vector-db`) bypasses that broken path→URL step. On
|
|
68
|
+
* POSIX a plain path works fine, so we leave it untouched.
|
|
69
|
+
*/
|
|
70
|
+
static resolveUri(dir) {
|
|
71
|
+
return process.platform === 'win32'
|
|
72
|
+
? pathToFileURL(path.resolve(dir)).href
|
|
73
|
+
: dir;
|
|
28
74
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
75
|
+
getTable() {
|
|
76
|
+
if (!this.tablePromise) {
|
|
77
|
+
this.tablePromise = (async () => {
|
|
78
|
+
const db = await lancedb.connect(VectorDB.resolveUri(VectorDB.vectorDbDir));
|
|
79
|
+
return db.openTable(TABLE_NAME);
|
|
80
|
+
})().catch((err) => {
|
|
81
|
+
this.tablePromise = null;
|
|
82
|
+
throw new Error(`Failed to open LanceDB table '${TABLE_NAME}' at '${VectorDB.vectorDbDir}'. ` +
|
|
83
|
+
`Has the index been generated? (${err instanceof Error ? err.message : err})`);
|
|
84
|
+
});
|
|
36
85
|
}
|
|
37
|
-
return
|
|
86
|
+
return this.tablePromise;
|
|
38
87
|
}
|
|
39
88
|
async query(text) {
|
|
40
|
-
const
|
|
41
|
-
|
|
89
|
+
const tbl = await this.getTable();
|
|
90
|
+
const vector = await embedQuery(text);
|
|
91
|
+
// Vector branch (always available). No `.select()`: scoring queries warn
|
|
92
|
+
// when output columns are projected without `_distance`/`_score`, and the
|
|
93
|
+
// candidate set is tiny so fetching full rows is negligible.
|
|
94
|
+
const vecRows = await tbl
|
|
95
|
+
.query()
|
|
96
|
+
.nearestTo(vector)
|
|
97
|
+
.limit(CANDIDATES)
|
|
98
|
+
.toArray();
|
|
99
|
+
// Full-text (BM25) branch. Degrade gracefully to vector-only if the FTS
|
|
100
|
+
// index is missing or the query cannot be parsed.
|
|
101
|
+
let ftsRows = [];
|
|
42
102
|
try {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
uri: result.item.metadata?.uri || 'unknown',
|
|
49
|
-
title: result.item.metadata?.title || 'unknown',
|
|
50
|
-
document: String(result.item.metadata?.document || ''),
|
|
51
|
-
id: result.item.metadata?.id || 'unknown',
|
|
52
|
-
};
|
|
53
|
-
});
|
|
54
|
-
}
|
|
103
|
+
ftsRows = await tbl
|
|
104
|
+
.query()
|
|
105
|
+
.nearestToText(text)
|
|
106
|
+
.limit(CANDIDATES)
|
|
107
|
+
.toArray();
|
|
55
108
|
}
|
|
56
|
-
catch
|
|
57
|
-
|
|
109
|
+
catch {
|
|
110
|
+
ftsRows = [];
|
|
58
111
|
}
|
|
59
|
-
return
|
|
112
|
+
return fuseRRF(vecRows, ftsRows).map((r) => ({
|
|
113
|
+
score: r.score.toFixed(4),
|
|
114
|
+
uri: r.row.uri ?? 'unknown',
|
|
115
|
+
title: r.row.title ?? 'unknown',
|
|
116
|
+
document: String(r.row.text ?? ''),
|
|
117
|
+
id: r.row.id ?? 'unknown',
|
|
118
|
+
}));
|
|
60
119
|
}
|
|
61
120
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nataliapc/mcp-openmsx",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.11",
|
|
4
4
|
"description": "Model context protocol server for openMSX automation and control",
|
|
5
5
|
"main": "dist/server.js",
|
|
6
6
|
"type": "module",
|
|
@@ -30,51 +30,49 @@
|
|
|
30
30
|
"node": ">=18.0.0"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
|
-
"build": "
|
|
34
|
-
"watch": "
|
|
33
|
+
"build": "shx rm -rf dist && tsc && shx chmod +x dist/server.js",
|
|
34
|
+
"watch": "tsc --watch",
|
|
35
35
|
"start": "node dist/server.js",
|
|
36
36
|
"dev": "tsx src/server.ts",
|
|
37
37
|
"prepublishOnly": "npm run build",
|
|
38
38
|
"test": "vitest run",
|
|
39
39
|
"test:watch": "vitest",
|
|
40
|
-
"test:coverage": "vitest run --coverage"
|
|
40
|
+
"test:coverage": "vitest run --coverage",
|
|
41
|
+
"build:proxy:win-x64": "dotnet publish helpers/openmsx-sspi-proxy/OpenMsxSspiProxy.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=true -p:TrimMode=partial -p:EnableCompressionInSingleFile=true -o bin/win-x64",
|
|
42
|
+
"build:proxy:win-x64:docker": "docker run --rm --user $(id -u):$(id -g) -e HOME=/tmp -e DOTNET_CLI_HOME=/tmp -v \"$PWD\":/work -w /work mcr.microsoft.com/dotnet/sdk:8.0 dotnet publish helpers/openmsx-sspi-proxy/OpenMsxSspiProxy.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=true -p:TrimMode=partial -p:EnableCompressionInSingleFile=true -o bin/win-x64",
|
|
43
|
+
"build:all": "pnpm build && pnpm build:proxy:win-x64:docker"
|
|
41
44
|
},
|
|
42
45
|
"optionalDependencies": {
|
|
43
46
|
"node-expose-sspi": "^0.1.60"
|
|
44
47
|
},
|
|
45
|
-
"pnpm": {
|
|
46
|
-
"overrides": {
|
|
47
|
-
"sharp": "$sharp"
|
|
48
|
-
}
|
|
49
|
-
},
|
|
50
48
|
"dependencies": {
|
|
49
|
+
"@anush008/tokenizers": "^0.6.0",
|
|
50
|
+
"@lancedb/lancedb": "^0.30.0",
|
|
51
51
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
52
|
-
"@themaximalist/embeddings.js": "^0.1.3",
|
|
53
52
|
"@types/express": "^5.0.6",
|
|
54
53
|
"@types/mime-types": "^3.0.1",
|
|
55
|
-
"@xenova/transformers": "^2.17.2",
|
|
56
54
|
"debug": "^4.4.3",
|
|
57
55
|
"express": "^5.2.1",
|
|
58
56
|
"mime-types": "^3.0.2",
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"tsx": "^4.
|
|
62
|
-
"vectra": "^0.11.1",
|
|
57
|
+
"onnxruntime-node": "^1.27.0",
|
|
58
|
+
"sanitize-html": "^2.17.4",
|
|
59
|
+
"tsx": "^4.22.0",
|
|
63
60
|
"zod": "^3.25.76"
|
|
64
61
|
},
|
|
65
62
|
"devDependencies": {
|
|
66
63
|
"@modelcontextprotocol/inspector": "^0.20.0",
|
|
67
|
-
"@types/node": "^25.
|
|
64
|
+
"@types/node": "^25.7.0",
|
|
68
65
|
"@types/sanitize-html": "^2.16.0",
|
|
69
|
-
"@vitest/coverage-v8": "^4.1.
|
|
66
|
+
"@vitest/coverage-v8": "^4.1.6",
|
|
70
67
|
"shx": "^0.4.0",
|
|
71
68
|
"typescript": "^5.9.3",
|
|
72
|
-
"vitest": "^4.1.
|
|
69
|
+
"vitest": "^4.1.6"
|
|
73
70
|
},
|
|
74
71
|
"files": [
|
|
75
72
|
"dist/**/*",
|
|
76
73
|
"resources/**/*",
|
|
77
74
|
"vector-db/**/*",
|
|
75
|
+
"bin/win-x64/**/*",
|
|
78
76
|
"README.md",
|
|
79
77
|
"LICENSE"
|
|
80
78
|
],
|