@softerist/heuristic-mcp 3.0.17 → 3.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.
package/index.js CHANGED
@@ -7,11 +7,18 @@ import {
7
7
  ListToolsRequestSchema,
8
8
  ListResourcesRequestSchema,
9
9
  ReadResourceRequestSchema,
10
+ RootsListChangedNotificationSchema,
10
11
  } from '@modelcontextprotocol/sdk/types.js';
11
12
  let transformersModule = null;
12
13
  async function getTransformers() {
13
14
  if (!transformersModule) {
14
15
  transformersModule = await import('@huggingface/transformers');
16
+ if (transformersModule?.env) {
17
+ transformersModule.env.cacheDir = path.join(getGlobalCacheDir(), 'xenova');
18
+ }
19
+ if (transformersModule?.env) {
20
+ transformersModule.env.cacheDir = path.join(getGlobalCacheDir(), 'xenova');
21
+ }
15
22
  }
16
23
  return transformersModule;
17
24
  }
@@ -22,23 +29,25 @@ import path from 'path';
22
29
  import os from 'os';
23
30
 
24
31
  import { createRequire } from 'module';
25
- import { fileURLToPath } from 'url';
32
+ import { fileURLToPath } from 'url';
33
+ import { getWorkspaceCachePath } from './lib/workspace-cache-key.js';
34
+
26
35
 
27
- // Import package.json for version
28
36
  const require = createRequire(import.meta.url);
29
37
  const packageJson = require('./package.json');
30
38
 
31
- import { loadConfig, getGlobalCacheDir } from './lib/config.js';
32
- import { clearStaleCaches } from './lib/cache-utils.js';
33
- import { enableStderrOnlyLogging, setupFileLogging, getLogFilePath } from './lib/logging.js';
34
- import { parseArgs, printHelp } from './lib/cli.js';
35
- import { clearCache } from './lib/cache-ops.js';
36
- import { logMemory, startMemoryLogger } from './lib/memory-logger.js';
37
- import {
38
- registerSignalHandlers,
39
- setupPidFile,
40
- acquireWorkspaceLock,
41
- } from './lib/server-lifecycle.js';
39
+ import { loadConfig, getGlobalCacheDir, isNonProjectDirectory } from './lib/config.js';
40
+ import { clearStaleCaches } from './lib/cache-utils.js';
41
+ import { enableStderrOnlyLogging, setupFileLogging, getLogFilePath, flushLogs } from './lib/logging.js';
42
+ import { parseArgs, printHelp } from './lib/cli.js';
43
+ import { clearCache } from './lib/cache-ops.js';
44
+ import { logMemory, startMemoryLogger } from './lib/memory-logger.js';
45
+ import {
46
+ registerSignalHandlers,
47
+ setupPidFile,
48
+ acquireWorkspaceLock,
49
+ stopOtherHeuristicServers,
50
+ } from './lib/server-lifecycle.js';
42
51
 
43
52
  import { EmbeddingsCache } from './lib/cache.js';
44
53
  import { CodebaseIndexer } from './features/index-codebase.js';
@@ -116,17 +125,115 @@ async function printMemorySnapshot(workspaceDir) {
116
125
  return true;
117
126
  }
118
127
 
119
- // Arguments parsed in main()
120
128
 
121
- // Global state
122
- let embedder = null;
123
- let unloadMainEmbedder = null; // Function to unload the embedding model
124
- let cache = null;
125
- let indexer = null;
126
- let hybridSearch = null;
127
- let config = null;
128
- let setWorkspaceFeatureInstance = null;
129
- let autoWorkspaceSwitchPromise = null;
129
+
130
+
131
+ let embedder = null;
132
+ let unloadMainEmbedder = null;
133
+ let cache = null;
134
+ let indexer = null;
135
+ let hybridSearch = null;
136
+ let config = null;
137
+ let workspaceLockAcquired = true;
138
+ let configReadyResolve = null;
139
+ let configInitError = null;
140
+ let configReadyPromise = new Promise((resolve) => {
141
+ configReadyResolve = resolve;
142
+ });
143
+ let setWorkspaceFeatureInstance = null;
144
+ let autoWorkspaceSwitchPromise = null;
145
+ let rootsCapabilitySupported = null;
146
+ let rootsProbeInFlight = null;
147
+ const WORKSPACE_BOUND_TOOL_NAMES = new Set([
148
+ 'a_semantic_search',
149
+ 'b_index_codebase',
150
+ 'c_clear_cache',
151
+ 'd_find_similar_code',
152
+ 'd_ann_config',
153
+ ]);
154
+ const trustedWorkspacePaths = new Set();
155
+
156
+ function shouldRequireTrustedWorkspaceSignalForTool(toolName) {
157
+ return WORKSPACE_BOUND_TOOL_NAMES.has(toolName);
158
+ }
159
+
160
+ function trustWorkspacePath(workspacePath) {
161
+ const normalized = normalizePathForCompare(workspacePath);
162
+ if (normalized) {
163
+ trustedWorkspacePaths.add(normalized);
164
+ }
165
+ }
166
+
167
+ function isCurrentWorkspaceTrusted() {
168
+ if (!config?.searchDirectory) return false;
169
+ return trustedWorkspacePaths.has(normalizePathForCompare(config.searchDirectory));
170
+ }
171
+
172
+ function isToolResponseError(result) {
173
+ if (!result || typeof result !== 'object') return true;
174
+ if (result.isError === true) return true;
175
+ if (!Array.isArray(result.content)) return false;
176
+
177
+ return result.content.some(
178
+ (entry) =>
179
+ entry?.type === 'text' &&
180
+ typeof entry.text === 'string' &&
181
+ entry.text.trim().toLowerCase().startsWith('error:')
182
+ );
183
+ }
184
+
185
+ function formatCrashDetail(detail) {
186
+ if (detail instanceof Error) {
187
+ return detail.stack || detail.message || String(detail);
188
+ }
189
+ if (typeof detail === 'string') {
190
+ return detail;
191
+ }
192
+ try {
193
+ return JSON.stringify(detail);
194
+ } catch {
195
+ return String(detail);
196
+ }
197
+ }
198
+
199
+ function isCrashShutdownReason(reason) {
200
+ const normalized = String(reason || '').toLowerCase();
201
+ return normalized.includes('uncaughtexception') || normalized.includes('unhandledrejection');
202
+ }
203
+
204
+ function registerProcessDiagnostics({ isServerMode, requestShutdown, getShutdownReason }) {
205
+ if (!isServerMode) return;
206
+
207
+ process.on('beforeExit', (code) => {
208
+ const reason = getShutdownReason() || 'natural';
209
+ console.info(`[Server] Process beforeExit (code=${code}, reason=${reason}).`);
210
+ });
211
+
212
+ process.on('exit', (code) => {
213
+ const reason = getShutdownReason() || 'natural';
214
+ console.info(`[Server] Process exit (code=${code}, reason=${reason}).`);
215
+ });
216
+
217
+ let fatalHandled = false;
218
+ const handleFatalError = (reason, detail) => {
219
+ if (fatalHandled) return;
220
+ fatalHandled = true;
221
+ console.error(`[Server] Fatal ${reason}: ${formatCrashDetail(detail)}`);
222
+ requestShutdown(reason);
223
+ const forceExitTimer = setTimeout(() => {
224
+ console.error(`[Server] Forced exit after fatal ${reason}.`);
225
+ process.exit(1);
226
+ }, 5000);
227
+ forceExitTimer.unref?.();
228
+ };
229
+
230
+ process.on('uncaughtException', (err) => {
231
+ handleFatalError('uncaughtException', err);
232
+ });
233
+ process.on('unhandledRejection', (reason) => {
234
+ handleFatalError('unhandledRejection', reason);
235
+ });
236
+ }
130
237
 
