magector 2.1.9 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +5 -5
  2. package/src/mcp-server.js +232 -63
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.1.9",
3
+ "version": "2.2.0",
4
4
  "description": "Semantic code search for Magento 2 — index, search, MCP server",
5
5
  "type": "module",
6
6
  "main": "src/mcp-server.js",
@@ -39,10 +39,10 @@
39
39
  "ruvector": "^0.1.96"
40
40
  },
41
41
  "optionalDependencies": {
42
- "@magector/cli-darwin-arm64": "2.1.9",
43
- "@magector/cli-linux-x64": "2.1.9",
44
- "@magector/cli-linux-arm64": "2.1.9",
45
- "@magector/cli-win32-x64": "2.1.9"
42
+ "@magector/cli-darwin-arm64": "2.2.0",
43
+ "@magector/cli-linux-x64": "2.2.0",
44
+ "@magector/cli-linux-arm64": "2.2.0",
45
+ "@magector/cli-win32-x64": "2.2.0"
46
46
  },
47
47
  "keywords": [
48
48
  "magento",
package/src/mcp-server.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from '@modelcontextprotocol/sdk/types.js';
17
17
  import { execFileSync, spawn } from 'child_process';
18
18
  import { createInterface } from 'readline';
19
+ import { createServer as createNetServer, createConnection } from 'net';
19
20
  import { existsSync, statSync, unlinkSync, copyFileSync, renameSync, appendFileSync, writeFileSync, readFileSync, mkdirSync } from 'fs';
20
21
  import { stat } from 'fs/promises';
21
22
  import { glob } from 'glob';
@@ -142,6 +143,8 @@ function extractJson(stdout) {
142
143
 
143
144
  const PID_PATH = path.join(config.magentoRoot, '.magector', 'serve.pid');
144
145
  const REINDEX_PID_PATH = path.join(config.magentoRoot, '.magector', 'reindex.pid');
146
+ const SOCK_PATH = path.join(config.magentoRoot, '.magector', 'serve.sock');
147
+ const FORMAT_CACHE_PATH = path.join(config.magentoRoot, '.magector', 'format-ok.json');
145
148
 
146
149
  /**
147
150
  * Write the serve process PID to disk so future instances can clean up orphans.
@@ -180,10 +183,29 @@ function getRunningReindexPid() {
180
183
  }
181
184
  }
182
185
 
186
+ /**
187
+ * Check if an existing serve process is alive and usable.
188
+ * Returns the PID if alive, null if stale/missing.
189
+ * Does NOT kill it — multiple MCP instances can share one serve process
190
+ * by sending queries to it via stdin (each instance starts its own).
191
+ */
192
+ function getExistingServePid() {
193
+ try {
194
+ if (!existsSync(PID_PATH)) return null;
195
+ const pid = parseInt(readFileSync(PID_PATH, 'utf-8').trim(), 10);
196
+ if (!pid || isNaN(pid)) return null;
197
+ process.kill(pid, 0); // signal 0 = existence check
198
+ return pid;
199
+ } catch {
200
+ removePidFile();
201
+ return null;
202
+ }
203
+ }
204
+
183
205
  /**
184
206
  * Kill any stale serve process from a previous MCP server instance.
185
- * This handles the common case where the MCP server was killed without
186
- * triggering its exit handler (SIGKILL, crash, IDE disconnect).
207
+ * Only called during cleanup (exit/SIGTERM), not during startup
208
+ * multiple concurrent MCP instances each run their own serve process.
187
209
  */
188
210
  function killStaleServeProcess() {
189
211
  try {
@@ -191,11 +213,9 @@ function killStaleServeProcess() {
191
213
  const stalePid = parseInt(readFileSync(PID_PATH, 'utf-8').trim(), 10);
192
214
  if (!stalePid || isNaN(stalePid)) return;
193
215
 
194
- // Check if the process is still alive
195
216
  try {
196
- process.kill(stalePid, 0); // signal 0 = existence check
217
+ process.kill(stalePid, 0);
197
218
  } catch {
198
- // Process doesn't exist, clean up stale PID file
199
219
  removePidFile();
200
220
  return;
201
221
  }
@@ -204,14 +224,11 @@ function killStaleServeProcess() {
204
224
  console.error(`Killing stale serve process (PID ${stalePid})`);
205
225
  try { process.kill(stalePid, 'SIGTERM'); } catch {}
206
226
 
207
- // Give it a moment, then force kill if still alive
208
227
  setTimeout(() => {
209
228
  try {
210
229
  process.kill(stalePid, 0);
211
230
  process.kill(stalePid, 'SIGKILL');
212
- } catch {
213
- // Already dead, good
214
- }
231
+ } catch {}
215
232
  }, 2000);
216
233
 
217
234
  removePidFile();
@@ -224,20 +241,33 @@ function killStaleServeProcess() {
224
241
 
225
242
  let reindexInProgress = false;
226
243
  let reindexProcess = null;
244
+ let warmupInProgress = true; // true until checkDbFormat + serve process ready
227
245
 
228
246
  /**
229
247
  * Check if the database file is compatible with the current binary.
230
- * Returns true if OK, false if format mismatch (file has data but binary reads 0 vectors).
231
- * Async to avoid blocking the event loop stats loads the HNSW graph which can
232
- * take 30-60s for large indexes (80k+ vectors).
248
+ * Uses a cached result file to avoid running stats (30-60s) on every startup.
249
+ * Cache key: binary path mtime + db file mtime + db size.
233
250
  */
234
251
  async function checkDbFormat() {
235
252
  if (!existsSync(config.dbPath)) return true;
236
253
 
237
254
  try {
238
255
  const fstat = statSync(config.dbPath);
239
- if (fstat.size < 100) return true; // Tiny file = likely empty/new
256
+ if (fstat.size < 100) return true;
257
+
258
+ // Check cached result — avoids 40s stats command on every MCP startup
259
+ const binaryStat = statSync(config.rustBinary);
260
+ const cacheKey = `${binaryStat.mtimeMs}|${fstat.mtimeMs}|${fstat.size}`;
261
+ try {
262
+ const cached = JSON.parse(readFileSync(FORMAT_CACHE_PATH, 'utf-8'));
263
+ if (cached.key === cacheKey) {
264
+ logToFile('INFO', `Format check cached: ${cached.ok ? 'compatible' : 'incompatible'}`);
265
+ return cached.ok;
266
+ }
267
+ } catch { /* no cache or invalid */ }
240
268
 
269
+ // Cache miss — run stats (expensive: loads full HNSW graph)
270
+ logToFile('INFO', 'Format check: running stats (this takes 30-60s for large indexes)...');
241
271
  const result = await new Promise((resolve, reject) => {
242
272
  const proc = spawn(config.rustBinary, ['stats', '-d', config.dbPath],
243
273
  { stdio: ['pipe', 'pipe', 'pipe'], env: rustEnv });
@@ -249,7 +279,11 @@ async function checkDbFormat() {
249
279
  });
250
280
 
251
281
  const vectors = parseInt(result.match(/Total vectors:\s*(\d+)/)?.[1] || '0');
252
- return vectors > 0;
282
+ const ok = vectors > 0;
283
+
284
+ // Write cache
285
+ try { writeFileSync(FORMAT_CACHE_PATH, JSON.stringify({ key: cacheKey, ok })); } catch {}
286
+ return ok;
253
287
  } catch {
254
288
  return false;
255
289
  }
@@ -566,6 +600,111 @@ function startServeProcess() {
566
600
  }
567
601
  }
568
602
 
603
+ // ─── Singleton Socket Proxy ──────────────────────────────────────
604
+ // Only one serve process runs per project. Other MCP instances connect
605
+ // to a Unix socket proxy instead of spawning their own serve process.
606
+
607
+ let socketServer = null;
608
+ let isSocketClient = false; // true if we're a secondary instance using the socket
609
+
610
+ /**
611
+ * Start a Unix socket server that proxies queries to the local serve process.
612
+ * Other MCP instances connect here instead of starting their own serve.
613
+ */
614
+ function startSocketProxy() {
615
+ try { if (existsSync(SOCK_PATH)) unlinkSync(SOCK_PATH); } catch {}
616
+ socketServer = createNetServer((conn) => {
617
+ const rl = createInterface({ input: conn });
618
+ rl.on('line', async (line) => {
619
+ try {
620
+ const req = JSON.parse(line);
621
+ const resp = await serveQuery(req.command, req.params || {}, req.timeout || 30000);
622
+ conn.write(JSON.stringify(resp) + '\n');
623
+ } catch (err) {
624
+ conn.write(JSON.stringify({ ok: false, error: err.message }) + '\n');
625
+ }
626
+ });
627
+ conn.on('error', () => {}); // ignore client disconnect
628
+ });
629
+ socketServer.on('error', (err) => {
630
+ logToFile('WARN', `Socket proxy error: ${err.message}`);
631
+ });
632
+ socketServer.listen(SOCK_PATH, () => {
633
+ logToFile('INFO', `Socket proxy listening on ${SOCK_PATH}`);
634
+ });
635
+ }
636
+
637
+ /**
638
+ * Try to connect to an existing socket proxy (another MCP instance owns the serve process).
639
+ * Returns true if connected successfully — serveQuery will route through the socket.
640
+ */
641
+ function tryConnectSocket() {
642
+ return new Promise((resolve) => {
643
+ if (!existsSync(SOCK_PATH)) { resolve(false); return; }
644
+ const conn = createConnection(SOCK_PATH);
645
+ const timeout = setTimeout(() => { conn.destroy(); resolve(false); }, 3000);
646
+ conn.on('connect', () => {
647
+ clearTimeout(timeout);
648
+ // Connection works — set up socket-based serveQuery
649
+ const rl = createInterface({ input: conn });
650
+ let pendingResolve = null;
651
+
652
+ rl.on('line', (line) => {
653
+ try {
654
+ const resp = JSON.parse(line);
655
+ if (pendingResolve) { pendingResolve(resp); pendingResolve = null; }
656
+ } catch {}
657
+ });
658
+
659
+ conn.on('error', () => {
660
+ isSocketClient = false;
661
+ serveReady = false;
662
+ logToFile('WARN', 'Socket connection lost — falling back to cold-start');
663
+ });
664
+ conn.on('close', () => {
665
+ isSocketClient = false;
666
+ serveReady = false;
667
+ });
668
+
669
+ // Override serveQuery to route through socket
670
+ const socketQueryQueue = [];
671
+ let socketBusy = false;
672
+
673
+ async function processSocketQueue() {
674
+ if (socketBusy || socketQueryQueue.length === 0) return;
675
+ socketBusy = true;
676
+ const { command, params, timeoutMs, resolve: qResolve, reject: qReject } = socketQueryQueue.shift();
677
+ const timer = setTimeout(() => { pendingResolve = null; qReject(new Error('Socket query timeout')); socketBusy = false; processSocketQueue(); }, timeoutMs);
678
+ pendingResolve = (resp) => { clearTimeout(timer); qResolve(resp); socketBusy = false; processSocketQueue(); };
679
+ try {
680
+ conn.write(JSON.stringify({ command, params, timeout: timeoutMs }) + '\n');
681
+ } catch (err) {
682
+ clearTimeout(timer);
683
+ pendingResolve = null;
684
+ qReject(err);
685
+ socketBusy = false;
686
+ processSocketQueue();
687
+ }
688
+ }
689
+
690
+ // Replace the global serveQuery with socket-based version
691
+ globalServeQuery = (command, params, timeoutMs) => new Promise((res, rej) => {
692
+ socketQueryQueue.push({ command, params, timeoutMs, resolve: res, reject: rej });
693
+ processSocketQueue();
694
+ });
695
+
696
+ isSocketClient = true;
697
+ serveReady = true;
698
+ logToFile('INFO', 'Connected to existing serve process via socket proxy');
699
+ resolve(true);
700
+ });
701
+ conn.on('error', () => { clearTimeout(timeout); resolve(false); });
702
+ });
703
+ }
704
+
705
+ // Global reference to serveQuery implementation (local or socket)
706
+ let globalServeQuery = null;
707
+
569
708
  function serveQuery(command, params = {}, timeoutMs = 30000) {
570
709
  return new Promise((resolve, reject) => {
571
710
  const id = serveNextId++;
@@ -597,10 +736,11 @@ async function rustSearchAsync(query, limit = 10) {
597
736
  await Promise.race([serveReadyPromise, new Promise(r => setTimeout(() => r(false), 10000))]);
598
737
  }
599
738
 
600
- // Try persistent serve process first
601
- if (serveProcess && serveReady) {
739
+ // Try socket proxy (secondary instance) or local serve process (primary)
740
+ const queryFn = globalServeQuery || ((serveProcess && serveReady) ? serveQuery : null);
741
+ if (queryFn) {
602
742
  try {
603
- const resp = await serveQuery('search', { query, limit });
743
+ const resp = await queryFn('search', { query, limit });
604
744
  if (resp.ok && Array.isArray(resp.data)) {
605
745
  cacheSet(cacheKey, resp.data);
606
746
  return resp.data;
@@ -2333,11 +2473,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2333
2473
  const reqStart = Date.now();
2334
2474
  logToFile('REQ', `${name}(${JSON.stringify(args || {})})`);
2335
2475
 
2336
- // Block search tools only when re-indexing AND no usable old DB exists.
2337
- // If old DB is preserved, searches keep running against it during rebuild.
2476
+ // ── Warmup guard: index compatibility check or serve process still loading ──
2338
2477
  const indexFreeTools = ['magento_stats', 'magento_analyze_diff', 'magento_complexity',
2339
2478
  'magento_trace_dependency', 'magento_error_parser', 'magento_find_layout',
2340
2479
  'magento_impact_analysis', 'magento_find_event_flow', 'magento_find_test'];
2480
+ if (warmupInProgress && !indexFreeTools.includes(name)) {
2481
+ logToFile('REQ', `${name} → blocked (warmup: loading index)`);
2482
+ return {
2483
+ content: [{
2484
+ type: 'text',
2485
+ text: 'Magector is warming up — loading the search index into memory. This takes 30-60 seconds on first startup. Please retry your query in a moment.'
2486
+ }],
2487
+ isError: true,
2488
+ };
2489
+ }
2490
+
2491
+ // Block search tools only when re-indexing AND no usable old DB exists.
2492
+ // If old DB is preserved, searches keep running against it during rebuild.
2341
2493
  const hasUsableDb = existsSync(config.dbPath) && (() => { try { return statSync(config.dbPath).size > 100; } catch { return false; } })();
2342
2494
  if (reindexInProgress && !hasUsableDb && !indexFreeTools.includes(name)) {
2343
2495
  logToFile('REQ', `${name} → blocked (re-indexing, no usable DB)`);
@@ -3217,56 +3369,68 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
3217
3369
  });
3218
3370
 
3219
3371
  async function main() {
3220
- // Kill any orphaned serve process from a previous session
3221
- killStaleServeProcess();
3222
-
3223
- // Check database format compatibility before starting serve process.
3224
- // With incremental saves, a partial but valid index should be kept — don't
3225
- // force a full re-index just because the previous session didn't finish.
3226
- if (existsSync(config.dbPath)) {
3227
- if (!(await checkDbFormat())) {
3228
- logToFile('WARN', 'Database format incompatible — scheduling background re-index');
3229
- startBackgroundReindex();
3230
- } else {
3231
- logToFile('INFO', 'Existing database is compatible — reusing index');
3232
- }
3233
- } else if (config.magentoRoot && existsSync(config.magentoRoot)) {
3234
- // No DB file at all — need initial index
3235
- logToFile('INFO', 'No index database found — scheduling background index');
3236
- startBackgroundReindex();
3237
- }
3372
+ // Don't kill existing serve processes other MCP instances may be using them.
3373
+ // Each instance starts its own serve process; cleanup happens on exit.
3238
3374
 
3239
- // Start persistent Rust serve process for fast queries.
3240
- // During reindex, start if old DB exists so searches keep working.
3241
- const canStartServe = !reindexInProgress || (existsSync(config.dbPath) && (() => { try { return statSync(config.dbPath).size > 100; } catch { return false; } })());
3242
- if (canStartServe) {
3243
- try {
3244
- startServeProcess();
3245
- if (serveReadyPromise) {
3246
- const ready = await Promise.race([
3247
- serveReadyPromise,
3248
- new Promise(r => setTimeout(() => r(false), 15000))
3249
- ]);
3250
- if (ready) {
3251
- logToFile('INFO', 'Serve process ready (persistent mode)');
3252
- console.error('Serve process ready (persistent mode)');
3375
+ // Connect MCP transport FIRST so tools can return "warming up" messages
3376
+ // instead of the client hanging during index load.
3377
+ const transport = new StdioServerTransport();
3378
+ await server.connect(transport);
3379
+ logToFile('INFO', 'Magector MCP server connected (warming up...)');
3380
+ console.error('Magector MCP server connected (warming up...)');
3381
+
3382
+ // ── Singleton serve: try connecting to existing instance first ──
3383
+ try {
3384
+ // Try to connect to an existing serve process via Unix socket
3385
+ const connected = await tryConnectSocket();
3386
+ if (connected) {
3387
+ logToFile('INFO', 'Joined existing serve process (singleton mode)');
3388
+ console.error('Joined existing serve process (singleton mode)');
3389
+ } else {
3390
+ // We are the primary instance — check DB format and start serve
3391
+ if (existsSync(config.dbPath)) {
3392
+ if (!(await checkDbFormat())) {
3393
+ logToFile('WARN', 'Database format incompatible — scheduling background re-index');
3394
+ startBackgroundReindex();
3253
3395
  } else {
3254
- logToFile('WARN', 'Serve process not ready in time, will use fallback');
3255
- console.error('Serve process not ready in time, will use fallback');
3396
+ logToFile('INFO', 'Existing database is compatible reusing index');
3256
3397
  }
3398
+ } else if (config.magentoRoot && existsSync(config.magentoRoot)) {
3399
+ logToFile('INFO', 'No index database found — scheduling background index');
3400
+ startBackgroundReindex();
3257
3401
  }
3258
- } catch {
3259
- // Non-fatal: falls back to execFileSync per query
3260
- }
3261
- }
3262
3402
 
3263
- // Load LLM descriptions (after serve process is ready for SQLite access)
3264
- await loadDescriptions();
3403
+ const canStartServe = !reindexInProgress || (existsSync(config.dbPath) && (() => { try { return statSync(config.dbPath).size > 100; } catch { return false; } })());
3404
+ if (canStartServe) {
3405
+ try {
3406
+ startServeProcess();
3407
+ if (serveReadyPromise) {
3408
+ const ready = await Promise.race([
3409
+ serveReadyPromise,
3410
+ new Promise(r => setTimeout(() => r(false), 15000))
3411
+ ]);
3412
+ if (ready) {
3413
+ logToFile('INFO', 'Serve process ready (primary instance)');
3414
+ console.error('Serve process ready (primary instance)');
3415
+ // Start socket proxy so other instances can connect
3416
+ startSocketProxy();
3417
+ } else {
3418
+ logToFile('WARN', 'Serve process not ready in time, will use fallback');
3419
+ console.error('Serve process not ready in time, will use fallback');
3420
+ }
3421
+ }
3422
+ } catch {
3423
+ // Non-fatal: falls back to execFileSync per query
3424
+ }
3425
+ }
3426
+ }
3265
3427
 
3266
- const transport = new StdioServerTransport();
3267
- await server.connect(transport);
3268
- logToFile('INFO', 'Magector MCP server started (Rust core backend)');
3269
- console.error('Magector MCP server started (Rust core backend)');
3428
+ await loadDescriptions();
3429
+ } finally {
3430
+ warmupInProgress = false;
3431
+ logToFile('INFO', 'Warmup complete all tools available');
3432
+ console.error('Warmup complete — all tools available');
3433
+ }
3270
3434
  }
3271
3435
 
3272
3436
  // Cleanup on exit — kill all child processes and remove PID file
@@ -3282,6 +3446,11 @@ function cleanup(reason) {
3282
3446
  try { reindexProcess.kill(); } catch {}
3283
3447
  reindexProcess = null;
3284
3448
  }
3449
+ if (socketServer) {
3450
+ try { socketServer.close(); } catch {}
3451
+ try { if (existsSync(SOCK_PATH)) unlinkSync(SOCK_PATH); } catch {}
3452
+ socketServer = null;
3453
+ }
3285
3454
  removePidFile();
3286
3455
  removeReindexPidFile();
3287
3456
  }