131
238
  async function resolveWorkspaceFromEnvValue(rawValue) {
132
239
  if (!rawValue || rawValue.includes('${')) return null;
@@ -140,56 +247,272 @@ async function resolveWorkspaceFromEnvValue(rawValue) {
140
247
  }
141
248
  }
142
249
 
143
- async function detectRuntimeWorkspaceFromEnv() {
144
- for (const key of getWorkspaceEnvKeys()) {
145
- const workspacePath = await resolveWorkspaceFromEnvValue(process.env[key]);
250
+ async function detectRuntimeWorkspaceFromEnv() {
251
+ for (const key of getWorkspaceEnvKeys()) {
252
+ const workspacePath = await resolveWorkspaceFromEnvValue(process.env[key]);
146
253
  if (workspacePath) {
147
254
  return { workspacePath, envKey: key };
148
255
  }
149
256
  }
150
257
 
151
- return null;
152
- }
153
-
154
- async function maybeAutoSwitchWorkspace(request) {
155
- if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return;
156
- if (!setWorkspaceFeatureInstance || !config?.searchDirectory) return;
157
- if (request?.params?.name === 'f_set_workspace') return;
258
+ return null;
259
+ }
260
+
261
+ function normalizePathForCompare(targetPath) {
262
+ if (!targetPath) return '';
263
+ const resolved = path.resolve(targetPath);
264
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
265
+ }
266
+
267
+ function isProcessAlive(pid) {
268
+ if (!Number.isInteger(pid) || pid <= 0) return false;
269
+ try {
270
+ process.kill(pid, 0);
271
+ return true;
272
+ } catch (err) {
273
+ return err?.code === 'EPERM';
274
+ }
275
+ }
276
+
277
+ async function findAutoAttachWorkspaceCandidate({ excludeCacheDirectory = null } = {}) {
278
+ const cacheRoot = path.join(getGlobalCacheDir(), 'heuristic-mcp');
279
+ const normalizedExclude = normalizePathForCompare(excludeCacheDirectory);
280
+
281
+ let cacheDirs = [];
282
+ try {
283
+ cacheDirs = await fs.readdir(cacheRoot, { withFileTypes: true });
284
+ } catch {
285
+ return null;
286
+ }
287
+
288
+ const candidatesByWorkspace = new Map();
289
+ const preferredWorkspaceFromEnv = (await detectRuntimeWorkspaceFromEnv())?.workspacePath ?? null;
290
+ const normalizedPreferred = normalizePathForCompare(preferredWorkspaceFromEnv);
291
+
292
+ const upsertCandidate = (candidate) => {
293
+ const key = normalizePathForCompare(candidate.workspace);
294
+ const existing = candidatesByWorkspace.get(key);
295
+ if (!existing || candidate.rank > existing.rank) {
296
+ candidatesByWorkspace.set(key, candidate);
297
+ }
298
+ };
299
+
300
+ for (const entry of cacheDirs) {
301
+ if (!entry.isDirectory()) continue;
302
+ const cacheDirectory = path.join(cacheRoot, entry.name);
303
+ if (normalizedExclude && normalizePathForCompare(cacheDirectory) === normalizedExclude) continue;
304
+
305
+ const lockPath = path.join(cacheDirectory, 'server.lock.json');
306
+ try {
307
+ const rawLock = await fs.readFile(lockPath, 'utf-8');
308
+ const lock = JSON.parse(rawLock);
309
+ if (!isProcessAlive(lock?.pid)) continue;
310
+ const workspace = path.resolve(lock?.workspace || '');
311
+ if (!workspace || isNonProjectDirectory(workspace)) continue;
312
+ const stats = await fs.stat(workspace).catch(() => null);
313
+ if (!stats?.isDirectory()) continue;
314
+ const rank = Date.parse(lock?.startedAt || '') || 0;
315
+ upsertCandidate({
316
+ workspace,
317
+ cacheDirectory,
318
+ source: `lock:${lock.pid}`,
319
+ rank,
320
+ });
321
+ continue;
322
+ } catch {
323
+
324
+ }
325
+
326
+ const metaPath = path.join(cacheDirectory, 'meta.json');
327
+ try {
328
+ const rawMeta = await fs.readFile(metaPath, 'utf-8');
329
+ const meta = JSON.parse(rawMeta);
330
+ const workspace = path.resolve(meta?.workspace || '');
331
+ if (!workspace || isNonProjectDirectory(workspace)) continue;
332
+ const stats = await fs.stat(workspace).catch(() => null);
333
+ if (!stats?.isDirectory()) continue;
334
+ const filesIndexed = Number(meta?.filesIndexed || 0);
335
+ if (filesIndexed <= 0) continue;
336
+ const rank = Date.parse(meta?.lastSaveTime || '') || 0;
337
+ upsertCandidate({
338
+ workspace,
339
+ cacheDirectory,
340
+ source: 'meta',
341
+ rank,
342
+ });
343
+ } catch {
344
+
345
+ }
346
+ }
347
+
348
+ const candidates = Array.from(candidatesByWorkspace.values());
349
+ if (candidates.length === 0) return null;
350
+ if (normalizedPreferred) {
351
+ const preferred = candidates.find(
352
+ (candidate) => normalizePathForCompare(candidate.workspace) === normalizedPreferred
353
+ );
354
+ if (preferred) return preferred;
355
+ }
356
+ if (candidates.length === 1) return candidates[0];
357
+ return null;
358
+ }
359
+
360
+ async function maybeAutoSwitchWorkspace(request) {
361
+ if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return null;
362
+ if (!setWorkspaceFeatureInstance || !config?.searchDirectory) return null;
363
+ if (request?.params?.name === 'f_set_workspace') return null;
364
+
365
+ const detected = await detectRuntimeWorkspaceFromEnv();
366
+ if (!detected) return null;
367
+ if (isNonProjectDirectory(detected.workspacePath)) {
368
+ console.info(
369
+ `[Server] Ignoring auto-switch candidate from env ${detected.envKey}: non-project path ${detected.workspacePath}`
370
+ );
371
+ return null;
372
+ }
373
+
374
+ const currentWorkspace = normalizePathForCompare(config.searchDirectory);
375
+ const detectedWorkspace = normalizePathForCompare(detected.workspacePath);
376
+ if (detectedWorkspace === currentWorkspace) return detected.workspacePath;
377
+
378
+ await maybeAutoSwitchWorkspaceToPath(detected.workspacePath, {
379
+ source: `env ${detected.envKey}`,
380
+ reindex: false,
381
+ });
382
+ return detected.workspacePath;
383
+ }
384
+
385
+
386
+
387
+ async function detectWorkspaceFromRoots({ quiet = false } = {}) {
388
+ try {
389
+ const caps = server.getClientCapabilities();
390
+ if (!caps?.roots) {
391
+ rootsCapabilitySupported = false;
392
+ if (!quiet) {
393
+ console.info(
394
+ '[Server] Client does not support roots capability, skipping workspace auto-detection.'
395
+ );
396
+ }
397
+ return null;
398
+ }
399
+ rootsCapabilitySupported = true;
400
+
401
+ const result = await server.listRoots();
402
+ if (!result?.roots?.length) {
403
+ if (!quiet) {
404
+ console.info('[Server] Client returned no roots.');
405
+ }
406
+ return null;
407
+ }
408
+
409
+ if (!quiet) {
410
+ console.info(`[Server] MCP roots received: ${result.roots.map(r => r.uri).join(', ')}`);
411
+ }
412
+
413
+
414
+ const rootPaths = result.roots
415
+ .map(r => r.uri)
416
+ .filter(uri => uri.startsWith('file://'))
417
+ .map(uri => {
418
+ try {
419
+ return fileURLToPath(uri);
420
+ } catch {
421
+ return null;
422
+ }
423
+ })
424
+ .filter(Boolean);
425
+
426
+ if (rootPaths.length === 0) {
427
+ if (!quiet) {
428
+ console.info('[Server] No valid file:// roots found.');
429
+ }
430
+ return null;
431
+ }
432
+
433
+ return path.resolve(rootPaths[0]);
434
+ } catch (err) {
435
+ if (!quiet) {
436
+ console.warn(`[Server] MCP roots detection failed (non-fatal): ${err.message}`);
437
+ }
438
+ return null;
439
+ }
440
+ }
441
+
442
+ async function maybeAutoSwitchWorkspaceToPath(targetWorkspacePath, { source, reindex = false } = {}) {
443
+ if (!setWorkspaceFeatureInstance || !config?.searchDirectory) return;
444
+ if (!targetWorkspacePath) return;
445
+ if (isNonProjectDirectory(targetWorkspacePath)) {
446
+ if (config?.verbose) {
447
+ console.info(
448
+ `[Server] Ignoring auto-switch candidate from ${source || 'unknown'}: non-project path ${targetWorkspacePath}`
449
+ );
450
+ }
451
+ return;
452
+ }
453
+
454
+ const currentWorkspace = normalizePathForCompare(config.searchDirectory);
455
+ const targetWorkspace = normalizePathForCompare(targetWorkspacePath);
456
+ if (targetWorkspace === currentWorkspace) return;
457
+
458
+ if (autoWorkspaceSwitchPromise) {
459
+ await autoWorkspaceSwitchPromise;
460
+ return;
461
+ }
462
+
463
+ autoWorkspaceSwitchPromise = (async () => {
464
+ console.info(
465
+ `[Server] Auto-switching workspace from ${currentWorkspace} to ${targetWorkspacePath} (${source || 'auto'})`
466
+ );
467
+ const result = await setWorkspaceFeatureInstance.execute({
468
+ workspacePath: targetWorkspacePath,
469
+ reindex,
470
+ });
471
+ if (!result.success) {
472
+ console.warn(
473
+ `[Server] Auto workspace switch failed (${source || 'auto'}): ${result.error}`
474
+ );
475
+ return;
476
+ }
477
+ trustWorkspacePath(targetWorkspacePath);
478
+ })();
479
+
480
+ try {
481
+ await autoWorkspaceSwitchPromise;
482
+ } finally {
483
+ autoWorkspaceSwitchPromise = null;
484
+ }
485
+ }
486
+
487
+ async function maybeAutoSwitchWorkspaceFromRoots(request) {
488
+ if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return null;
489
+ if (!setWorkspaceFeatureInstance || !config?.searchDirectory) return null;
490
+ if (request?.params?.name === 'f_set_workspace') return null;
491
+ if (rootsCapabilitySupported === false) return null;
492
+
493
+ if (rootsProbeInFlight) {
494
+ return await rootsProbeInFlight;
495
+ }
496
+
497
+ rootsProbeInFlight = (async () => {
498
+ const rootWorkspace = await detectWorkspaceFromRoots({ quiet: true });
499
+ if (!rootWorkspace) return null;
500
+ await maybeAutoSwitchWorkspaceToPath(rootWorkspace, {
501
+ source: 'roots probe',
502
+ reindex: false,
503
+ });
504
+ return rootWorkspace;
505
+ })();
506
+
507
+ try {
508
+ return await rootsProbeInFlight;
509
+ } finally {
510
+ rootsProbeInFlight = null;
511
+ }
512
+ }
158
513
 
159
- const detected = await detectRuntimeWorkspaceFromEnv();
160
- if (!detected) return;
161
-
162
- const currentWorkspace = path.resolve(config.searchDirectory);
163
- if (detected.workspacePath === currentWorkspace) return;
164
-
165
- if (autoWorkspaceSwitchPromise) {
166
- await autoWorkspaceSwitchPromise;
167
- return;
168
- }
169
-
170
- autoWorkspaceSwitchPromise = (async () => {
171
- console.info(
172
- `[Server] Auto-switching workspace from ${currentWorkspace} to ${detected.workspacePath} (env ${detected.envKey})`
173
- );
174
- const result = await setWorkspaceFeatureInstance.execute({
175
- workspacePath: detected.workspacePath,
176
- reindex: false,
177
- });
178
- if (!result.success) {
179
- console.warn(
180
- `[Server] Auto workspace switch failed (env ${detected.envKey}): ${result.error}`
181
- );
182
- }
183
- })();
184
514
 
185
- try {
186
- await autoWorkspaceSwitchPromise;
187
- } finally {
188
- autoWorkspaceSwitchPromise = null;
189
- }
190
- }
191
515
 
192
- // Feature registry - ordered by priority (semantic_search first as primary tool)
193
516
  const features = [
194
517
  {
195
518
  module: HybridSearchFeature,
@@ -221,19 +544,22 @@ const features = [
221
544
  instance: null,
222
545
  handler: PackageVersionFeature.handleToolCall,
223
546
  },
224
- {
225
- module: SetWorkspaceFeature,
226
- instance: null,
227
- handler: null, // Late-bound after initialization
228
- },
229
- ];
547
+ {
548
+ module: SetWorkspaceFeature,
549
+ instance: null,
550
+ handler: null,
551
+ },
552
+ ];
553
+
230
554
 
231
- // Initialize application
232
- async function initialize(workspaceDir) {
233
- // Load configuration with workspace support
555
+
556
+ async function initialize(workspaceDir) {
557
+
558
+
234
559
  config = await loadConfig(workspaceDir);
235
560
 
236
- // Automatic cache cleanup on startup (Option A)
561
+
562
+
237
563
  if (config.enableCache && config.cacheCleanup?.autoCleanup) {
238
564
  console.info('[Server] Running automatic cache cleanup...');
239
565
  const results = await clearStaleCaches({
@@ -245,7 +571,8 @@ async function initialize(workspaceDir) {
245
571
  }
246
572
  }
247
573
 
248
- // Skip gc check during tests (VITEST env is set)
574
+
575
+
249
576
  const isTest = Boolean(process.env.VITEST || process.env.VITEST_WORKER_ID);
250
577
  if (config.enableExplicitGc && typeof global.gc !== 'function' && !isTest) {
251
578
  console.warn(
@@ -280,7 +607,8 @@ async function initialize(workspaceDir) {
280
607
  env.backends.onnx.wasm.numThreads = ONNX_THREAD_LIMIT;
281
608
  }
282
609
  } catch {
283
- // ignore: fallback tuning is best effort
610
+
611
+
284
612
  }
285
613
  const status = getNativeOnnxStatus();
286
614
  const reason = status?.message || 'onnxruntime-node not available';
@@ -295,27 +623,101 @@ async function initialize(workspaceDir) {
295
623
  config.embeddingProcessPerBatch = true;
296
624
  }
297
625
  }
298
- const lock = await acquireWorkspaceLock({
299
- cacheDirectory: config.cacheDirectory,
300
- workspaceDir: config.searchDirectory,
301
- });
302
- if (!lock.acquired) {
303
- console.warn(
304
- `[Server] Another heuristic-mcp instance is already running for this workspace (pid ${lock.ownerPid ?? 'unknown'}).`
305
- );
306
- console.warn('[Server] Exiting to avoid duplicate model loads.');
307
- process.exit(0);
308
- }
309
- const [pidPath, logPath] = await Promise.all([
310
- setupPidFile({ pidFileName: PID_FILE_NAME, cacheDirectory: config.cacheDirectory }),
311
- setupFileLogging(config),
312
- ]);
626
+ const resolutionSource = config.workspaceResolution?.source || 'unknown';
627
+ if (resolutionSource === 'workspace-arg' || resolutionSource === 'env') {
628
+ trustWorkspacePath(config.searchDirectory);
629
+ }
630
+ const isSystemFallbackWorkspace =
631
+ (resolutionSource === 'cwd' || resolutionSource === 'cwd-root-search') &&
632
+ isNonProjectDirectory(config.searchDirectory);
633
+
634
+ let pidPath = null;
635
+ let logPath = null;
636
+ if (isSystemFallbackWorkspace) {
637
+ workspaceLockAcquired = false;
638
+ console.warn(
639
+ `[Server] System fallback workspace detected (${config.searchDirectory}); running in lightweight read-only mode.`
640
+ );
641
+ console.warn('[Server] Skipping lock/PID/log file setup for fallback workspace.');
642
+ } else {
643
+ if (config.autoStopOtherServersOnStartup !== false) {
644
+ const globalCacheRoot = path.join(getGlobalCacheDir(), 'heuristic-mcp');
645
+ const { killed, failed } = await stopOtherHeuristicServers({
646
+ globalCacheRoot,
647
+ currentCacheDirectory: config.cacheDirectory,
648
+ });
649
+ if (killed.length > 0) {
650
+ const details = killed
651
+ .map((entry) => `${entry.pid}${entry.workspace ? ` (${entry.workspace})` : ''}`)
652
+ .join(', ');
653
+ console.info(`[Server] Auto-stopped ${killed.length} stale heuristic-mcp server(s): ${details}`);
654
+ }
655
+ if (failed.length > 0) {
656
+ const details = failed
657
+ .map((entry) => `${entry.pid}${entry.workspace ? ` (${entry.workspace})` : ''}`)
658
+ .join(', ');
659
+ console.warn(
660
+ `[Server] Failed to stop ${failed.length} older heuristic-mcp server(s): ${details}`
661
+ );
662
+ }
663
+ }
664
+
665
+ const lock = await acquireWorkspaceLock({
666
+ cacheDirectory: config.cacheDirectory,
667
+ workspaceDir: config.searchDirectory,
668
+ });
669
+ workspaceLockAcquired = lock.acquired;
670
+ if (!workspaceLockAcquired) {
671
+ console.warn(
672
+ `[Server] Another heuristic-mcp instance is already running for this workspace (pid ${lock.ownerPid ?? 'unknown'}).`
673
+ );
674
+ console.warn(
675
+ '[Server] Starting in secondary read-only mode: background indexing and cache writes are disabled for this instance.'
676
+ );
677
+ }
678
+ [pidPath, logPath] = workspaceLockAcquired
679
+ ? await Promise.all([
680
+ setupPidFile({ pidFileName: PID_FILE_NAME, cacheDirectory: config.cacheDirectory }),
681
+ setupFileLogging(config),
682
+ ])
683
+ : [null, await setupFileLogging(config)];
684
+ }
313
685
  if (logPath) {
314
686
  console.info(`[Logs] Writing server logs to ${logPath}`);
315
687
  console.info(`[Logs] Log viewer: heuristic-mcp --logs --workspace "${config.searchDirectory}"`);
316
688
  }
689
+ {
690
+ const resolution = config.workspaceResolution || {};
691
+ const sourceLabel =
692
+ resolution.source === 'env' && resolution.envKey
693
+ ? `env:${resolution.envKey}`
694
+ : resolution.source || 'unknown';
695
+ const baseLabel = resolution.baseDirectory || '(unknown)';
696
+ const searchLabel = resolution.searchDirectory || config.searchDirectory;
697
+ const overrideLabel = resolution.searchDirectoryFromConfig ? 'yes' : 'no';
698
+ console.info(
699
+ `[Server] Workspace resolved: source=${sourceLabel}, base=${baseLabel}, search=${searchLabel}, configOverride=${overrideLabel}`
700
+ );
701
+ if (resolution.fromPath) {
702
+ console.info(`[Server] Workspace resolution origin cwd: ${resolution.fromPath}`);
703
+ }
704
+
705
+ const workspaceEnvProbe = Array.isArray(resolution.workspaceEnvProbe)
706
+ ? resolution.workspaceEnvProbe
707
+ : [];
708
+ if (workspaceEnvProbe.length > 0) {
709
+ const probePreview = workspaceEnvProbe.slice(0, 8).map((entry) => {
710
+ const scope = entry?.priority ? 'priority' : 'diagnostic';
711
+ const status = entry?.resolvedPath ? `valid:${entry.resolvedPath}` : `invalid:${entry?.value}`;
712
+ return `${entry?.key}[${scope}]=${status}`;
713
+ });
714
+ const suffix = workspaceEnvProbe.length > 8 ? ` (+${workspaceEnvProbe.length - 8} more)` : '';
715
+ console.info(`[Server] Workspace env probe: ${probePreview.join('; ')}${suffix}`);
716
+ }
717
+ }
317
718
 
318
- // Log effective configuration for debugging
719
+
720
+
319
721
  console.info(
320
722
  `[Server] Config: workerThreads=${config.workerThreads}, embeddingProcessPerBatch=${config.embeddingProcessPerBatch}`
321
723
  );
@@ -327,14 +729,17 @@ async function initialize(workspaceDir) {
327
729
  console.info(`[Server] PID file: ${pidPath}`);
328
730
  }
329
731
 
330
- // Log cache directory logic for debugging
732
+
733
+
331
734
  try {
332
735
  const globalCache = path.join(getGlobalCacheDir(), 'heuristic-mcp');
333
736
  const localCache = path.join(process.cwd(), '.heuristic-mcp');
334
737
  console.info(`[Server] Cache debug: Global=${globalCache}, Local=${localCache}`);
335
738
  console.info(`[Server] Process CWD: ${process.cwd()}`);
739
+ console.info(`[Server] Resolved workspace: ${config.searchDirectory} (via ${config.workspaceResolution?.source || 'unknown'})`);
336
740
  } catch (_e) {
337
- /* ignore */
741
+
742
+
338
743
  }
339
744
 
340
745
  let stopStartupMemory = null;
@@ -343,7 +748,8 @@ async function initialize(workspaceDir) {
343
748
  stopStartupMemory = startMemoryLogger('[Server] Memory (startup)', MEMORY_LOG_INTERVAL_MS);
344
749
  }
345
750
 
346
- // Ensure search directory exists
751
+
752
+
347
753
  try {
348
754
  await fs.access(config.searchDirectory);
349
755
  } catch {
@@ -351,7 +757,8 @@ async function initialize(workspaceDir) {
351
757
  process.exit(1);
352
758
  }
353
759
 
354
- // Create a transparent lazy-loading embedder closure
760
+
761
+
355
762
  console.info('[Server] Initializing features...');
356
763
  let cachedEmbedderPromise = null;
357
764
  const lazyEmbedder = async (...args) => {
@@ -384,7 +791,7 @@ async function initialize(workspaceDir) {
384
791
  return model(...args);
385
792
  };
386
793
 
387
- // Unload the main process embedding model to free memory
794
+
388
795
  const unloader = async () => {
389
796
  if (!cachedEmbedderPromise) return false;
390
797
  try {
@@ -409,7 +816,7 @@ async function initialize(workspaceDir) {
409
816
  };
410
817
 
411
818
  embedder = lazyEmbedder;
412
- unloadMainEmbedder = unloader; // Store in module scope for tool handler access
819
+ unloadMainEmbedder = unloader;
413
820
  const preloadEmbeddingModel = async () => {
414
821
  if (config.preloadEmbeddingModel === false) return;
415
822
  try {
@@ -420,29 +827,29 @@ async function initialize(workspaceDir) {
420
827
  }
421
828
  };
422
829
 
423
- // NOTE: We no longer auto-load in verbose mode when preloadEmbeddingModel=false.
424
- // The model will be loaded lazily on first search or by child processes during indexing.
830
+
831
+
425
832
 
426
- // Initialize cache (load deferred until after server is ready)
833
+
427
834
  cache = new EmbeddingsCache(config);
428
835
  console.info(`[Server] Cache directory: ${config.cacheDirectory}`);
429
836
 
430
- // Initialize features
837
+
431
838
  indexer = new CodebaseIndexer(embedder, cache, config, server);
432
839
  hybridSearch = new HybridSearch(embedder, cache, config);
433
840
  const cacheClearer = new ClearCacheFeature.CacheClearer(embedder, cache, config, indexer);
434
841
  const findSimilarCode = new FindSimilarCodeFeature.FindSimilarCode(embedder, cache, config);
435
842
  const annConfig = new AnnConfigFeature.AnnConfigTool(cache, config);
436
843
 
437
- // Store feature instances (matches features array order)
844
+
438
845
  features[0].instance = hybridSearch;
439
846
  features[1].instance = indexer;
440
847
  features[2].instance = cacheClearer;
441
848
  features[3].instance = findSimilarCode;
442
849
  features[4].instance = annConfig;
443
- // Features 5 (PackageVersion) doesn't need instance
850
+
444
851
 
445
- // Initialize SetWorkspace feature with shared state
852
+
446
853
  const setWorkspaceInstance = new SetWorkspaceFeature.SetWorkspaceFeature(
447
854
  config,
448
855
  cache,
@@ -453,34 +860,99 @@ async function initialize(workspaceDir) {
453
860
  features[6].instance = setWorkspaceInstance;
454
861
  features[6].handler = SetWorkspaceFeature.createHandleToolCall(setWorkspaceInstance);
455
862
 
456
- // Attach hybridSearch to server for cross-feature access (e.g. cache invalidation)
457
- server.hybridSearch = hybridSearch;
458
-
459
- const startBackgroundTasks = async () => {
460
- // Keep startup responsive: do not block server readiness on model preload.
461
- void preloadEmbeddingModel();
462
-
863
+
864
+ server.hybridSearch = hybridSearch;
865
+
866
+ const startBackgroundTasks = async () => {
867
+ const stopStartupMemoryLogger = () => {
868
+ if (stopStartupMemory) {
869
+ stopStartupMemory();
870
+ }
871
+ };
872
+ const tryAutoAttachWorkspaceCache = async (reason) => {
873
+ const candidate = await findAutoAttachWorkspaceCandidate({
874
+ excludeCacheDirectory: config.cacheDirectory,
875
+ });
876
+ if (!candidate) {
877
+ console.warn(
878
+ `[Server] Auto-attach skipped (${reason}): no unambiguous workspace cache candidate found.`
879
+ );
880
+ return false;
881
+ }
882
+
883
+ config.searchDirectory = candidate.workspace;
884
+ config.cacheDirectory = candidate.cacheDirectory;
885
+ await fs.mkdir(config.cacheDirectory, { recursive: true });
886
+ await cache.load();
887
+ console.info(
888
+ `[Server] Auto-attached workspace cache (${reason}): ${candidate.workspace} via ${candidate.source}`
889
+ );
890
+ if (config.verbose) {
891
+ logMemory('[Server] Memory (after cache load)');
892
+ }
893
+ return true;
894
+ };
895
+
896
+ const resolutionSource = config.workspaceResolution?.source || 'unknown';
897
+ const isSystemFallback =
898
+ (resolutionSource === 'cwd' || resolutionSource === 'cwd-root-search') &&
899
+ isNonProjectDirectory(config.searchDirectory);
900
+
901
+ if (isSystemFallback) {
902
+ try {
903
+ console.warn(
904
+ `[Server] Detected system fallback workspace: ${config.searchDirectory}. Attempting cache auto-attach.`
905
+ );
906
+ const attached = await tryAutoAttachWorkspaceCache('system-fallback');
907
+ if (!attached) {
908
+ console.warn(
909
+ '[Server] Waiting for a proper workspace root (MCP roots, env vars, or f_set_workspace).'
910
+ );
911
+ }
912
+ } finally {
913
+ stopStartupMemoryLogger();
914
+ }
915
+ return;
916
+ }
917
+
918
+ if (!workspaceLockAcquired) {
919
+ try {
920
+ console.info('[Server] Secondary instance detected; loading cache in read-only mode.');
921
+ await cache.load();
922
+ if (cache.getStoreSize() === 0) {
923
+ await tryAutoAttachWorkspaceCache('secondary-empty-cache');
924
+ }
925
+ if (config.verbose) {
926
+ logMemory('[Server] Memory (after cache load)');
927
+ }
928
+ } finally {
929
+ stopStartupMemoryLogger();
930
+ }
931
+ console.info('[Server] Secondary instance ready; skipping background indexing.');
932
+ return;
933
+ }
934
+
935
+ void preloadEmbeddingModel();
936
+
463
937
  try {
464
938
  console.info('[Server] Loading cache (deferred)...');
465
- await cache.load();
466
- if (config.verbose) {
467
- logMemory('[Server] Memory (after cache load)');
468
- }
469
- } finally {
470
- if (stopStartupMemory) {
471
- stopStartupMemory();
472
- }
473
- }
474
-
475
- // Start indexing in background (non-blocking)
939
+ await cache.load();
940
+ if (config.verbose) {
941
+ logMemory('[Server] Memory (after cache load)');
942
+ }
943
+ } finally {
944
+ stopStartupMemoryLogger();
945
+ }
946
+
947
+
476
948
  console.info('[Server] Starting background indexing (delayed)...');
477
949
 
478
- // Slight delay to allow server to bind and accept first request if immediate
950
+
479
951
  setTimeout(() => {
480
952
  indexer
481
953
  .indexAll()
482
954
  .then(() => {
483
- // Only start file watcher if explicitly enabled in config
955
+
484
956
  if (config.watchFiles) {
485
957
  indexer.setupFileWatcher();
486
958
  }
@@ -494,7 +966,7 @@ async function initialize(workspaceDir) {
494
966
  return { startBackgroundTasks, config };
495
967
  }
496
968
 
497
- // Setup MCP server
969
+
498
970
  const server = new Server(
499
971
  {
500
972
  name: 'heuristic-mcp',
@@ -508,19 +980,46 @@ const server = new Server(
508
980
  }
509
981
  );
510
982
 
511
- // Handle resources/list
512
- server.setRequestHandler(ListResourcesRequestSchema, async () => {
513
- return await handleListResources(config);
514
- });
515
983
 
516
- // Handle resources/read
517
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
518
- return await handleReadResource(request.params.uri, config);
519
- });
984
+ server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
985
+ console.info('[Server] Received roots/list_changed notification from client.');
986
+ const newRoot = await detectWorkspaceFromRoots();
987
+ if (newRoot) {
988
+ await maybeAutoSwitchWorkspaceToPath(newRoot, {
989
+ source: 'roots changed',
990
+ reindex: true,
991
+ });
992
+ }
993
+ });
994
+
995
+
996
+
997
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
998
+ await configReadyPromise;
999
+ if (configInitError || !config) {
1000
+ throw configInitError ?? new Error('Server configuration is not initialized');
1001
+ }
1002
+ return await handleListResources(config);
1003
+ });
1004
+
1005
+
1006
+
1007
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1008
+ await configReadyPromise;
1009
+ if (configInitError || !config) {
1010
+ throw configInitError ?? new Error('Server configuration is not initialized');
1011
+ }
1012
+ return await handleReadResource(request.params.uri, config);
1013
+ });
520
1014
 
521
- // Register tools from all features
522
- server.setRequestHandler(ListToolsRequestSchema, async () => {
523
- const tools = [];
1015
+
1016
+
1017
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
1018
+ await configReadyPromise;
1019
+ if (configInitError || !config) {
1020
+ throw configInitError ?? new Error('Server configuration is not initialized');
1021
+ }
1022
+ const tools = [];
524
1023
 
525
1024
  for (const feature of features) {
526
1025
  const toolDef = feature.module.getToolDefinition(config);
@@ -530,15 +1029,142 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
530
1029
  return { tools };
531
1030
  });
532
1031
 
533
- // Handle tool calls
534
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
535
- await maybeAutoSwitchWorkspace(request);
536
1032
 
537
- for (const feature of features) {
1033
+
1034
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1035
+ await configReadyPromise;
1036
+ if (configInitError || !config) {
1037
+ return {
1038
+ content: [
1039
+ {
1040
+ type: 'text',
1041
+ text: `Server initialization failed: ${configInitError?.message || 'configuration not available'}`,
1042
+ },
1043
+ ],
1044
+ isError: true,
1045
+ };
1046
+ }
1047
+
1048
+ if (!workspaceLockAcquired && request.params?.name === 'f_set_workspace') {
1049
+ const args = request.params?.arguments || {};
1050
+ const workspacePath = args.workspacePath;
1051
+ const reindex = args.reindex !== false;
1052
+ if (typeof workspacePath !== 'string' || workspacePath.trim().length === 0) {
1053
+ return {
1054
+ content: [{ type: 'text', text: 'Error: workspacePath is required.' }],
1055
+ isError: true,
1056
+ };
1057
+ }
1058
+ if (reindex) {
1059
+ return {
1060
+ content: [
1061
+ {
1062
+ type: 'text',
1063
+ text: 'This server instance is in secondary read-only mode. Set reindex=false to attach cache only.',
1064
+ },
1065
+ ],
1066
+ isError: true,
1067
+ };
1068
+ }
1069
+ const normalizedPath = path.resolve(workspacePath);
1070
+ try {
1071
+ const stats = await fs.stat(normalizedPath);
1072
+ if (!stats.isDirectory()) {
1073
+ return {
1074
+ content: [{ type: 'text', text: `Error: Path is not a directory: ${normalizedPath}` }],
1075
+ isError: true,
1076
+ };
1077
+ }
1078
+ } catch (err) {
1079
+ return {
1080
+ content: [
1081
+ {
1082
+ type: 'text',
1083
+ text: `Error: Cannot access directory ${normalizedPath}: ${err.message}`,
1084
+ },
1085
+ ],
1086
+ isError: true,
1087
+ };
1088
+ }
1089
+
1090
+ config.searchDirectory = normalizedPath;
1091
+ config.cacheDirectory = getWorkspaceCachePath(normalizedPath, getGlobalCacheDir());
1092
+ try {
1093
+ await fs.mkdir(config.cacheDirectory, { recursive: true });
1094
+ await cache.load();
1095
+ trustWorkspacePath(normalizedPath);
1096
+ return {
1097
+ content: [
1098
+ {
1099
+ type: 'text',
1100
+ text: `Attached in read-only mode to workspace cache: ${normalizedPath}`,
1101
+ },
1102
+ ],
1103
+ };
1104
+ } catch (err) {
1105
+ return {
1106
+ content: [
1107
+ {
1108
+ type: 'text',
1109
+ text: `Error: Failed to attach cache for ${normalizedPath}: ${err.message}`,
1110
+ },
1111
+ ],
1112
+ isError: true,
1113
+ };
1114
+ }
1115
+ }
1116
+
1117
+ if (
1118
+ !workspaceLockAcquired &&
1119
+ ['b_index_codebase', 'c_clear_cache'].includes(request.params?.name)
1120
+ ) {
1121
+ return {
1122
+ content: [
1123
+ {
1124
+ type: 'text',
1125
+ text: 'This server instance is in secondary read-only mode. Use the primary instance for indexing/cache mutation tools.',
1126
+ },
1127
+ ],
1128
+ isError: true,
1129
+ };
1130
+ }
1131
+ const detectedFromRoots = await maybeAutoSwitchWorkspaceFromRoots(request);
1132
+ const detectedFromEnv = await maybeAutoSwitchWorkspace(request);
1133
+ if (detectedFromRoots) {
1134
+ trustWorkspacePath(detectedFromRoots);
1135
+ }
1136
+ if (detectedFromEnv) {
1137
+ trustWorkspacePath(detectedFromEnv);
1138
+ }
1139
+
1140
+ const toolName = request.params?.name;
1141
+ if (
1142
+ config.requireTrustedWorkspaceSignalForTools === true &&
1143
+ shouldRequireTrustedWorkspaceSignalForTool(toolName) &&
1144
+ !detectedFromRoots &&
1145
+ !detectedFromEnv &&
1146
+ !isCurrentWorkspaceTrusted()
1147
+ ) {
1148
+ return {
1149
+ content: [
1150
+ {
1151
+ type: 'text',
1152
+ text:
1153
+ `Workspace context appears stale for "${toolName}" (current: "${config.searchDirectory}"). ` +
1154
+ 'Please reload your IDE window and retry. ' +
1155
+ 'If needed, call MCP tool "f_set_workspace" from your chat/client with your opened folder path.',
1156
+ },
1157
+ ],
1158
+ isError: true,
1159
+ };
1160
+ }
1161
+
1162
+ for (const feature of features) {
538
1163
  const toolDef = feature.module.getToolDefinition(config);
539
1164
 
540
1165
  if (request.params.name === toolDef.name) {
541
- // Safety check: handler may be null if initialization is incomplete
1166
+
1167
+
542
1168
  if (typeof feature.handler !== 'function') {
543
1169
  return {
544
1170
  content: [{
@@ -547,14 +1173,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
547
1173
  }],
548
1174
  isError: true,
549
1175
  };
550
- }
551
- const result = await feature.handler(request, feature.instance);
1176
+ }
1177
+ const result = await feature.handler(request, feature.instance);
1178
+ if (toolDef.name === 'f_set_workspace' && !isToolResponseError(result)) {
1179
+ trustWorkspacePath(config.searchDirectory);
1180
+ }
1181
+
1182
+
1183
+
1184
+
552
1185
 
553
- // Unload embedding model after search-related tools to free memory
554
- // Tools that use embedder: a_semantic_search, d_find_similar_code
555
1186
  const searchTools = ['a_semantic_search', 'd_find_similar_code'];
556
1187
  if (config.unloadModelAfterSearch && searchTools.includes(toolDef.name)) {
557
- // Defer unload slightly to not block response, use setImmediate for non-blocking
1188
+
1189
+
558
1190
  setImmediate(async () => {
559
1191
  if (typeof unloadMainEmbedder === 'function') {
560
1192
  await unloadMainEmbedder();
@@ -577,8 +1209,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
577
1209
  };
578
1210
  });
579
1211
 
580
- // Main entry point
581
- export async function main(argv = process.argv) {
1212
+
1213
+
1214
+ export async function main(argv = process.argv) {
582
1215
  const parsed = parseArgs(argv);
583
1216
  const {
584
1217
  isServerMode,
@@ -600,17 +1233,24 @@ export async function main(argv = process.argv) {
600
1233
  unknownFlags,
601
1234
  } = parsed;
602
1235
 
603
- let shutdownRequested = false;
604
- const requestShutdown = (reason) => {
605
- if (shutdownRequested) return;
606
- shutdownRequested = true;
607
- console.info(`[Server] Shutdown requested (${reason}).`);
608
- void gracefulShutdown(reason);
609
- };
610
-
611
- if (isServerMode && !(process.env.VITEST === 'true' || process.env.NODE_ENV === 'test')) {
612
- enableStderrOnlyLogging();
613
- }
1236
+ let shutdownRequested = false;
1237
+ let shutdownReason = 'natural';
1238
+ const requestShutdown = (reason) => {
1239
+ if (shutdownRequested) return;
1240
+ shutdownRequested = true;
1241
+ shutdownReason = String(reason || 'unknown');
1242
+ console.info(`[Server] Shutdown requested (${reason}).`);
1243
+ void gracefulShutdown(reason);
1244
+ };
1245
+ registerProcessDiagnostics({
1246
+ isServerMode,
1247
+ requestShutdown,
1248
+ getShutdownReason: () => shutdownReason,
1249
+ });
1250
+
1251
+ if (isServerMode && !(process.env.VITEST === 'true' || process.env.NODE_ENV === 'test')) {
1252
+ enableStderrOnlyLogging();
1253
+ }
614
1254
  if (wantsVersion) {
615
1255
  console.info(packageJson.version);
616
1256
  process.exit(0);
@@ -641,19 +1281,23 @@ export async function main(argv = process.argv) {
641
1281
  process.exit(0);
642
1282
  }
643
1283
 
644
- // --cache command (cache-only, no server status)
1284
+
1285
+
645
1286
  if (wantsCache) {
646
1287
  await status({ fix: wantsClean, cacheOnly: true, workspaceDir });
647
1288
  process.exit(0);
648
1289
  }
649
1290
 
650
- // --clear <cache_id> command (remove specific cache by ID)
1291
+
1292
+
651
1293
  const clearIndex = parsed.rawArgs.indexOf('--clear');
652
1294
  if (clearIndex !== -1) {
653
1295
  const cacheId = parsed.rawArgs[clearIndex + 1];
654
1296
  if (cacheId && !cacheId.startsWith('--')) {
655
- // Remove specific cache by ID
656
- // Determine platform-appropriate cache directory
1297
+
1298
+
1299
+
1300
+
657
1301
  let cacheHome;
658
1302
  if (process.platform === 'win32') {
659
1303
  cacheHome = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
@@ -697,7 +1341,8 @@ export async function main(argv = process.argv) {
697
1341
  }
698
1342
  process.exit(0);
699
1343
  }
700
- // If --clear with no arg, fall through to --clear-cache behavior
1344
+
1345
+
701
1346
  }
702
1347
 
703
1348
  if (wantsClearCache) {
@@ -740,34 +1385,89 @@ export async function main(argv = process.argv) {
740
1385
  }
741
1386
 
742
1387
  registerSignalHandlers(requestShutdown);
743
- // NOTE: We intentionally do NOT shut down on stdin close.
744
- // When an IDE restarts, it may briefly close stdin then reconnect.
745
- // The server should remain running to preserve cache and be ready for reconnection.
746
- // Use SIGINT/SIGTERM or --stop command for intentional shutdown.
747
- const { startBackgroundTasks } = await initialize(workspaceDir);
1388
+
1389
+
1390
+
1391
+
748
1392
 
749
- // (Blocking init moved below)
1393
+
1394
+ const detectedRootPromise = new Promise((resolve) => {
1395
+ const HANDSHAKE_TIMEOUT_MS = 1000;
1396
+ let settled = false;
1397
+ const resolveOnce = (value) => {
1398
+ if (settled) return;
1399
+ settled = true;
1400
+ resolve(value);
1401
+ };
1402
+
1403
+ const timer = setTimeout(() => {
1404
+ console.warn(`[Server] MCP handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms, proceeding without roots.`);
1405
+ resolveOnce(null);
1406
+ }, HANDSHAKE_TIMEOUT_MS);
1407
+
1408
+ server.oninitialized = async () => {
1409
+ clearTimeout(timer);
1410
+ console.info('[Server] MCP handshake complete.');
1411
+ const root = await detectWorkspaceFromRoots();
1412
+ resolveOnce(root);
1413
+ };
1414
+ });
1415
+
1416
+ const transport = new StdioServerTransport();
1417
+ await server.connect(transport);
1418
+ console.info('[Server] MCP transport connected.');
1419
+ if (isServerMode) {
1420
+ process.stdin?.on?.('end', () => requestShutdown('stdin-end'));
1421
+ process.stdin?.on?.('close', () => requestShutdown('stdin-close'));
1422
+ process.stdout?.on?.('error', (err) => {
1423
+ if (err?.code === 'EPIPE') {
1424
+ requestShutdown('stdout-epipe');
1425
+ }
1426
+ });
1427
+ }
1428
+
1429
+
1430
+
1431
+ const detectedRoot = await detectedRootPromise;
750
1432
 
751
- const transport = new StdioServerTransport();
752
- await server.connect(transport);
1433
+
1434
+ const effectiveWorkspace = detectedRoot || workspaceDir;
1435
+ if (detectedRoot) {
1436
+ console.info(`[Server] Using workspace from MCP roots: ${detectedRoot}`);
1437
+ }
1438
+ const initPromise = initialize(effectiveWorkspace);
1439
+ const initWithResolve = initPromise
1440
+ .then((result) => {
1441
+ configReadyResolve();
1442
+ return result;
1443
+ })
1444
+ .catch((err) => {
1445
+ configInitError = err;
1446
+ configReadyResolve();
1447
+ throw err;
1448
+ });
1449
+ const { startBackgroundTasks } = await initWithResolve;
753
1450
 
754
- console.info('[Server] MCP transport connected.');
755
1451
  console.info('[Server] Heuristic MCP server started.');
756
1452
 
757
- // Load cache and start indexing in background AFTER server is ready
1453
+
1454
+
758
1455
  void startBackgroundTasks().catch((err) => {
759
1456
  console.error(`[Server] Background task error: ${err.message}`);
760
1457
  });
761
1458
  console.info('[Server] MCP server is now fully ready to accept requests.');
762
1459
  }
763
1460
 
764
- // Graceful shutdown
765
- async function gracefulShutdown(signal) {
766
- console.info(`[Server] Received ${signal}, shutting down gracefully...`);
1461
+
1462
+
1463
+ async function gracefulShutdown(signal) {
1464
+ console.info(`[Server] Received ${signal}, shutting down gracefully...`);
1465
+ const exitCode = isCrashShutdownReason(signal) ? 1 : 0;
767
1466
 
768
1467
  const cleanupTasks = [];
769
1468
 
770
- // Stop file watcher
1469
+
1470
+
771
1471
  if (indexer && indexer.watcher) {
772
1472
  cleanupTasks.push(
773
1473
  indexer.watcher
@@ -777,7 +1477,8 @@ async function gracefulShutdown(signal) {
777
1477
  );
778
1478
  }
779
1479
 
780
- // Give workers time to finish current batch
1480
+
1481
+
781
1482
  if (indexer && indexer.terminateWorkers) {
782
1483
  cleanupTasks.push(
783
1484
  (async () => {
@@ -788,22 +1489,29 @@ async function gracefulShutdown(signal) {
788
1489
  );
789
1490
  }
790
1491
 
791
- // Save cache
792
- if (cache) {
793
- cleanupTasks.push(
794
- cache
795
- .save()
796
- .then(() => console.info('[Server] Cache saved'))
797
- .catch((err) => console.error(`[Server] Failed to save cache: ${err.message}`))
798
- );
799
- }
800
-
801
- await Promise.allSettled(cleanupTasks);
802
- console.info('[Server] Goodbye!');
803
-
804
- // Allow stdio buffers to flush
805
- setTimeout(() => process.exit(0), 100);
806
- }
1492
+
1493
+
1494
+ if (cache) {
1495
+ if (!workspaceLockAcquired) {
1496
+ console.info('[Server] Secondary/fallback mode: skipping cache save.');
1497
+ } else {
1498
+ cleanupTasks.push(
1499
+ cache
1500
+ .save()
1501
+ .then(() => console.info('[Server] Cache saved'))
1502
+ .catch((err) => console.error(`[Server] Failed to save cache: ${err.message}`))
1503
+ );
1504
+ }
1505
+ }
1506
+
1507
+ await Promise.allSettled(cleanupTasks);
1508
+ console.info('[Server] Goodbye!');
1509
+ await flushLogs({ close: true, timeoutMs: 1500 }).catch(() => {});
1510
+
1511
+
1512
+
1513
+ setTimeout(() => process.exit(exitCode), 100);
1514
+ }
807
1515
 
808
1516
  const isMain =
809
1517
  process.argv[1] &&
@@ -813,6 +1521,10 @@ const isMain =
813
1521
  path.basename(process.argv[1]) === 'index.js') &&
814
1522
  !(process.env.VITEST === 'true' || process.env.NODE_ENV === 'test');
815
1523
 
816
- if (isMain) {
817
- main().catch(console.error);
818
- }
1524
+ if (isMain) {
1525
+ main().catch(async (err) => {
1526
+ console.error(err);
1527
+ await flushLogs({ close: true, timeoutMs: 500 }).catch(() => {});
1528
+ process.exit(1);
1529
+ });
1530
+ }