@softerist/heuristic-mcp 3.2.8 → 3.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,1690 +1,1745 @@
1
- #!/usr/bin/env node
2
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
- import { stop, start, status, logs } from './features/lifecycle.js';
4
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
- import {
6
- CallToolRequestSchema,
7
- ListToolsRequestSchema,
8
- ListResourcesRequestSchema,
9
- ReadResourceRequestSchema,
10
- RootsListChangedNotificationSchema,
11
- } from '@modelcontextprotocol/sdk/types.js';
12
- let transformersModule = null;
13
- async function getTransformers() {
14
- if (!transformersModule) {
15
- transformersModule = await import('@huggingface/transformers');
16
- if (transformersModule?.env) {
17
- transformersModule.env.cacheDir = path.join(getGlobalCacheDir(), 'xenova');
18
- }
19
- }
20
- return transformersModule;
21
- }
22
- import { configureNativeOnnxBackend, getNativeOnnxStatus } from './lib/onnx-backend.js';
23
-
24
- import fs from 'fs/promises';
25
- import path from 'path';
26
- import os from 'os';
27
-
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { stop, start, status, logs } from './features/lifecycle.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import {
6
+ CallToolRequestSchema,
7
+ ListToolsRequestSchema,
8
+ ListResourcesRequestSchema,
9
+ ReadResourceRequestSchema,
10
+ RootsListChangedNotificationSchema,
11
+ } from '@modelcontextprotocol/sdk/types.js';
12
+ let transformersModule = null;
13
+ async function getTransformers() {
14
+ if (!transformersModule) {
15
+ transformersModule = await import('@huggingface/transformers');
16
+ if (transformersModule?.env) {
17
+ transformersModule.env.cacheDir = path.join(getGlobalCacheDir(), 'xenova');
18
+ }
19
+ }
20
+ return transformersModule;
21
+ }
22
+ import { configureNativeOnnxBackend, getNativeOnnxStatus } from './lib/onnx-backend.js';
23
+
24
+ import fs from 'fs/promises';
25
+ import path from 'path';
26
+ import os from 'os';
27
+
28
28
  import { createRequire } from 'module';
29
29
  import { fileURLToPath } from 'url';
30
- import { getWorkspaceCachePath } from './lib/workspace-cache-key.js';
31
-
32
- const require = createRequire(import.meta.url);
33
- const packageJson = require('./package.json');
34
-
35
- import { loadConfig, getGlobalCacheDir, isNonProjectDirectory } from './lib/config.js';
36
- import { clearStaleCaches } from './lib/cache-utils.js';
37
- import {
38
- enableStderrOnlyLogging,
39
- setupFileLogging,
40
- getLogFilePath,
41
- flushLogs,
42
- } from './lib/logging.js';
43
- import { parseArgs, printHelp } from './lib/cli.js';
44
- import { clearCache } from './lib/cache-ops.js';
45
- import { logMemory, startMemoryLogger } from './lib/memory-logger.js';
46
- import {
47
- registerSignalHandlers,
48
- setupPidFile,
49
- acquireWorkspaceLock,
50
- releaseWorkspaceLock,
51
- stopOtherHeuristicServers,
52
- } from './lib/server-lifecycle.js';
53
-
54
- import { EmbeddingsCache } from './lib/cache.js';
55
- import {
56
- cleanupStaleBinaryArtifacts,
57
- recordBinaryStoreCorruption,
58
- } from './lib/vector-store-binary.js';
59
- import { CodebaseIndexer } from './features/index-codebase.js';
60
- import { HybridSearch } from './features/hybrid-search.js';
61
-
62
- import * as IndexCodebaseFeature from './features/index-codebase.js';
63
- import * as HybridSearchFeature from './features/hybrid-search.js';
64
- import * as ClearCacheFeature from './features/clear-cache.js';
65
- import * as FindSimilarCodeFeature from './features/find-similar-code.js';
66
- import * as AnnConfigFeature from './features/ann-config.js';
67
- import * as PackageVersionFeature from './features/package-version.js';
68
- import * as SetWorkspaceFeature from './features/set-workspace.js';
69
- import { handleListResources, handleReadResource } from './features/resources.js';
70
- import { getWorkspaceEnvKeys } from './lib/workspace-env.js';
71
-
72
- import {
73
- MEMORY_LOG_INTERVAL_MS,
74
- ONNX_THREAD_LIMIT,
75
- BACKGROUND_INDEX_DELAY_MS,
76
- SERVER_KEEP_ALIVE_INTERVAL_MS,
77
- } from './lib/constants.js';
78
- const PID_FILE_NAME = '.heuristic-mcp.pid';
79
-
80
- function isTestRuntime() {
81
- return (
82
- process.env.VITEST === 'true' ||
83
- process.env.NODE_ENV === 'test'
84
- );
85
- }
86
-
87
- async function readLogTail(logPath, maxLines = 2000) {
88
- const data = await fs.readFile(logPath, 'utf-8');
89
- if (!data) return [];
90
- const lines = data.split(/\r?\n/).filter(Boolean);
91
- return lines.slice(-maxLines);
92
- }
93
-
94
- async function printMemorySnapshot(workspaceDir) {
95
- const activeConfig = await loadConfig(workspaceDir);
96
- const logPath = getLogFilePath(activeConfig);
97
-
98
- let lines;
99
- try {
100
- lines = await readLogTail(logPath);
101
- } catch (err) {
102
- if (err.code === 'ENOENT') {
103
- console.error(`[Memory] No log file found for workspace.`);
104
- console.error(`[Memory] Expected location: ${logPath}`);
105
- console.error(
106
- '[Memory] Start the server with verbose logging (set "verbose": true), then try again.'
107
- );
108
- return false;
109
- }
110
- console.error(`[Memory] Failed to read log file: ${err.message}`);
111
- return false;
112
- }
113
-
114
- const memoryLines = lines.filter((line) => /Memory\s*\(/.test(line) || /Memory.*rss=/.test(line));
115
- if (memoryLines.length === 0) {
116
- console.info('[Memory] No memory snapshots found in logs.');
117
- console.info('[Memory] Ensure "verbose": true in config and restart the server.');
118
- return true;
119
- }
120
-
121
- const idleLine =
122
- [...memoryLines].reverse().find((line) => line.includes('after cache load')) ??
123
- memoryLines[memoryLines.length - 1];
124
-
125
- const logLine = (line) => {
126
- console.info(line);
127
- if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') {
128
- console.error(line);
129
- }
130
- };
131
-
132
- logLine(`[Memory] Idle snapshot: ${idleLine}`);
133
-
134
- const latestLine = memoryLines[memoryLines.length - 1];
135
- if (latestLine !== idleLine) {
136
- logLine(`[Memory] Latest snapshot: ${latestLine}`);
137
- }
138
-
139
- return true;
140
- }
141
-
142
- async function flushLogsSafely(options) {
143
- if (typeof flushLogs !== 'function') {
144
- console.warn('[Logs] flushLogs helper is unavailable; skipping log flush.');
145
- return;
146
- }
147
-
148
- try {
149
- await flushLogs(options);
150
- } catch (error) {
151
- const message = error instanceof Error ? error.message : String(error);
152
- console.warn(`[Logs] Failed to flush logs: ${message}`);
153
- }
154
- }
155
-
156
- function assertCacheContract(cacheInstance) {
157
- const requiredMethods = [
158
- 'load',
159
- 'save',
160
- 'consumeAutoReindex',
161
- 'clearInMemoryState',
162
- 'getStoreSize',
163
- ];
164
- const missing = requiredMethods.filter((name) => typeof cacheInstance?.[name] !== 'function');
165
- if (missing.length > 0) {
166
- throw new Error(
167
- `[Server] Cache implementation contract violation: missing method(s): ${missing.join(', ')}`
168
- );
169
- }
170
- }
171
-
172
- let embedder = null;
173
- let unloadMainEmbedder = null;
174
- let cache = null;
175
- let indexer = null;
176
- let hybridSearch = null;
177
- let config = null;
178
- let workspaceLockAcquired = true;
179
- let configReadyResolve = null;
180
- let configInitError = null;
181
- let configReadyPromise = new Promise((resolve) => {
182
- configReadyResolve = resolve;
183
- });
184
- let setWorkspaceFeatureInstance = null;
185
- let autoWorkspaceSwitchPromise = null;
186
- let rootsCapabilitySupported = null;
187
- let rootsProbeInFlight = null;
188
- let lastRootsProbeTime = 0;
189
- let keepAliveTimer = null;
190
- let stdioShutdownHandlers = null;
191
- const ROOTS_PROBE_COOLDOWN_MS = 2000;
192
- const WORKSPACE_BOUND_TOOL_NAMES = new Set([
193
- 'a_semantic_search',
194
- 'b_index_codebase',
195
- 'c_clear_cache',
196
- 'd_find_similar_code',
197
- 'd_ann_config',
198
- ]);
199
- const trustedWorkspacePaths = new Set();
200
-
201
- function shouldRequireTrustedWorkspaceSignalForTool(toolName) {
202
- return WORKSPACE_BOUND_TOOL_NAMES.has(toolName);
203
- }
204
-
205
- function trustWorkspacePath(workspacePath) {
206
- const normalized = normalizePathForCompare(workspacePath);
207
- if (normalized) {
208
- trustedWorkspacePaths.add(normalized);
209
- }
210
- }
211
-
212
- function isCurrentWorkspaceTrusted() {
213
- if (!config?.searchDirectory) return false;
214
- return trustedWorkspacePaths.has(normalizePathForCompare(config.searchDirectory));
215
- }
216
-
217
- function isToolResponseError(result) {
218
- if (!result || typeof result !== 'object') return true;
219
- if (result.isError === true) return true;
220
- if (!Array.isArray(result.content)) return false;
221
-
222
- return result.content.some(
223
- (entry) =>
224
- entry?.type === 'text' &&
225
- typeof entry.text === 'string' &&
226
- entry.text.trim().toLowerCase().startsWith('error:')
227
- );
228
- }
229
-
230
- function formatCrashDetail(detail) {
231
- if (detail instanceof Error) {
232
- return detail.stack || detail.message || String(detail);
233
- }
234
- if (typeof detail === 'string') {
235
- return detail;
236
- }
237
- try {
238
- return JSON.stringify(detail);
239
- } catch {
240
- return String(detail);
241
- }
242
- }
243
-
244
- function isBrokenPipeError(detail) {
245
- if (!detail) return false;
246
- if (typeof detail === 'string') {
247
- return /(?:^|[\s:])EPIPE(?:[\s:]|$)|broken pipe/i.test(detail);
248
- }
249
- if (typeof detail === 'object') {
250
- if (detail.code === 'EPIPE') return true;
251
- if (typeof detail.message === 'string') {
252
- return /(?:^|[\s:])EPIPE(?:[\s:]|$)|broken pipe/i.test(detail.message);
253
- }
254
- }
255
- return false;
256
- }
257
-
258
- function isCrashShutdownReason(reason) {
259
- const normalized = String(reason || '').toLowerCase();
260
- return normalized.includes('uncaughtexception') || normalized.includes('unhandledrejection');
261
- }
262
-
263
- function shouldLogProcessLifecycle() {
264
- const value = String(process.env.HEURISTIC_MCP_PROCESS_LIFECYCLE || '').trim().toLowerCase();
265
- return value === '1' || value === 'true' || value === 'yes' || value === 'on';
266
- }
267
-
268
- function getShutdownExitCode(reason) {
269
- const normalized = String(reason || '').trim().toUpperCase();
270
- if (normalized === 'SIGINT') return 130;
271
- if (normalized === 'SIGTERM') return 143;
272
- return isCrashShutdownReason(reason) ? 1 : 0;
273
- }
274
-
275
- function registerProcessDiagnostics({ isServerMode, requestShutdown, getShutdownReason }) {
276
- if (!isServerMode) return;
277
-
278
- if (shouldLogProcessLifecycle()) {
279
- let beforeExitLogged = false;
280
- process.on('beforeExit', (code) => {
281
- if (beforeExitLogged) return;
282
- beforeExitLogged = true;
283
- const reason = getShutdownReason() || 'natural';
284
- console.info(`[Server] Process beforeExit (code=${code}, reason=${reason}).`);
285
- });
286
-
287
- process.on('exit', (code) => {
288
- const reason = getShutdownReason() || 'natural';
289
- console.info(`[Server] Process exit (code=${code}, reason=${reason}).`);
290
- });
291
- }
292
-
293
- let fatalHandled = false;
294
- const handleFatalError = (reason, detail) => {
295
- if (fatalHandled) return;
296
- if (isBrokenPipeError(detail)) {
297
- requestShutdown('stdio-epipe');
298
- return;
299
- }
300
- fatalHandled = true;
301
- console.error(`[Server] Fatal ${reason}: ${formatCrashDetail(detail)}`);
302
- requestShutdown(reason);
303
- const forceExitTimer = setTimeout(() => {
304
- console.error(`[Server] Forced exit after fatal ${reason}.`);
305
- process.exit(1);
306
- }, 5000);
307
- forceExitTimer.unref?.();
308
- };
309
-
310
- process.on('uncaughtException', (err) => {
311
- handleFatalError('uncaughtException', err);
312
- });
313
- process.on('unhandledRejection', (reason) => {
314
- handleFatalError('unhandledRejection', reason);
315
- });
316
- }
317
-
318
- function registerStdioShutdownHandlers(requestShutdown) {
319
- if (stdioShutdownHandlers) return;
320
-
321
- const onStdinEnd = () => requestShutdown('stdin-end');
322
- const onStdinClose = () => requestShutdown('stdin-close');
323
- const onStdoutError = (err) => {
324
- if (err?.code === 'EPIPE') {
325
- requestShutdown('stdout-epipe');
326
- }
327
- };
328
- const onStderrError = (err) => {
329
- if (err?.code === 'EPIPE') {
330
- requestShutdown('stderr-epipe');
331
- }
332
- };
333
-
334
- process.stdin?.on?.('end', onStdinEnd);
335
- process.stdin?.on?.('close', onStdinClose);
336
- process.stdout?.on?.('error', onStdoutError);
337
- process.stderr?.on?.('error', onStderrError);
338
-
339
- stdioShutdownHandlers = {
340
- onStdinEnd,
341
- onStdinClose,
342
- onStdoutError,
343
- onStderrError,
344
- };
345
- }
346
-
347
- function unregisterStdioShutdownHandlers() {
348
- if (!stdioShutdownHandlers) return;
349
- process.stdin?.off?.('end', stdioShutdownHandlers.onStdinEnd);
350
- process.stdin?.off?.('close', stdioShutdownHandlers.onStdinClose);
351
- process.stdout?.off?.('error', stdioShutdownHandlers.onStdoutError);
352
- process.stderr?.off?.('error', stdioShutdownHandlers.onStderrError);
353
- stdioShutdownHandlers = null;
354
- }
355
-
356
- async function resolveWorkspaceFromEnvValue(rawValue) {
357
- if (!rawValue || rawValue.includes('${')) return null;
358
- const resolved = path.resolve(rawValue);
359
- try {
360
- const stats = await fs.stat(resolved);
361
- if (!stats.isDirectory()) return null;
362
- return resolved;
363
- } catch {
364
- return null;
365
- }
366
- }
367
-
368
- async function detectRuntimeWorkspaceFromEnv() {
369
- for (const key of getWorkspaceEnvKeys()) {
370
- const workspacePath = await resolveWorkspaceFromEnvValue(process.env[key]);
371
- if (workspacePath) {
372
- return { workspacePath, envKey: key };
373
- }
374
- }
375
-
376
- return null;
377
- }
378
-
30
+ import { getWorkspaceCachePathCandidates } from './lib/workspace-cache-key.js';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ const packageJson = require('./package.json');
34
+
35
+ import { loadConfig, getGlobalCacheDir, isNonProjectDirectory } from './lib/config.js';
36
+ import { clearStaleCaches } from './lib/cache-utils.js';
37
+ import {
38
+ enableStderrOnlyLogging,
39
+ setupFileLogging,
40
+ getLogFilePath,
41
+ flushLogs,
42
+ } from './lib/logging.js';
43
+ import { parseArgs, printHelp } from './lib/cli.js';
44
+ import { clearCache } from './lib/cache-ops.js';
45
+ import { logMemory, startMemoryLogger } from './lib/memory-logger.js';
46
+ import {
47
+ registerSignalHandlers,
48
+ setupPidFile,
49
+ acquireWorkspaceLock,
50
+ releaseWorkspaceLock,
51
+ stopOtherHeuristicServers,
52
+ } from './lib/server-lifecycle.js';
53
+
54
+ import { EmbeddingsCache } from './lib/cache.js';
55
+ import {
56
+ cleanupStaleBinaryArtifacts,
57
+ recordBinaryStoreCorruption,
58
+ } from './lib/vector-store-binary.js';
59
+ import { CodebaseIndexer } from './features/index-codebase.js';
60
+ import { HybridSearch } from './features/hybrid-search.js';
61
+
62
+ import * as IndexCodebaseFeature from './features/index-codebase.js';
63
+ import * as HybridSearchFeature from './features/hybrid-search.js';
64
+ import * as ClearCacheFeature from './features/clear-cache.js';
65
+ import * as FindSimilarCodeFeature from './features/find-similar-code.js';
66
+ import * as AnnConfigFeature from './features/ann-config.js';
67
+ import * as PackageVersionFeature from './features/package-version.js';
68
+ import * as SetWorkspaceFeature from './features/set-workspace.js';
69
+ import { handleListResources, handleReadResource } from './features/resources.js';
70
+ import { getWorkspaceEnvKeys } from './lib/workspace-env.js';
71
+
72
+ import {
73
+ MEMORY_LOG_INTERVAL_MS,
74
+ ONNX_THREAD_LIMIT,
75
+ BACKGROUND_INDEX_DELAY_MS,
76
+ SERVER_KEEP_ALIVE_INTERVAL_MS,
77
+ } from './lib/constants.js';
78
+ const PID_FILE_NAME = '.heuristic-mcp.pid';
79
+
80
+ function isTestRuntime() {
81
+ return (
82
+ process.env.VITEST === 'true' ||
83
+ process.env.NODE_ENV === 'test'
84
+ );
85
+ }
86
+
87
+ async function readLogTail(logPath, maxLines = 2000) {
88
+ const data = await fs.readFile(logPath, 'utf-8');
89
+ if (!data) return [];
90
+ const lines = data.split(/\r?\n/).filter(Boolean);
91
+ return lines.slice(-maxLines);
92
+ }
93
+
94
+ async function printMemorySnapshot(workspaceDir) {
95
+ const activeConfig = await loadConfig(workspaceDir);
96
+ const logPath = getLogFilePath(activeConfig);
97
+
98
+ let lines;
99
+ try {
100
+ lines = await readLogTail(logPath);
101
+ } catch (err) {
102
+ if (err.code === 'ENOENT') {
103
+ console.error(`[Memory] No log file found for workspace.`);
104
+ console.error(`[Memory] Expected location: ${logPath}`);
105
+ console.error(
106
+ '[Memory] Start the server with verbose logging (set "verbose": true), then try again.'
107
+ );
108
+ return false;
109
+ }
110
+ console.error(`[Memory] Failed to read log file: ${err.message}`);
111
+ return false;
112
+ }
113
+
114
+ const memoryLines = lines.filter((line) => /Memory\s*\(/.test(line) || /Memory.*rss=/.test(line));
115
+ if (memoryLines.length === 0) {
116
+ console.info('[Memory] No memory snapshots found in logs.');
117
+ console.info('[Memory] Ensure "verbose": true in config and restart the server.');
118
+ return true;
119
+ }
120
+
121
+ const idleLine =
122
+ [...memoryLines].reverse().find((line) => line.includes('after cache load')) ??
123
+ memoryLines[memoryLines.length - 1];
124
+
125
+ const logLine = (line) => {
126
+ console.info(line);
127
+ if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') {
128
+ console.error(line);
129
+ }
130
+ };
131
+
132
+ logLine(`[Memory] Idle snapshot: ${idleLine}`);
133
+
134
+ const latestLine = memoryLines[memoryLines.length - 1];
135
+ if (latestLine !== idleLine) {
136
+ logLine(`[Memory] Latest snapshot: ${latestLine}`);
137
+ }
138
+
139
+ return true;
140
+ }
141
+
142
+ async function flushLogsSafely(options) {
143
+ if (typeof flushLogs !== 'function') {
144
+ console.warn('[Logs] flushLogs helper is unavailable; skipping log flush.');
145
+ return;
146
+ }
147
+
148
+ try {
149
+ await flushLogs(options);
150
+ } catch (error) {
151
+ const message = error instanceof Error ? error.message : String(error);
152
+ console.warn(`[Logs] Failed to flush logs: ${message}`);
153
+ }
154
+ }
155
+
156
+ function assertCacheContract(cacheInstance) {
157
+ const requiredMethods = [
158
+ 'load',
159
+ 'save',
160
+ 'consumeAutoReindex',
161
+ 'clearInMemoryState',
162
+ 'getStoreSize',
163
+ ];
164
+ const missing = requiredMethods.filter((name) => typeof cacheInstance?.[name] !== 'function');
165
+ if (missing.length > 0) {
166
+ throw new Error(
167
+ `[Server] Cache implementation contract violation: missing method(s): ${missing.join(', ')}`
168
+ );
169
+ }
170
+ }
171
+
172
+ let embedder = null;
173
+ let unloadMainEmbedder = null;
174
+ let cache = null;
175
+ let indexer = null;
176
+ let hybridSearch = null;
177
+ let config = null;
178
+ let workspaceLockAcquired = true;
179
+ let configReadyResolve = null;
180
+ let configInitError = null;
181
+ let configReadyPromise = new Promise((resolve) => {
182
+ configReadyResolve = resolve;
183
+ });
184
+ let setWorkspaceFeatureInstance = null;
185
+ let autoWorkspaceSwitchPromise = null;
186
+ let rootsCapabilitySupported = null;
187
+ let rootsProbeInFlight = null;
188
+ let lastRootsProbeTime = 0;
189
+ let keepAliveTimer = null;
190
+ let stdioShutdownHandlers = null;
191
+ const ROOTS_PROBE_COOLDOWN_MS = 2000;
192
+ const WORKSPACE_BOUND_TOOL_NAMES = new Set([
193
+ 'a_semantic_search',
194
+ 'b_index_codebase',
195
+ 'c_clear_cache',
196
+ 'd_find_similar_code',
197
+ 'd_ann_config',
198
+ ]);
199
+ const trustedWorkspacePaths = new Set();
200
+
201
+ function shouldRequireTrustedWorkspaceSignalForTool(toolName) {
202
+ return WORKSPACE_BOUND_TOOL_NAMES.has(toolName);
203
+ }
204
+
205
+ function trustWorkspacePath(workspacePath) {
206
+ const normalized = normalizePathForCompare(workspacePath);
207
+ if (normalized) {
208
+ trustedWorkspacePaths.add(normalized);
209
+ }
210
+ }
211
+
212
+ function isCurrentWorkspaceTrusted() {
213
+ if (!config?.searchDirectory) return false;
214
+ return trustedWorkspacePaths.has(normalizePathForCompare(config.searchDirectory));
215
+ }
216
+
217
+ function isToolResponseError(result) {
218
+ if (!result || typeof result !== 'object') return true;
219
+ if (result.isError === true) return true;
220
+ if (!Array.isArray(result.content)) return false;
221
+
222
+ return result.content.some(
223
+ (entry) =>
224
+ entry?.type === 'text' &&
225
+ typeof entry.text === 'string' &&
226
+ entry.text.trim().toLowerCase().startsWith('error:')
227
+ );
228
+ }
229
+
230
+ function formatCrashDetail(detail) {
231
+ if (detail instanceof Error) {
232
+ return detail.stack || detail.message || String(detail);
233
+ }
234
+ if (typeof detail === 'string') {
235
+ return detail;
236
+ }
237
+ try {
238
+ return JSON.stringify(detail);
239
+ } catch {
240
+ return String(detail);
241
+ }
242
+ }
243
+
244
+ function isBrokenPipeError(detail) {
245
+ if (!detail) return false;
246
+ if (typeof detail === 'string') {
247
+ return /(?:^|[\s:])EPIPE(?:[\s:]|$)|broken pipe/i.test(detail);
248
+ }
249
+ if (typeof detail === 'object') {
250
+ if (detail.code === 'EPIPE') return true;
251
+ if (typeof detail.message === 'string') {
252
+ return /(?:^|[\s:])EPIPE(?:[\s:]|$)|broken pipe/i.test(detail.message);
253
+ }
254
+ }
255
+ return false;
256
+ }
257
+
258
+ function isCrashShutdownReason(reason) {
259
+ const normalized = String(reason || '').toLowerCase();
260
+ return normalized.includes('uncaughtexception') || normalized.includes('unhandledrejection');
261
+ }
262
+
263
+ function shouldLogProcessLifecycle() {
264
+ const value = String(process.env.HEURISTIC_MCP_PROCESS_LIFECYCLE || '').trim().toLowerCase();
265
+ return value === '1' || value === 'true' || value === 'yes' || value === 'on';
266
+ }
267
+
268
+ function getShutdownExitCode(reason) {
269
+ const normalized = String(reason || '').trim().toUpperCase();
270
+ if (normalized === 'SIGINT') return 130;
271
+ if (normalized === 'SIGTERM') return 143;
272
+ return isCrashShutdownReason(reason) ? 1 : 0;
273
+ }
274
+
275
+ function registerProcessDiagnostics({ isServerMode, requestShutdown, getShutdownReason }) {
276
+ if (!isServerMode) return;
277
+
278
+ if (shouldLogProcessLifecycle()) {
279
+ let beforeExitLogged = false;
280
+ process.on('beforeExit', (code) => {
281
+ if (beforeExitLogged) return;
282
+ beforeExitLogged = true;
283
+ const reason = getShutdownReason() || 'natural';
284
+ console.info(`[Server] Process beforeExit (code=${code}, reason=${reason}).`);
285
+ });
286
+
287
+ process.on('exit', (code) => {
288
+ const reason = getShutdownReason() || 'natural';
289
+ console.info(`[Server] Process exit (code=${code}, reason=${reason}).`);
290
+ });
291
+ }
292
+
293
+ let fatalHandled = false;
294
+ const handleFatalError = (reason, detail) => {
295
+ if (fatalHandled) return;
296
+ if (isBrokenPipeError(detail)) {
297
+ requestShutdown('stdio-epipe');
298
+ return;
299
+ }
300
+ fatalHandled = true;
301
+ console.error(`[Server] Fatal ${reason}: ${formatCrashDetail(detail)}`);
302
+ requestShutdown(reason);
303
+ const forceExitTimer = setTimeout(() => {
304
+ console.error(`[Server] Forced exit after fatal ${reason}.`);
305
+ process.exit(1);
306
+ }, 5000);
307
+ forceExitTimer.unref?.();
308
+ };
309
+
310
+ process.on('uncaughtException', (err) => {
311
+ handleFatalError('uncaughtException', err);
312
+ });
313
+ process.on('unhandledRejection', (reason) => {
314
+ handleFatalError('unhandledRejection', reason);
315
+ });
316
+ }
317
+
318
+ function registerStdioShutdownHandlers(requestShutdown) {
319
+ if (stdioShutdownHandlers) return;
320
+
321
+ const onStdinEnd = () => requestShutdown('stdin-end');
322
+ const onStdinClose = () => requestShutdown('stdin-close');
323
+ const onStdoutError = (err) => {
324
+ if (err?.code === 'EPIPE') {
325
+ requestShutdown('stdout-epipe');
326
+ }
327
+ };
328
+ const onStderrError = (err) => {
329
+ if (err?.code === 'EPIPE') {
330
+ requestShutdown('stderr-epipe');
331
+ }
332
+ };
333
+
334
+ process.stdin?.on?.('end', onStdinEnd);
335
+ process.stdin?.on?.('close', onStdinClose);
336
+ process.stdout?.on?.('error', onStdoutError);
337
+ process.stderr?.on?.('error', onStderrError);
338
+
339
+ stdioShutdownHandlers = {
340
+ onStdinEnd,
341
+ onStdinClose,
342
+ onStdoutError,
343
+ onStderrError,
344
+ };
345
+ }
346
+
347
+ function unregisterStdioShutdownHandlers() {
348
+ if (!stdioShutdownHandlers) return;
349
+ process.stdin?.off?.('end', stdioShutdownHandlers.onStdinEnd);
350
+ process.stdin?.off?.('close', stdioShutdownHandlers.onStdinClose);
351
+ process.stdout?.off?.('error', stdioShutdownHandlers.onStdoutError);
352
+ process.stderr?.off?.('error', stdioShutdownHandlers.onStderrError);
353
+ stdioShutdownHandlers = null;
354
+ }
355
+
356
+ async function resolveWorkspaceFromEnvValue(rawValue) {
357
+ if (!rawValue || rawValue.includes('${')) return null;
358
+ const resolved = path.resolve(rawValue);
359
+ try {
360
+ const stats = await fs.stat(resolved);
361
+ if (!stats.isDirectory()) return null;
362
+ return resolved;
363
+ } catch {
364
+ return null;
365
+ }
366
+ }
367
+
368
+ async function detectRuntimeWorkspaceFromEnv() {
369
+ for (const key of getWorkspaceEnvKeys()) {
370
+ const workspacePath = await resolveWorkspaceFromEnvValue(process.env[key]);
371
+ if (workspacePath) {
372
+ return { workspacePath, envKey: key };
373
+ }
374
+ }
375
+
376
+ return null;
377
+ }
378
+
379
379
  function normalizePathForCompare(targetPath) {
380
380
  if (!targetPath) return '';
381
381
  const resolved = path.resolve(targetPath);
382
382
  return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
383
383
  }
384
384
 
385
- function isProcessAlive(pid) {
386
- if (!Number.isInteger(pid) || pid <= 0) return false;
385
+ async function pathExists(targetPath) {
387
386
  try {
388
- process.kill(pid, 0);
387
+ await fs.access(targetPath);
389
388
  return true;
390
- } catch (err) {
391
- return err?.code === 'EPERM';
392
- }
393
- }
394
-
395
- async function findAutoAttachWorkspaceCandidate({ excludeCacheDirectory = null } = {}) {
396
- const cacheRoot = path.join(getGlobalCacheDir(), 'heuristic-mcp');
397
- const normalizedExclude = normalizePathForCompare(excludeCacheDirectory);
398
-
399
- let cacheDirs = [];
400
- try {
401
- cacheDirs = await fs.readdir(cacheRoot, { withFileTypes: true });
402
389
  } catch {
403
- return null;
404
- }
405
-
406
- const candidatesByWorkspace = new Map();
407
- const preferredWorkspaceFromEnv = (await detectRuntimeWorkspaceFromEnv())?.workspacePath ?? null;
408
- const normalizedPreferred = normalizePathForCompare(preferredWorkspaceFromEnv);
409
-
410
- const upsertCandidate = (candidate) => {
411
- const key = normalizePathForCompare(candidate.workspace);
412
- const existing = candidatesByWorkspace.get(key);
413
- if (!existing || candidate.rank > existing.rank) {
414
- candidatesByWorkspace.set(key, candidate);
415
- }
416
- };
417
-
418
- for (const entry of cacheDirs) {
419
- if (!entry.isDirectory()) continue;
420
- const cacheDirectory = path.join(cacheRoot, entry.name);
421
- if (normalizedExclude && normalizePathForCompare(cacheDirectory) === normalizedExclude)
422
- continue;
423
-
424
- const lockPath = path.join(cacheDirectory, 'server.lock.json');
425
- try {
426
- const rawLock = await fs.readFile(lockPath, 'utf-8');
427
- const lock = JSON.parse(rawLock);
428
- if (!isProcessAlive(lock?.pid)) continue;
429
- const workspace = path.resolve(lock?.workspace || '');
430
- if (!workspace || isNonProjectDirectory(workspace)) continue;
431
- const stats = await fs.stat(workspace).catch(() => null);
432
- if (!stats?.isDirectory()) continue;
433
- const rank = Date.parse(lock?.startedAt || '') || 0;
434
- upsertCandidate({
435
- workspace,
436
- cacheDirectory,
437
- source: `lock:${lock.pid}`,
438
- rank,
439
- });
440
- continue;
441
- } catch {}
442
-
443
- const metaPath = path.join(cacheDirectory, 'meta.json');
444
- try {
445
- const rawMeta = await fs.readFile(metaPath, 'utf-8');
446
- const meta = JSON.parse(rawMeta);
447
- const workspace = path.resolve(meta?.workspace || '');
448
- if (!workspace || isNonProjectDirectory(workspace)) continue;
449
- const stats = await fs.stat(workspace).catch(() => null);
450
- if (!stats?.isDirectory()) continue;
451
- const filesIndexed = Number(meta?.filesIndexed || 0);
452
- if (filesIndexed <= 0) continue;
453
- const rank = Date.parse(meta?.lastSaveTime || '') || 0;
454
- upsertCandidate({
455
- workspace,
456
- cacheDirectory,
457
- source: 'meta',
458
- rank,
459
- });
460
- } catch {}
461
- }
462
-
463
- const candidates = Array.from(candidatesByWorkspace.values());
464
- if (candidates.length === 0) return null;
465
- if (normalizedPreferred) {
466
- const preferred = candidates.find(
467
- (candidate) => normalizePathForCompare(candidate.workspace) === normalizedPreferred
468
- );
469
- if (preferred) return preferred;
470
- }
471
- if (candidates.length === 1) return candidates[0];
472
- return null;
473
- }
474
-
475
- async function maybeAutoSwitchWorkspace(request) {
476
- if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return null;
477
- if (!setWorkspaceFeatureInstance || !config?.searchDirectory) return null;
478
- if (request?.params?.name === 'f_set_workspace') return null;
479
-
480
- const detected = await detectRuntimeWorkspaceFromEnv();
481
- if (!detected) return null;
482
- if (isNonProjectDirectory(detected.workspacePath)) {
483
- console.info(
484
- `[Server] Ignoring auto-switch candidate from env ${detected.envKey}: non-project path ${detected.workspacePath}`
485
- );
486
- return null;
487
- }
488
-
489
- const currentWorkspace = normalizePathForCompare(config.searchDirectory);
490
- const detectedWorkspace = normalizePathForCompare(detected.workspacePath);
491
- if (detectedWorkspace === currentWorkspace) return detected.workspacePath;
492
-
493
- await maybeAutoSwitchWorkspaceToPath(detected.workspacePath, {
494
- source: `env ${detected.envKey}`,
495
- reindex: false,
496
- });
497
- return detected.workspacePath;
498
- }
499
-
500
- async function detectWorkspaceFromRoots({ quiet = false } = {}) {
501
- try {
502
- const caps = server.getClientCapabilities();
503
- if (!caps?.roots) {
504
- rootsCapabilitySupported = false;
505
- if (!quiet) {
506
- console.info(
507
- '[Server] Client does not support roots capability, skipping workspace auto-detection.'
508
- );
509
- }
510
- return null;
511
- }
512
- rootsCapabilitySupported = true;
513
-
514
- const result = await server.listRoots();
515
- if (!result?.roots?.length) {
516
- if (!quiet) {
517
- console.info('[Server] Client returned no roots.');
518
- }
519
- return null;
520
- }
521
-
522
- if (!quiet) {
523
- console.info(`[Server] MCP roots received: ${result.roots.map((r) => r.uri).join(', ')}`);
524
- }
525
-
526
- const rootPaths = result.roots
527
- .map((r) => r.uri)
528
- .filter((uri) => uri.startsWith('file://'))
529
- .map((uri) => {
530
- try {
531
- return fileURLToPath(uri);
532
- } catch {
533
- return null;
534
- }
535
- })
536
- .filter(Boolean);
537
-
538
- if (rootPaths.length === 0) {
539
- if (!quiet) {
540
- console.info('[Server] No valid file:// roots found.');
541
- }
542
- return null;
543
- }
544
-
545
- return path.resolve(rootPaths[0]);
546
- } catch (err) {
547
- if (!quiet) {
548
- console.warn(`[Server] MCP roots detection failed (non-fatal): ${err.message}`);
549
- }
550
- return null;
551
- }
552
- }
553
-
554
- async function maybeAutoSwitchWorkspaceToPath(
555
- targetWorkspacePath,
556
- { source, reindex = false } = {}
557
- ) {
558
- if (!setWorkspaceFeatureInstance || !config?.searchDirectory) return;
559
- if (!targetWorkspacePath) return;
560
- if (isNonProjectDirectory(targetWorkspacePath)) {
561
- if (config?.verbose) {
562
- console.info(
563
- `[Server] Ignoring auto-switch candidate from ${source || 'unknown'}: non-project path ${targetWorkspacePath}`
564
- );
565
- }
566
- return;
567
- }
568
-
569
- const currentWorkspace = normalizePathForCompare(config.searchDirectory);
570
- const targetWorkspace = normalizePathForCompare(targetWorkspacePath);
571
- if (targetWorkspace === currentWorkspace) return;
572
-
573
- if (autoWorkspaceSwitchPromise) {
574
- await autoWorkspaceSwitchPromise;
575
- const currentNow = normalizePathForCompare(config.searchDirectory);
576
- const targetNow = normalizePathForCompare(targetWorkspacePath);
577
- if (targetNow === currentNow) return;
578
- }
579
-
580
- const switchPromise = (async () => {
581
- const latestWorkspace = normalizePathForCompare(config.searchDirectory);
582
- console.info(
583
- `[Server] Auto-switching workspace from ${latestWorkspace} to ${targetWorkspacePath} (${source || 'auto'})`
584
- );
585
- const result = await setWorkspaceFeatureInstance.execute({
586
- workspacePath: targetWorkspacePath,
587
- reindex,
588
- });
589
- if (!result.success) {
590
- console.warn(`[Server] Auto workspace switch failed (${source || 'auto'}): ${result.error}`);
591
- return;
592
- }
593
- trustWorkspacePath(targetWorkspacePath);
594
- })();
595
- autoWorkspaceSwitchPromise = switchPromise;
596
-
597
- try {
598
- await switchPromise;
599
- } finally {
600
- if (autoWorkspaceSwitchPromise === switchPromise) {
601
- autoWorkspaceSwitchPromise = null;
602
- }
603
- }
604
- }
605
-
606
- async function maybeAutoSwitchWorkspaceFromRoots(request) {
607
- if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return null;
608
- if (!setWorkspaceFeatureInstance || !config?.searchDirectory) return null;
609
- if (request?.params?.name === 'f_set_workspace') return null;
610
- if (rootsCapabilitySupported === false) return null;
611
-
612
- if (rootsProbeInFlight) {
613
- return await rootsProbeInFlight;
614
- }
615
- const now = Date.now();
616
- if (now - lastRootsProbeTime < ROOTS_PROBE_COOLDOWN_MS) return null;
617
- lastRootsProbeTime = now;
618
-
619
- rootsProbeInFlight = (async () => {
620
- const rootWorkspace = await detectWorkspaceFromRoots({ quiet: true });
621
- if (!rootWorkspace) return null;
622
- await maybeAutoSwitchWorkspaceToPath(rootWorkspace, {
623
- source: 'roots probe',
624
- reindex: false,
625
- });
626
- return rootWorkspace;
627
- })();
628
-
629
- try {
630
- return await rootsProbeInFlight;
631
- } finally {
632
- rootsProbeInFlight = null;
390
+ return false;
633
391
  }
634
392
  }
635
393
 
636
- const features = [
637
- {
638
- module: HybridSearchFeature,
639
- instance: null,
640
- handler: HybridSearchFeature.handleToolCall,
641
- },
642
- {
643
- module: IndexCodebaseFeature,
644
- instance: null,
645
- handler: IndexCodebaseFeature.handleToolCall,
646
- },
647
- {
648
- module: ClearCacheFeature,
649
- instance: null,
650
- handler: ClearCacheFeature.handleToolCall,
651
- },
652
- {
653
- module: FindSimilarCodeFeature,
654
- instance: null,
655
- handler: FindSimilarCodeFeature.handleToolCall,
656
- },
657
- {
658
- module: AnnConfigFeature,
659
- instance: null,
660
- handler: AnnConfigFeature.handleToolCall,
661
- },
662
- {
663
- module: PackageVersionFeature,
664
- instance: null,
665
- handler: PackageVersionFeature.handleToolCall,
666
- },
667
- {
668
- module: SetWorkspaceFeature,
669
- instance: null,
670
- handler: null,
671
- },
672
- ];
673
-
674
- async function initialize(workspaceDir) {
675
- config = await loadConfig(workspaceDir);
676
-
677
- if (config.enableCache && config.cacheCleanup?.autoCleanup) {
678
- console.info('[Server] Running automatic cache cleanup...');
679
- const results = await clearStaleCaches({
680
- ...config.cacheCleanup,
681
- logger: console,
682
- });
683
- if (results.removed > 0) {
684
- console.info(
685
- `[Server] Removed ${results.removed} stale cache ${results.removed === 1 ? 'directory' : 'directories'}`
686
- );
687
- }
688
- }
689
-
690
- const isTest = isTestRuntime();
691
- if (config.enableExplicitGc && typeof global.gc !== 'function' && !isTest) {
692
- console.warn(
693
- '[Server] enableExplicitGc=true but this process was not started with --expose-gc; continuing with explicit GC disabled.'
694
- );
695
- console.warn(
696
- '[Server] Tip: start with "npm start" or add --expose-gc to enable explicit GC again.'
697
- );
698
- config.enableExplicitGc = false;
699
- }
700
-
701
- let mainBackendConfigured = false;
702
- let nativeOnnxAvailable = null;
703
- const ensureMainOnnxBackend = () => {
704
- if (mainBackendConfigured) return;
705
- nativeOnnxAvailable = configureNativeOnnxBackend({
706
- log: config.verbose ? console.info : null,
707
- label: '[Server]',
708
- threads: {
709
- intraOpNumThreads: ONNX_THREAD_LIMIT,
710
- interOpNumThreads: 1,
711
- },
712
- });
713
- mainBackendConfigured = true;
714
- };
715
-
716
- ensureMainOnnxBackend();
717
- if (nativeOnnxAvailable === false) {
718
- try {
719
- const { env } = await getTransformers();
720
- if (env?.backends?.onnx?.wasm) {
721
- env.backends.onnx.wasm.numThreads = ONNX_THREAD_LIMIT;
722
- }
723
- } catch {}
724
- const status = getNativeOnnxStatus();
725
- const reason = status?.message || 'onnxruntime-node not available';
726
- console.warn(`[Server] Native ONNX backend unavailable (${reason}); using WASM backend.`);
727
- console.warn(
728
- '[Server] Auto-safety: disabling workers and forcing embeddingProcessPerBatch for memory isolation.'
729
- );
730
- if (config.workerThreads !== 0) {
731
- config.workerThreads = 0;
732
- }
733
- if (!config.embeddingProcessPerBatch) {
734
- config.embeddingProcessPerBatch = true;
735
- }
736
- }
737
- const resolutionSource = config.workspaceResolution?.source || 'unknown';
738
- if (resolutionSource === 'workspace-arg' || resolutionSource === 'env') {
739
- trustWorkspacePath(config.searchDirectory);
740
- }
741
- const isSystemFallbackWorkspace =
742
- (resolutionSource === 'cwd' || resolutionSource === 'cwd-root-search') &&
743
- isNonProjectDirectory(config.searchDirectory);
744
-
745
- let pidPath = null;
746
- let logPath = null;
747
- if (isSystemFallbackWorkspace) {
748
- workspaceLockAcquired = false;
749
- console.warn(
750
- `[Server] System fallback workspace detected (${config.searchDirectory}); running in lightweight read-only mode.`
751
- );
752
- console.warn('[Server] Skipping lock/PID/log file setup for fallback workspace.');
753
- } else {
754
- if (config.autoStopOtherServersOnStartup !== false) {
755
- const globalCacheRoot = path.join(getGlobalCacheDir(), 'heuristic-mcp');
756
- const { killed, failed } = await stopOtherHeuristicServers({
757
- globalCacheRoot,
758
- currentCacheDirectory: config.cacheDirectory,
759
- });
760
- if (killed.length > 0) {
761
- const details = killed
762
- .map((entry) => `${entry.pid}${entry.workspace ? ` (${entry.workspace})` : ''}`)
763
- .join(', ');
764
- console.info(
765
- `[Server] Auto-stopped ${killed.length} stale heuristic-mcp server(s): ${details}`
766
- );
767
- }
768
- if (failed.length > 0) {
769
- const details = failed
770
- .map((entry) => `${entry.pid}${entry.workspace ? ` (${entry.workspace})` : ''}`)
771
- .join(', ');
772
- console.warn(
773
- `[Server] Failed to stop ${failed.length} older heuristic-mcp server(s): ${details}`
774
- );
775
- }
776
- }
777
-
778
- const lock = await acquireWorkspaceLock({
779
- cacheDirectory: config.cacheDirectory,
780
- workspaceDir: config.searchDirectory,
781
- });
782
- workspaceLockAcquired = lock.acquired;
783
- if (!workspaceLockAcquired) {
784
- console.warn(
785
- `[Server] Another heuristic-mcp instance is already running for this workspace (pid ${lock.ownerPid ?? 'unknown'}).`
786
- );
787
- console.warn(
788
- '[Server] Starting in secondary read-only mode: background indexing and cache writes are disabled for this instance.'
789
- );
790
- }
791
- [pidPath, logPath] = workspaceLockAcquired
792
- ? await Promise.all([
793
- setupPidFile({ pidFileName: PID_FILE_NAME, cacheDirectory: config.cacheDirectory }),
794
- setupFileLogging(config),
795
- ])
796
- : [null, await setupFileLogging(config)];
797
- }
798
- if (logPath) {
799
- console.info(`[Logs] Writing server logs to ${logPath}`);
800
- console.info(`[Logs] Log viewer: heuristic-mcp --logs --workspace "${config.searchDirectory}"`);
801
- }
802
- {
803
- const resolution = config.workspaceResolution || {};
804
- const sourceLabel =
805
- resolution.source === 'env' && resolution.envKey
806
- ? `env:${resolution.envKey}`
807
- : resolution.source || 'unknown';
808
- const baseLabel = resolution.baseDirectory || '(unknown)';
809
- const searchLabel = resolution.searchDirectory || config.searchDirectory;
810
- const overrideLabel = resolution.searchDirectoryFromConfig ? 'yes' : 'no';
811
- console.info(
812
- `[Server] Workspace resolved: source=${sourceLabel}, base=${baseLabel}, search=${searchLabel}, configOverride=${overrideLabel}`
813
- );
814
- if (resolution.fromPath) {
815
- console.info(`[Server] Workspace resolution origin cwd: ${resolution.fromPath}`);
816
- }
817
-
818
- const workspaceEnvProbe = Array.isArray(resolution.workspaceEnvProbe)
819
- ? resolution.workspaceEnvProbe
820
- : [];
821
- if (workspaceEnvProbe.length > 0) {
822
- const probePreview = workspaceEnvProbe.slice(0, 8).map((entry) => {
823
- const scope = entry?.priority ? 'priority' : 'diagnostic';
824
- const status = entry?.resolvedPath
825
- ? `valid:${entry.resolvedPath}`
826
- : `invalid:${entry?.value}`;
827
- return `${entry?.key}[${scope}]=${status}`;
828
- });
829
- const suffix = workspaceEnvProbe.length > 8 ? ` (+${workspaceEnvProbe.length - 8} more)` : '';
830
- console.info(`[Server] Workspace env probe: ${probePreview.join('; ')}${suffix}`);
831
- }
832
- }
833
-
834
- console.info(
835
- `[Server] Config: workerThreads=${config.workerThreads}, embeddingProcessPerBatch=${config.embeddingProcessPerBatch}`
836
- );
837
- console.info(
838
- `[Server] Config: vectorStoreLoadMode=${config.vectorStoreLoadMode}, vectorCacheEntries=${config.vectorCacheEntries}`
839
- );
840
-
841
- if (pidPath) {
842
- console.info(`[Server] PID file: ${pidPath}`);
394
+ async function resolveWorkspaceCacheDirectory(workspacePath, globalCacheRoot) {
395
+ const candidates = getWorkspaceCachePathCandidates(workspacePath, globalCacheRoot);
396
+ if (await pathExists(candidates.canonical)) {
397
+ return { cacheDirectory: candidates.canonical, mode: 'canonical' };
843
398
  }
844
-
845
- try {
846
- const globalCache = path.join(getGlobalCacheDir(), 'heuristic-mcp');
847
- const localCache = path.join(process.cwd(), '.heuristic-mcp');
848
- console.info(`[Server] Cache debug: Global=${globalCache}, Local=${localCache}`);
849
- console.info(`[Server] Process CWD: ${process.cwd()}`);
850
- console.info(
851
- `[Server] Resolved workspace: ${config.searchDirectory} (via ${config.workspaceResolution?.source || 'unknown'})`
852
- );
853
- } catch (_e) {}
854
-
855
- let stopStartupMemory = null;
856
- if (config.verbose) {
857
- logMemory('[Server] Memory (startup)');
858
- stopStartupMemory = startMemoryLogger('[Server] Memory (startup)', MEMORY_LOG_INTERVAL_MS);
399
+ if (
400
+ candidates.compatDriveCase !== candidates.canonical &&
401
+ (await pathExists(candidates.compatDriveCase))
402
+ ) {
403
+ return { cacheDirectory: candidates.compatDriveCase, mode: 'compat-drivecase' };
859
404
  }
860
-
861
- try {
862
- await fs.access(config.searchDirectory);
863
- } catch {
864
- console.error(`[Server] Error: Search directory "${config.searchDirectory}" does not exist`);
865
- process.exit(1);
405
+ if (candidates.legacy !== candidates.canonical && (await pathExists(candidates.legacy))) {
406
+ return { cacheDirectory: candidates.legacy, mode: 'legacy' };
866
407
  }
867
-
868
- console.info('[Server] Initializing features...');
869
- let cachedEmbedderPromise = null;
870
- const lazyEmbedder = async (...args) => {
871
- if (!cachedEmbedderPromise) {
872
- ensureMainOnnxBackend();
873
- console.info(`[Server] Loading AI embedding model: ${config.embeddingModel}...`);
874
- const modelLoadStart = Date.now();
875
- const { pipeline } = await getTransformers();
876
- cachedEmbedderPromise = pipeline('feature-extraction', config.embeddingModel, {
877
- quantized: true,
878
- dtype: 'fp32',
879
- session_options: {
880
- numThreads: 2,
881
- intraOpNumThreads: 2,
882
- interOpNumThreads: 2,
883
- },
884
- }).then((model) => {
885
- const loadSeconds = ((Date.now() - modelLoadStart) / 1000).toFixed(1);
886
- console.info(
887
- `[Server] Embedding model loaded (${loadSeconds}s). Starting intensive indexing (expect high CPU)...`
888
- );
889
- console.info(`[Server] Embedding model ready: ${config.embeddingModel}`);
890
- if (config.verbose) {
891
- logMemory('[Server] Memory (after model load)');
892
- }
893
- return model;
894
- });
895
- }
896
- const model = await cachedEmbedderPromise;
897
- return model(...args);
898
- };
899
-
900
- const unloader = async () => {
901
- if (!cachedEmbedderPromise) return false;
902
- try {
903
- const model = await cachedEmbedderPromise;
904
- if (model && typeof model.dispose === 'function') {
905
- await model.dispose();
906
- }
907
- cachedEmbedderPromise = null;
908
- if (typeof global.gc === 'function') {
909
- global.gc();
910
- }
911
- if (config.verbose) {
912
- logMemory('[Server] Memory (after model unload)');
913
- }
914
- console.info('[Server] Embedding model unloaded to free memory.');
915
- return true;
916
- } catch (err) {
917
- console.warn(`[Server] Error unloading embedding model: ${err.message}`);
918
- cachedEmbedderPromise = null;
919
- return false;
920
- }
921
- };
922
-
923
- embedder = lazyEmbedder;
924
- unloadMainEmbedder = unloader;
925
- const preloadEmbeddingModel = async () => {
926
- if (config.preloadEmbeddingModel === false) return;
927
- try {
928
- console.info('[Server] Preloading embedding model (background)...');
929
- await embedder(' ');
930
- } catch (err) {
931
- console.warn(`[Server] Embedding model preload failed: ${err.message}`);
932
- }
933
- };
934
-
935
- if (config.vectorStoreFormat === 'binary') {
936
- try {
937
- await cleanupStaleBinaryArtifacts(config.cacheDirectory, { logger: console });
938
- } catch (err) {
939
- console.warn(`[Cache] Startup temp cleanup failed: ${err.message}`);
940
- }
941
- }
942
-
943
- cache = new EmbeddingsCache(config);
944
- assertCacheContract(cache);
945
- console.info(`[Server] Cache directory: ${config.cacheDirectory}`);
946
-
947
- indexer = new CodebaseIndexer(embedder, cache, config, server);
948
- hybridSearch = new HybridSearch(embedder, cache, config);
949
- const cacheClearer = new ClearCacheFeature.CacheClearer(embedder, cache, config, indexer);
950
- const findSimilarCode = new FindSimilarCodeFeature.FindSimilarCode(embedder, cache, config);
951
- const annConfig = new AnnConfigFeature.AnnConfigTool(cache, config);
952
-
953
- features[0].instance = hybridSearch;
954
- features[1].instance = indexer;
955
- features[2].instance = cacheClearer;
956
- features[3].instance = findSimilarCode;
957
- features[4].instance = annConfig;
958
-
959
- const setWorkspaceInstance = new SetWorkspaceFeature.SetWorkspaceFeature(
960
- config,
961
- cache,
962
- indexer,
963
- getGlobalCacheDir
964
- );
965
- setWorkspaceFeatureInstance = setWorkspaceInstance;
966
- features[6].instance = setWorkspaceInstance;
967
- features[6].handler = SetWorkspaceFeature.createHandleToolCall(setWorkspaceInstance);
968
-
969
- server.hybridSearch = hybridSearch;
970
-
971
- const startBackgroundTasks = async () => {
972
- const stopStartupMemoryLogger = () => {
973
- if (stopStartupMemory) {
974
- stopStartupMemory();
975
- }
976
- };
977
- const handleCorruptCacheAfterLoad = async ({ context, canReindex }) => {
978
- if (!cache.consumeAutoReindex()) return false;
979
- cache.clearInMemoryState();
980
- await recordBinaryStoreCorruption(config.cacheDirectory, {
981
- context,
982
- action: canReindex ? 'auto-cleared' : 'secondary-readonly-blocked',
983
- });
984
- if (canReindex) {
985
- console.warn(
986
- `[Server] Cache corruption detected while ${context}; in-memory cache was cleared and a full re-index will run.`
987
- );
988
- } else {
989
- console.warn(
990
- `[Server] Cache corruption detected while ${context}. This server is secondary read-only and cannot re-index. Restart the MCP client session for this workspace or use the primary instance to rebuild the cache.`
991
- );
992
- }
993
- return true;
994
- };
995
- const tryAutoAttachWorkspaceCache = async (
996
- reason,
997
- { canReindex = workspaceLockAcquired } = {}
998
- ) => {
999
- const candidate = await findAutoAttachWorkspaceCandidate({
1000
- excludeCacheDirectory: config.cacheDirectory,
1001
- });
1002
- if (!candidate) {
1003
- console.warn(
1004
- `[Server] Auto-attach skipped (${reason}): no unambiguous workspace cache candidate found.`
1005
- );
1006
- return false;
1007
- }
1008
-
1009
- config.searchDirectory = candidate.workspace;
1010
- config.cacheDirectory = candidate.cacheDirectory;
1011
- await fs.mkdir(config.cacheDirectory, { recursive: true });
1012
- if (config.vectorStoreFormat === 'binary') {
1013
- await cleanupStaleBinaryArtifacts(config.cacheDirectory, { logger: console });
1014
- }
1015
- await cache.load();
1016
- await handleCorruptCacheAfterLoad({
1017
- context: `auto-attaching workspace cache (${reason})`,
1018
- canReindex,
1019
- });
1020
- console.info(
1021
- `[Server] Auto-attached workspace cache (${reason}): ${candidate.workspace} via ${candidate.source}`
1022
- );
1023
- if (config.verbose) {
1024
- logMemory('[Server] Memory (after cache load)');
1025
- }
1026
- return true;
1027
- };
1028
-
1029
- const resolutionSource = config.workspaceResolution?.source || 'unknown';
1030
- const isSystemFallback =
1031
- (resolutionSource === 'cwd' || resolutionSource === 'cwd-root-search') &&
1032
- isNonProjectDirectory(config.searchDirectory);
1033
-
1034
- if (isSystemFallback) {
1035
- try {
1036
- console.warn(
1037
- `[Server] Detected system fallback workspace: ${config.searchDirectory}. Attempting cache auto-attach.`
1038
- );
1039
- const attached = await tryAutoAttachWorkspaceCache('system-fallback', {
1040
- canReindex: workspaceLockAcquired,
1041
- });
1042
- if (!attached) {
1043
- console.warn(
1044
- '[Server] Waiting for a proper workspace root (MCP roots, env vars, or f_set_workspace).'
1045
- );
1046
- }
1047
- } finally {
1048
- stopStartupMemoryLogger();
1049
- }
1050
- return;
1051
- }
1052
-
1053
- if (!workspaceLockAcquired) {
1054
- try {
1055
- console.info('[Server] Secondary instance detected; loading cache in read-only mode.');
1056
- await cache.load();
1057
- await handleCorruptCacheAfterLoad({
1058
- context: 'loading cache in secondary read-only mode',
1059
- canReindex: false,
1060
- });
1061
- const storeSize = cache.getStoreSize();
1062
- if (storeSize === 0) {
1063
- await tryAutoAttachWorkspaceCache('secondary-empty-cache', { canReindex: false });
1064
- }
1065
- if (config.verbose) {
1066
- logMemory('[Server] Memory (after cache load)');
1067
- }
1068
- } finally {
1069
- stopStartupMemoryLogger();
1070
- }
1071
- console.info('[Server] Secondary instance ready; skipping background indexing.');
1072
- return;
1073
- }
1074
-
1075
- void preloadEmbeddingModel();
1076
-
1077
- try {
1078
- console.info('[Server] Loading cache (deferred)...');
1079
- await cache.load();
1080
- await handleCorruptCacheAfterLoad({ context: 'startup cache load', canReindex: true });
1081
- if (config.verbose) {
1082
- logMemory('[Server] Memory (after cache load)');
1083
- }
1084
- } finally {
1085
- stopStartupMemoryLogger();
1086
- }
1087
-
1088
- console.info('[Server] Starting background indexing (delayed)...');
1089
-
1090
- setTimeout(() => {
1091
- indexer
1092
- .indexAll()
1093
- .then(() => {
1094
- if (config.watchFiles) {
1095
- indexer.setupFileWatcher();
1096
- }
1097
- })
1098
- .catch((err) => {
1099
- console.error('[Server] Background indexing error:', err.message);
1100
- });
1101
- }, BACKGROUND_INDEX_DELAY_MS);
1102
- };
1103
-
1104
- return { startBackgroundTasks, config };
408
+ return { cacheDirectory: candidates.canonical, mode: 'canonical' };
1105
409
  }
1106
-
1107
- const server = new Server(
1108
- {
1109
- name: 'heuristic-mcp',
1110
- version: packageJson.version,
1111
- },
1112
- {
1113
- capabilities: {
1114
- tools: {},
1115
- resources: {},
1116
- },
1117
- }
1118
- );
1119
-
410
+
411
+ function isProcessAlive(pid) {
412
+ if (!Number.isInteger(pid) || pid <= 0) return false;
413
+ try {
414
+ process.kill(pid, 0);
415
+ return true;
416
+ } catch (err) {
417
+ return err?.code === 'EPERM';
418
+ }
419
+ }
420
+
421
+ async function findAutoAttachWorkspaceCandidate({ excludeCacheDirectory = null } = {}) {
422
+ const cacheRoot = path.join(getGlobalCacheDir(), 'heuristic-mcp');
423
+ const normalizedExclude = normalizePathForCompare(excludeCacheDirectory);
424
+
425
+ let cacheDirs = [];
426
+ try {
427
+ cacheDirs = await fs.readdir(cacheRoot, { withFileTypes: true });
428
+ } catch {
429
+ return null;
430
+ }
431
+
432
+ const candidatesByWorkspace = new Map();
433
+ const preferredWorkspaceFromEnv = (await detectRuntimeWorkspaceFromEnv())?.workspacePath ?? null;
434
+ const normalizedPreferred = normalizePathForCompare(preferredWorkspaceFromEnv);
435
+
436
+ const upsertCandidate = (candidate) => {
437
+ const key = normalizePathForCompare(candidate.workspace);
438
+ const existing = candidatesByWorkspace.get(key);
439
+ if (!existing || candidate.rank > existing.rank) {
440
+ candidatesByWorkspace.set(key, candidate);
441
+ }
442
+ };
443
+
444
+ for (const entry of cacheDirs) {
445
+ if (!entry.isDirectory()) continue;
446
+ const cacheDirectory = path.join(cacheRoot, entry.name);
447
+ if (normalizedExclude && normalizePathForCompare(cacheDirectory) === normalizedExclude)
448
+ continue;
449
+
450
+ const lockPath = path.join(cacheDirectory, 'server.lock.json');
451
+ try {
452
+ const rawLock = await fs.readFile(lockPath, 'utf-8');
453
+ const lock = JSON.parse(rawLock);
454
+ if (!isProcessAlive(lock?.pid)) continue;
455
+ const workspace = path.resolve(lock?.workspace || '');
456
+ if (!workspace || isNonProjectDirectory(workspace)) continue;
457
+ const stats = await fs.stat(workspace).catch(() => null);
458
+ if (!stats?.isDirectory()) continue;
459
+ const rank = Date.parse(lock?.startedAt || '') || 0;
460
+ upsertCandidate({
461
+ workspace,
462
+ cacheDirectory,
463
+ source: `lock:${lock.pid}`,
464
+ rank,
465
+ });
466
+ continue;
467
+ } catch {}
468
+
469
+ const metaPath = path.join(cacheDirectory, 'meta.json');
470
+ try {
471
+ const rawMeta = await fs.readFile(metaPath, 'utf-8');
472
+ const meta = JSON.parse(rawMeta);
473
+ const workspace = path.resolve(meta?.workspace || '');
474
+ if (!workspace || isNonProjectDirectory(workspace)) continue;
475
+ const stats = await fs.stat(workspace).catch(() => null);
476
+ if (!stats?.isDirectory()) continue;
477
+ const filesIndexed = Number(meta?.filesIndexed || 0);
478
+ if (filesIndexed <= 0) continue;
479
+ const rank = Date.parse(meta?.lastSaveTime || '') || 0;
480
+ upsertCandidate({
481
+ workspace,
482
+ cacheDirectory,
483
+ source: 'meta',
484
+ rank,
485
+ });
486
+ } catch {}
487
+ }
488
+
489
+ const candidates = Array.from(candidatesByWorkspace.values());
490
+ if (candidates.length === 0) return null;
491
+ if (normalizedPreferred) {
492
+ const preferred = candidates.find(
493
+ (candidate) => normalizePathForCompare(candidate.workspace) === normalizedPreferred
494
+ );
495
+ if (preferred) return preferred;
496
+ }
497
+ if (candidates.length === 1) return candidates[0];
498
+ return null;
499
+ }
500
+
501
+ async function maybeAutoSwitchWorkspace(request) {
502
+ if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return null;
503
+ if (!setWorkspaceFeatureInstance || !config?.searchDirectory) return null;
504
+ if (request?.params?.name === 'f_set_workspace') return null;
505
+
506
+ const detected = await detectRuntimeWorkspaceFromEnv();
507
+ if (!detected) return null;
508
+ if (isNonProjectDirectory(detected.workspacePath)) {
509
+ console.info(
510
+ `[Server] Ignoring auto-switch candidate from env ${detected.envKey}: non-project path ${detected.workspacePath}`
511
+ );
512
+ return null;
513
+ }
514
+
515
+ const currentWorkspace = normalizePathForCompare(config.searchDirectory);
516
+ const detectedWorkspace = normalizePathForCompare(detected.workspacePath);
517
+ if (detectedWorkspace === currentWorkspace) return detected.workspacePath;
518
+
519
+ await maybeAutoSwitchWorkspaceToPath(detected.workspacePath, {
520
+ source: `env ${detected.envKey}`,
521
+ reindex: false,
522
+ });
523
+ return detected.workspacePath;
524
+ }
525
+
526
+ async function detectWorkspaceFromRoots({ quiet = false } = {}) {
527
+ try {
528
+ const caps = server.getClientCapabilities();
529
+ if (!caps?.roots) {
530
+ rootsCapabilitySupported = false;
531
+ if (!quiet) {
532
+ console.info(
533
+ '[Server] Client does not support roots capability, skipping workspace auto-detection.'
534
+ );
535
+ }
536
+ return null;
537
+ }
538
+ rootsCapabilitySupported = true;
539
+
540
+ const result = await server.listRoots();
541
+ if (!result?.roots?.length) {
542
+ if (!quiet) {
543
+ console.info('[Server] Client returned no roots.');
544
+ }
545
+ return null;
546
+ }
547
+
548
+ if (!quiet) {
549
+ console.info(`[Server] MCP roots received: ${result.roots.map((r) => r.uri).join(', ')}`);
550
+ }
551
+
552
+ const rootPaths = result.roots
553
+ .map((r) => r.uri)
554
+ .filter((uri) => uri.startsWith('file://'))
555
+ .map((uri) => {
556
+ try {
557
+ return fileURLToPath(uri);
558
+ } catch {
559
+ return null;
560
+ }
561
+ })
562
+ .filter(Boolean);
563
+
564
+ if (rootPaths.length === 0) {
565
+ if (!quiet) {
566
+ console.info('[Server] No valid file:// roots found.');
567
+ }
568
+ return null;
569
+ }
570
+
571
+ return path.resolve(rootPaths[0]);
572
+ } catch (err) {
573
+ if (!quiet) {
574
+ console.warn(`[Server] MCP roots detection failed (non-fatal): ${err.message}`);
575
+ }
576
+ return null;
577
+ }
578
+ }
579
+
580
+ async function maybeAutoSwitchWorkspaceToPath(
581
+ targetWorkspacePath,
582
+ { source, reindex = false } = {}
583
+ ) {
584
+ if (!setWorkspaceFeatureInstance || !config?.searchDirectory) return;
585
+ if (!targetWorkspacePath) return;
586
+ if (isNonProjectDirectory(targetWorkspacePath)) {
587
+ if (config?.verbose) {
588
+ console.info(
589
+ `[Server] Ignoring auto-switch candidate from ${source || 'unknown'}: non-project path ${targetWorkspacePath}`
590
+ );
591
+ }
592
+ return;
593
+ }
594
+
595
+ const currentWorkspace = normalizePathForCompare(config.searchDirectory);
596
+ const targetWorkspace = normalizePathForCompare(targetWorkspacePath);
597
+ if (targetWorkspace === currentWorkspace) return;
598
+
599
+ if (autoWorkspaceSwitchPromise) {
600
+ await autoWorkspaceSwitchPromise;
601
+ const currentNow = normalizePathForCompare(config.searchDirectory);
602
+ const targetNow = normalizePathForCompare(targetWorkspacePath);
603
+ if (targetNow === currentNow) return;
604
+ }
605
+
606
+ const switchPromise = (async () => {
607
+ const latestWorkspace = normalizePathForCompare(config.searchDirectory);
608
+ console.info(
609
+ `[Server] Auto-switching workspace from ${latestWorkspace} to ${targetWorkspacePath} (${source || 'auto'})`
610
+ );
611
+ const result = await setWorkspaceFeatureInstance.execute({
612
+ workspacePath: targetWorkspacePath,
613
+ reindex,
614
+ });
615
+ if (!result.success) {
616
+ console.warn(`[Server] Auto workspace switch failed (${source || 'auto'}): ${result.error}`);
617
+ return;
618
+ }
619
+ trustWorkspacePath(targetWorkspacePath);
620
+ })();
621
+ autoWorkspaceSwitchPromise = switchPromise;
622
+
623
+ try {
624
+ await switchPromise;
625
+ } finally {
626
+ if (autoWorkspaceSwitchPromise === switchPromise) {
627
+ autoWorkspaceSwitchPromise = null;
628
+ }
629
+ }
630
+ }
631
+
632
+ async function maybeAutoSwitchWorkspaceFromRoots(request) {
633
+ if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return null;
634
+ if (!setWorkspaceFeatureInstance || !config?.searchDirectory) return null;
635
+ if (request?.params?.name === 'f_set_workspace') return null;
636
+ if (rootsCapabilitySupported === false) return null;
637
+
638
+ if (rootsProbeInFlight) {
639
+ return await rootsProbeInFlight;
640
+ }
641
+ const now = Date.now();
642
+ if (now - lastRootsProbeTime < ROOTS_PROBE_COOLDOWN_MS) return null;
643
+ lastRootsProbeTime = now;
644
+
645
+ rootsProbeInFlight = (async () => {
646
+ const rootWorkspace = await detectWorkspaceFromRoots({ quiet: true });
647
+ if (!rootWorkspace) return null;
648
+ await maybeAutoSwitchWorkspaceToPath(rootWorkspace, {
649
+ source: 'roots probe',
650
+ reindex: false,
651
+ });
652
+ return rootWorkspace;
653
+ })();
654
+
655
+ try {
656
+ return await rootsProbeInFlight;
657
+ } finally {
658
+ rootsProbeInFlight = null;
659
+ }
660
+ }
661
+
662
+ const features = [
663
+ {
664
+ module: HybridSearchFeature,
665
+ instance: null,
666
+ handler: HybridSearchFeature.handleToolCall,
667
+ },
668
+ {
669
+ module: IndexCodebaseFeature,
670
+ instance: null,
671
+ handler: IndexCodebaseFeature.handleToolCall,
672
+ },
673
+ {
674
+ module: ClearCacheFeature,
675
+ instance: null,
676
+ handler: ClearCacheFeature.handleToolCall,
677
+ },
678
+ {
679
+ module: FindSimilarCodeFeature,
680
+ instance: null,
681
+ handler: FindSimilarCodeFeature.handleToolCall,
682
+ },
683
+ {
684
+ module: AnnConfigFeature,
685
+ instance: null,
686
+ handler: AnnConfigFeature.handleToolCall,
687
+ },
688
+ {
689
+ module: PackageVersionFeature,
690
+ instance: null,
691
+ handler: PackageVersionFeature.handleToolCall,
692
+ },
693
+ {
694
+ module: SetWorkspaceFeature,
695
+ instance: null,
696
+ handler: null,
697
+ },
698
+ ];
699
+
700
+ async function initialize(workspaceDir) {
701
+ config = await loadConfig(workspaceDir);
702
+
703
+ if (config.enableCache && config.cacheCleanup?.autoCleanup) {
704
+ console.info('[Server] Running automatic cache cleanup...');
705
+ const results = await clearStaleCaches({
706
+ ...config.cacheCleanup,
707
+ logger: console,
708
+ });
709
+ if (results.removed > 0) {
710
+ console.info(
711
+ `[Server] Removed ${results.removed} stale cache ${results.removed === 1 ? 'directory' : 'directories'}`
712
+ );
713
+ }
714
+ }
715
+
716
+ const isTest = isTestRuntime();
717
+ if (config.enableExplicitGc && typeof global.gc !== 'function' && !isTest) {
718
+ console.warn(
719
+ '[Server] enableExplicitGc=true but this process was not started with --expose-gc; continuing with explicit GC disabled.'
720
+ );
721
+ console.warn(
722
+ '[Server] Tip: start with "npm start" or add --expose-gc to enable explicit GC again.'
723
+ );
724
+ config.enableExplicitGc = false;
725
+ }
726
+
727
+ let mainBackendConfigured = false;
728
+ let nativeOnnxAvailable = null;
729
+ const ensureMainOnnxBackend = () => {
730
+ if (mainBackendConfigured) return;
731
+ nativeOnnxAvailable = configureNativeOnnxBackend({
732
+ log: config.verbose ? console.info : null,
733
+ label: '[Server]',
734
+ threads: {
735
+ intraOpNumThreads: ONNX_THREAD_LIMIT,
736
+ interOpNumThreads: 1,
737
+ },
738
+ });
739
+ mainBackendConfigured = true;
740
+ };
741
+
742
+ ensureMainOnnxBackend();
743
+ if (nativeOnnxAvailable === false) {
744
+ try {
745
+ const { env } = await getTransformers();
746
+ if (env?.backends?.onnx?.wasm) {
747
+ env.backends.onnx.wasm.numThreads = ONNX_THREAD_LIMIT;
748
+ }
749
+ } catch {}
750
+ const status = getNativeOnnxStatus();
751
+ const reason = status?.message || 'onnxruntime-node not available';
752
+ console.warn(`[Server] Native ONNX backend unavailable (${reason}); using WASM backend.`);
753
+ console.warn(
754
+ '[Server] Auto-safety: disabling workers and forcing embeddingProcessPerBatch for memory isolation.'
755
+ );
756
+ if (config.workerThreads !== 0) {
757
+ config.workerThreads = 0;
758
+ }
759
+ if (!config.embeddingProcessPerBatch) {
760
+ config.embeddingProcessPerBatch = true;
761
+ }
762
+ }
763
+ const resolutionSource = config.workspaceResolution?.source || 'unknown';
764
+ if (resolutionSource === 'workspace-arg' || resolutionSource === 'env') {
765
+ trustWorkspacePath(config.searchDirectory);
766
+ }
767
+ const isSystemFallbackWorkspace =
768
+ (resolutionSource === 'cwd' || resolutionSource === 'cwd-root-search') &&
769
+ isNonProjectDirectory(config.searchDirectory);
770
+
771
+ let pidPath = null;
772
+ let logPath = null;
773
+ if (isSystemFallbackWorkspace) {
774
+ workspaceLockAcquired = false;
775
+ console.warn(
776
+ `[Server] System fallback workspace detected (${config.searchDirectory}); running in lightweight read-only mode.`
777
+ );
778
+ console.warn('[Server] Skipping lock/PID/log file setup for fallback workspace.');
779
+ } else {
780
+ if (config.autoStopOtherServersOnStartup !== false) {
781
+ const globalCacheRoot = path.join(getGlobalCacheDir(), 'heuristic-mcp');
782
+ const { killed, failed } = await stopOtherHeuristicServers({
783
+ globalCacheRoot,
784
+ currentCacheDirectory: config.cacheDirectory,
785
+ });
786
+ if (killed.length > 0) {
787
+ const details = killed
788
+ .map((entry) => `${entry.pid}${entry.workspace ? ` (${entry.workspace})` : ''}`)
789
+ .join(', ');
790
+ console.info(
791
+ `[Server] Auto-stopped ${killed.length} stale heuristic-mcp server(s): ${details}`
792
+ );
793
+ }
794
+ if (failed.length > 0) {
795
+ const details = failed
796
+ .map((entry) => `${entry.pid}${entry.workspace ? ` (${entry.workspace})` : ''}`)
797
+ .join(', ');
798
+ console.warn(
799
+ `[Server] Failed to stop ${failed.length} older heuristic-mcp server(s): ${details}`
800
+ );
801
+ }
802
+ }
803
+
804
+ const lock = await acquireWorkspaceLock({
805
+ cacheDirectory: config.cacheDirectory,
806
+ workspaceDir: config.searchDirectory,
807
+ });
808
+ workspaceLockAcquired = lock.acquired;
809
+ if (!workspaceLockAcquired) {
810
+ console.warn(
811
+ `[Server] Another heuristic-mcp instance is already running for this workspace (pid ${lock.ownerPid ?? 'unknown'}).`
812
+ );
813
+ console.warn(
814
+ '[Server] Starting in secondary read-only mode: background indexing and cache writes are disabled for this instance.'
815
+ );
816
+ }
817
+ [pidPath, logPath] = workspaceLockAcquired
818
+ ? await Promise.all([
819
+ setupPidFile({ pidFileName: PID_FILE_NAME, cacheDirectory: config.cacheDirectory }),
820
+ setupFileLogging(config),
821
+ ])
822
+ : [null, await setupFileLogging(config)];
823
+ }
824
+ if (logPath) {
825
+ console.info(`[Logs] Writing server logs to ${logPath}`);
826
+ console.info(`[Logs] Log viewer: heuristic-mcp --logs --workspace "${config.searchDirectory}"`);
827
+ }
828
+ {
829
+ const resolution = config.workspaceResolution || {};
830
+ const sourceLabel =
831
+ resolution.source === 'env' && resolution.envKey
832
+ ? `env:${resolution.envKey}`
833
+ : resolution.source || 'unknown';
834
+ const baseLabel = resolution.baseDirectory || '(unknown)';
835
+ const searchLabel = resolution.searchDirectory || config.searchDirectory;
836
+ const overrideLabel = resolution.searchDirectoryFromConfig ? 'yes' : 'no';
837
+ console.info(
838
+ `[Server] Workspace resolved: source=${sourceLabel}, base=${baseLabel}, search=${searchLabel}, configOverride=${overrideLabel}`
839
+ );
840
+ if (resolution.fromPath) {
841
+ console.info(`[Server] Workspace resolution origin cwd: ${resolution.fromPath}`);
842
+ }
843
+
844
+ const workspaceEnvProbe = Array.isArray(resolution.workspaceEnvProbe)
845
+ ? resolution.workspaceEnvProbe
846
+ : [];
847
+ if (workspaceEnvProbe.length > 0) {
848
+ const probePreview = workspaceEnvProbe.slice(0, 8).map((entry) => {
849
+ const scope = entry?.priority ? 'priority' : 'diagnostic';
850
+ const status = entry?.resolvedPath
851
+ ? `valid:${entry.resolvedPath}`
852
+ : `invalid:${entry?.value}`;
853
+ return `${entry?.key}[${scope}]=${status}`;
854
+ });
855
+ const suffix = workspaceEnvProbe.length > 8 ? ` (+${workspaceEnvProbe.length - 8} more)` : '';
856
+ console.info(`[Server] Workspace env probe: ${probePreview.join('; ')}${suffix}`);
857
+ }
858
+ }
859
+
860
+ console.info(
861
+ `[Server] Config: workerThreads=${config.workerThreads}, embeddingProcessPerBatch=${config.embeddingProcessPerBatch}`
862
+ );
863
+ console.info(
864
+ `[Server] Config: vectorStoreLoadMode=${config.vectorStoreLoadMode}, vectorCacheEntries=${config.vectorCacheEntries}`
865
+ );
866
+
867
+ if (pidPath) {
868
+ console.info(`[Server] PID file: ${pidPath}`);
869
+ }
870
+
871
+ try {
872
+ const globalCache = path.join(getGlobalCacheDir(), 'heuristic-mcp');
873
+ const localCache = path.join(process.cwd(), '.heuristic-mcp');
874
+ console.info(`[Server] Cache debug: Global=${globalCache}, Local=${localCache}`);
875
+ console.info(`[Server] Process CWD: ${process.cwd()}`);
876
+ console.info(
877
+ `[Server] Resolved workspace: ${config.searchDirectory} (via ${config.workspaceResolution?.source || 'unknown'})`
878
+ );
879
+ } catch (_e) {}
880
+
881
+ let stopStartupMemory = null;
882
+ if (config.verbose) {
883
+ logMemory('[Server] Memory (startup)');
884
+ stopStartupMemory = startMemoryLogger('[Server] Memory (startup)', MEMORY_LOG_INTERVAL_MS);
885
+ }
886
+
887
+ try {
888
+ await fs.access(config.searchDirectory);
889
+ } catch {
890
+ console.error(`[Server] Error: Search directory "${config.searchDirectory}" does not exist`);
891
+ process.exit(1);
892
+ }
893
+
894
+ console.info('[Server] Initializing features...');
895
+ let cachedEmbedderPromise = null;
896
+ const lazyEmbedder = async (...args) => {
897
+ if (!cachedEmbedderPromise) {
898
+ ensureMainOnnxBackend();
899
+ console.info(`[Server] Loading AI embedding model: ${config.embeddingModel}...`);
900
+ const modelLoadStart = Date.now();
901
+ const { pipeline } = await getTransformers();
902
+ cachedEmbedderPromise = pipeline('feature-extraction', config.embeddingModel, {
903
+ quantized: true,
904
+ dtype: 'fp32',
905
+ session_options: {
906
+ numThreads: 2,
907
+ intraOpNumThreads: 2,
908
+ interOpNumThreads: 2,
909
+ },
910
+ }).then((model) => {
911
+ const loadSeconds = ((Date.now() - modelLoadStart) / 1000).toFixed(1);
912
+ console.info(
913
+ `[Server] Embedding model loaded (${loadSeconds}s). Starting intensive indexing (expect high CPU)...`
914
+ );
915
+ console.info(`[Server] Embedding model ready: ${config.embeddingModel}`);
916
+ if (config.verbose) {
917
+ logMemory('[Server] Memory (after model load)');
918
+ }
919
+ return model;
920
+ });
921
+ }
922
+ const model = await cachedEmbedderPromise;
923
+ return model(...args);
924
+ };
925
+
926
+ const unloader = async () => {
927
+ if (!cachedEmbedderPromise) return false;
928
+ try {
929
+ const model = await cachedEmbedderPromise;
930
+ if (model && typeof model.dispose === 'function') {
931
+ await model.dispose();
932
+ }
933
+ cachedEmbedderPromise = null;
934
+ if (typeof global.gc === 'function') {
935
+ global.gc();
936
+ }
937
+ if (config.verbose) {
938
+ logMemory('[Server] Memory (after model unload)');
939
+ }
940
+ console.info('[Server] Embedding model unloaded to free memory.');
941
+ return true;
942
+ } catch (err) {
943
+ console.warn(`[Server] Error unloading embedding model: ${err.message}`);
944
+ cachedEmbedderPromise = null;
945
+ return false;
946
+ }
947
+ };
948
+
949
+ embedder = lazyEmbedder;
950
+ unloadMainEmbedder = unloader;
951
+ const preloadEmbeddingModel = async () => {
952
+ if (config.preloadEmbeddingModel === false) return;
953
+ try {
954
+ console.info('[Server] Preloading embedding model (background)...');
955
+ await embedder(' ');
956
+ } catch (err) {
957
+ console.warn(`[Server] Embedding model preload failed: ${err.message}`);
958
+ }
959
+ };
960
+
961
+ if (config.vectorStoreFormat === 'binary') {
962
+ try {
963
+ await cleanupStaleBinaryArtifacts(config.cacheDirectory, { logger: console });
964
+ } catch (err) {
965
+ console.warn(`[Cache] Startup temp cleanup failed: ${err.message}`);
966
+ }
967
+ }
968
+
969
+ cache = new EmbeddingsCache(config);
970
+ assertCacheContract(cache);
971
+ console.info(`[Server] Cache directory: ${config.cacheDirectory}`);
972
+
973
+ indexer = new CodebaseIndexer(embedder, cache, config, server);
974
+ hybridSearch = new HybridSearch(embedder, cache, config);
975
+ const cacheClearer = new ClearCacheFeature.CacheClearer(embedder, cache, config, indexer);
976
+ const findSimilarCode = new FindSimilarCodeFeature.FindSimilarCode(embedder, cache, config);
977
+ const annConfig = new AnnConfigFeature.AnnConfigTool(cache, config);
978
+
979
+ features[0].instance = hybridSearch;
980
+ features[1].instance = indexer;
981
+ features[2].instance = cacheClearer;
982
+ features[3].instance = findSimilarCode;
983
+ features[4].instance = annConfig;
984
+
985
+ const setWorkspaceInstance = new SetWorkspaceFeature.SetWorkspaceFeature(
986
+ config,
987
+ cache,
988
+ indexer,
989
+ getGlobalCacheDir
990
+ );
991
+ setWorkspaceFeatureInstance = setWorkspaceInstance;
992
+ features[6].instance = setWorkspaceInstance;
993
+ features[6].handler = SetWorkspaceFeature.createHandleToolCall(setWorkspaceInstance);
994
+
995
+ server.hybridSearch = hybridSearch;
996
+
997
+ const startBackgroundTasks = async () => {
998
+ const stopStartupMemoryLogger = () => {
999
+ if (stopStartupMemory) {
1000
+ stopStartupMemory();
1001
+ }
1002
+ };
1003
+ const handleCorruptCacheAfterLoad = async ({ context, canReindex }) => {
1004
+ if (!cache.consumeAutoReindex()) return false;
1005
+ cache.clearInMemoryState();
1006
+ await recordBinaryStoreCorruption(config.cacheDirectory, {
1007
+ context,
1008
+ action: canReindex ? 'auto-cleared' : 'secondary-readonly-blocked',
1009
+ });
1010
+ if (canReindex) {
1011
+ console.warn(
1012
+ `[Server] Cache corruption detected while ${context}; in-memory cache was cleared and a full re-index will run.`
1013
+ );
1014
+ } else {
1015
+ console.warn(
1016
+ `[Server] Cache corruption detected while ${context}. This server is secondary read-only and cannot re-index. Restart the MCP client session for this workspace or use the primary instance to rebuild the cache.`
1017
+ );
1018
+ }
1019
+ return true;
1020
+ };
1021
+ const tryAutoAttachWorkspaceCache = async (
1022
+ reason,
1023
+ { canReindex = workspaceLockAcquired } = {}
1024
+ ) => {
1025
+ const candidate = await findAutoAttachWorkspaceCandidate({
1026
+ excludeCacheDirectory: config.cacheDirectory,
1027
+ });
1028
+ if (!candidate) {
1029
+ console.warn(
1030
+ `[Server] Auto-attach skipped (${reason}): no unambiguous workspace cache candidate found.`
1031
+ );
1032
+ return false;
1033
+ }
1034
+
1035
+ config.searchDirectory = candidate.workspace;
1036
+ config.cacheDirectory = candidate.cacheDirectory;
1037
+ await fs.mkdir(config.cacheDirectory, { recursive: true });
1038
+ if (config.vectorStoreFormat === 'binary') {
1039
+ await cleanupStaleBinaryArtifacts(config.cacheDirectory, { logger: console });
1040
+ }
1041
+ await cache.load();
1042
+ await handleCorruptCacheAfterLoad({
1043
+ context: `auto-attaching workspace cache (${reason})`,
1044
+ canReindex,
1045
+ });
1046
+ console.info(
1047
+ `[Server] Auto-attached workspace cache (${reason}): ${candidate.workspace} via ${candidate.source}`
1048
+ );
1049
+ if (config.verbose) {
1050
+ logMemory('[Server] Memory (after cache load)');
1051
+ }
1052
+ return true;
1053
+ };
1054
+
1055
+ const resolutionSource = config.workspaceResolution?.source || 'unknown';
1056
+ const isSystemFallback =
1057
+ (resolutionSource === 'cwd' || resolutionSource === 'cwd-root-search') &&
1058
+ isNonProjectDirectory(config.searchDirectory);
1059
+
1060
+ if (isSystemFallback) {
1061
+ try {
1062
+ console.warn(
1063
+ `[Server] Detected system fallback workspace: ${config.searchDirectory}. Attempting cache auto-attach.`
1064
+ );
1065
+ const attached = await tryAutoAttachWorkspaceCache('system-fallback', {
1066
+ canReindex: workspaceLockAcquired,
1067
+ });
1068
+ if (!attached) {
1069
+ console.warn(
1070
+ '[Server] Waiting for a proper workspace root (MCP roots, env vars, or f_set_workspace).'
1071
+ );
1072
+ }
1073
+ } finally {
1074
+ stopStartupMemoryLogger();
1075
+ }
1076
+ return;
1077
+ }
1078
+
1079
+ if (!workspaceLockAcquired) {
1080
+ try {
1081
+ console.info('[Server] Secondary instance detected; loading cache in read-only mode.');
1082
+ await cache.load();
1083
+ await handleCorruptCacheAfterLoad({
1084
+ context: 'loading cache in secondary read-only mode',
1085
+ canReindex: false,
1086
+ });
1087
+ const storeSize = cache.getStoreSize();
1088
+ if (storeSize === 0) {
1089
+ await tryAutoAttachWorkspaceCache('secondary-empty-cache', { canReindex: false });
1090
+ }
1091
+ if (config.verbose) {
1092
+ logMemory('[Server] Memory (after cache load)');
1093
+ }
1094
+ } finally {
1095
+ stopStartupMemoryLogger();
1096
+ }
1097
+ console.info('[Server] Secondary instance ready; skipping background indexing.');
1098
+ return;
1099
+ }
1100
+
1101
+ void preloadEmbeddingModel();
1102
+
1103
+ try {
1104
+ console.info('[Server] Loading cache (deferred)...');
1105
+ await cache.load();
1106
+ await handleCorruptCacheAfterLoad({ context: 'startup cache load', canReindex: true });
1107
+ if (config.verbose) {
1108
+ logMemory('[Server] Memory (after cache load)');
1109
+ }
1110
+ } finally {
1111
+ stopStartupMemoryLogger();
1112
+ }
1113
+
1114
+ console.info('[Server] Starting background indexing (delayed)...');
1115
+
1116
+ setTimeout(() => {
1117
+ indexer
1118
+ .indexAll()
1119
+ .then(() => {
1120
+ if (config.watchFiles) {
1121
+ indexer.setupFileWatcher();
1122
+ }
1123
+ })
1124
+ .catch((err) => {
1125
+ console.error('[Server] Background indexing error:', err.message);
1126
+ });
1127
+ }, BACKGROUND_INDEX_DELAY_MS);
1128
+ };
1129
+
1130
+ return { startBackgroundTasks, config };
1131
+ }
1132
+
1133
+ const server = new Server(
1134
+ {
1135
+ name: 'heuristic-mcp',
1136
+ version: packageJson.version,
1137
+ },
1138
+ {
1139
+ capabilities: {
1140
+ tools: {},
1141
+ resources: {},
1142
+ },
1143
+ }
1144
+ );
1145
+
1120
1146
  server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
1121
1147
  console.info('[Server] Received roots/list_changed notification from client.');
1122
1148
  const newRoot = await detectWorkspaceFromRoots();
1123
1149
  if (newRoot) {
1124
1150
  await maybeAutoSwitchWorkspaceToPath(newRoot, {
1125
1151
  source: 'roots changed',
1126
- reindex: true,
1152
+ reindex: false,
1127
1153
  });
1128
1154
  }
1129
1155
  });
1130
-
1131
- server.setRequestHandler(ListResourcesRequestSchema, async () => {
1132
- await configReadyPromise;
1133
- if (configInitError || !config) {
1134
- throw configInitError ?? new Error('Server configuration is not initialized');
1135
- }
1136
- return await handleListResources(config);
1137
- });
1138
-
1139
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1140
- await configReadyPromise;
1141
- if (configInitError || !config) {
1142
- throw configInitError ?? new Error('Server configuration is not initialized');
1143
- }
1144
- return await handleReadResource(request.params.uri, config);
1145
- });
1146
-
1147
- server.setRequestHandler(ListToolsRequestSchema, async () => {
1148
- await configReadyPromise;
1149
- if (configInitError || !config) {
1150
- throw configInitError ?? new Error('Server configuration is not initialized');
1151
- }
1152
- const tools = [];
1153
-
1154
- for (const feature of features) {
1155
- const toolDef = feature.module.getToolDefinition(config);
1156
- tools.push(toolDef);
1157
- }
1158
-
1159
- return { tools };
1160
- });
1161
-
1162
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
1163
- await configReadyPromise;
1164
- if (configInitError || !config) {
1165
- return {
1166
- content: [
1167
- {
1168
- type: 'text',
1169
- text: `Server initialization failed: ${configInitError?.message || 'configuration not available'}`,
1170
- },
1171
- ],
1172
- isError: true,
1173
- };
1174
- }
1175
-
1176
- if (!workspaceLockAcquired && request.params?.name === 'f_set_workspace') {
1177
- const args = request.params?.arguments || {};
1178
- const workspacePath = args.workspacePath;
1179
- const reindex = args.reindex !== false;
1180
- if (typeof workspacePath !== 'string' || workspacePath.trim().length === 0) {
1181
- return {
1182
- content: [{ type: 'text', text: 'Error: workspacePath is required.' }],
1183
- isError: true,
1184
- };
1185
- }
1186
- if (reindex) {
1187
- return {
1188
- content: [
1189
- {
1190
- type: 'text',
1191
- text: 'This server instance is in secondary read-only mode. Set reindex=false to attach cache only.',
1192
- },
1193
- ],
1194
- isError: true,
1195
- };
1196
- }
1197
- const normalizedPath = path.resolve(workspacePath);
1198
- try {
1199
- const stats = await fs.stat(normalizedPath);
1200
- if (!stats.isDirectory()) {
1201
- return {
1202
- content: [{ type: 'text', text: `Error: Path is not a directory: ${normalizedPath}` }],
1203
- isError: true,
1204
- };
1205
- }
1206
- } catch (err) {
1207
- return {
1208
- content: [
1209
- {
1210
- type: 'text',
1211
- text: `Error: Cannot access directory ${normalizedPath}: ${err.message}`,
1212
- },
1213
- ],
1214
- isError: true,
1215
- };
1216
- }
1217
-
1156
+
1157
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
1158
+ await configReadyPromise;
1159
+ if (configInitError || !config) {
1160
+ throw configInitError ?? new Error('Server configuration is not initialized');
1161
+ }
1162
+ return await handleListResources(config);
1163
+ });
1164
+
1165
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1166
+ await configReadyPromise;
1167
+ if (configInitError || !config) {
1168
+ throw configInitError ?? new Error('Server configuration is not initialized');
1169
+ }
1170
+ return await handleReadResource(request.params.uri, config);
1171
+ });
1172
+
1173
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
1174
+ await configReadyPromise;
1175
+ if (configInitError || !config) {
1176
+ throw configInitError ?? new Error('Server configuration is not initialized');
1177
+ }
1178
+ const tools = [];
1179
+
1180
+ for (const feature of features) {
1181
+ const toolDef = feature.module.getToolDefinition(config);
1182
+ tools.push(toolDef);
1183
+ }
1184
+
1185
+ return { tools };
1186
+ });
1187
+
1188
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1189
+ await configReadyPromise;
1190
+ if (configInitError || !config) {
1191
+ return {
1192
+ content: [
1193
+ {
1194
+ type: 'text',
1195
+ text: `Server initialization failed: ${configInitError?.message || 'configuration not available'}`,
1196
+ },
1197
+ ],
1198
+ isError: true,
1199
+ };
1200
+ }
1201
+
1202
+ if (!workspaceLockAcquired && request.params?.name === 'f_set_workspace') {
1203
+ const args = request.params?.arguments || {};
1204
+ const workspacePath = args.workspacePath;
1205
+ const reindex = args.reindex !== false;
1206
+ if (typeof workspacePath !== 'string' || workspacePath.trim().length === 0) {
1207
+ return {
1208
+ content: [{ type: 'text', text: 'Error: workspacePath is required.' }],
1209
+ isError: true,
1210
+ };
1211
+ }
1212
+ if (reindex) {
1213
+ return {
1214
+ content: [
1215
+ {
1216
+ type: 'text',
1217
+ text: 'This server instance is in secondary read-only mode. Set reindex=false to attach cache only.',
1218
+ },
1219
+ ],
1220
+ isError: true,
1221
+ };
1222
+ }
1223
+ const normalizedPath = path.resolve(workspacePath);
1224
+ try {
1225
+ const stats = await fs.stat(normalizedPath);
1226
+ if (!stats.isDirectory()) {
1227
+ return {
1228
+ content: [{ type: 'text', text: `Error: Path is not a directory: ${normalizedPath}` }],
1229
+ isError: true,
1230
+ };
1231
+ }
1232
+ } catch (err) {
1233
+ return {
1234
+ content: [
1235
+ {
1236
+ type: 'text',
1237
+ text: `Error: Cannot access directory ${normalizedPath}: ${err.message}`,
1238
+ },
1239
+ ],
1240
+ isError: true,
1241
+ };
1242
+ }
1243
+
1218
1244
  config.searchDirectory = normalizedPath;
1219
- config.cacheDirectory = getWorkspaceCachePath(normalizedPath, getGlobalCacheDir());
1245
+ const cacheResolution = await resolveWorkspaceCacheDirectory(normalizedPath, getGlobalCacheDir());
1246
+ config.cacheDirectory = cacheResolution.cacheDirectory;
1247
+ if (config.verbose || cacheResolution.mode !== 'canonical') {
1248
+ console.info(`[Server] Cache resolution mode: ${cacheResolution.mode}`);
1249
+ }
1220
1250
  try {
1221
1251
  await fs.mkdir(config.cacheDirectory, { recursive: true });
1222
1252
  await cache.load();
1223
- if (cache.consumeAutoReindex()) {
1224
- cache.clearInMemoryState();
1225
- await recordBinaryStoreCorruption(config.cacheDirectory, {
1226
- context: 'f_set_workspace read-only attach',
1227
- action: 'secondary-readonly-blocked',
1228
- });
1229
- return {
1230
- content: [
1231
- {
1232
- type: 'text',
1233
- text: `Attached cache for ${normalizedPath}, but it is corrupt. This secondary read-only instance cannot rebuild it. Restart the MCP client session for this workspace or run indexing from the primary instance.`,
1234
- },
1235
- ],
1236
- isError: true,
1237
- };
1238
- }
1239
- trustWorkspacePath(normalizedPath);
1240
- return {
1241
- content: [
1242
- {
1243
- type: 'text',
1244
- text: `Attached in read-only mode to workspace cache: ${normalizedPath}`,
1245
- },
1246
- ],
1247
- };
1248
- } catch (err) {
1249
- return {
1250
- content: [
1251
- {
1252
- type: 'text',
1253
- text: `Error: Failed to attach cache for ${normalizedPath}: ${err.message}`,
1254
- },
1255
- ],
1256
- isError: true,
1257
- };
1258
- }
1259
- }
1260
-
1261
- if (
1262
- !workspaceLockAcquired &&
1263
- ['b_index_codebase', 'c_clear_cache'].includes(request.params?.name)
1264
- ) {
1265
- return {
1266
- content: [
1267
- {
1268
- type: 'text',
1269
- text: 'This server instance is in secondary read-only mode. Use the primary instance for indexing/cache mutation tools.',
1270
- },
1271
- ],
1272
- isError: true,
1273
- };
1274
- }
1275
- const detectedFromRoots = await maybeAutoSwitchWorkspaceFromRoots(request);
1276
- const detectedFromEnv = await maybeAutoSwitchWorkspace(request);
1277
- if (detectedFromRoots) {
1278
- trustWorkspacePath(detectedFromRoots);
1279
- }
1280
- if (detectedFromEnv) {
1281
- trustWorkspacePath(detectedFromEnv);
1282
- }
1283
-
1284
- const toolName = request.params?.name;
1285
- if (
1286
- config.requireTrustedWorkspaceSignalForTools === true &&
1287
- shouldRequireTrustedWorkspaceSignalForTool(toolName) &&
1288
- !detectedFromRoots &&
1289
- !detectedFromEnv &&
1290
- !isCurrentWorkspaceTrusted()
1291
- ) {
1292
- return {
1293
- content: [
1294
- {
1295
- type: 'text',
1296
- text:
1297
- `Workspace context appears stale for "${toolName}" (current: "${config.searchDirectory}"). ` +
1298
- 'Please reload your IDE window and retry. ' +
1299
- 'If needed, call MCP tool "f_set_workspace" from your chat/client with your opened folder path.',
1300
- },
1301
- ],
1302
- isError: true,
1303
- };
1304
- }
1305
-
1306
- for (const feature of features) {
1307
- const toolDef = feature.module.getToolDefinition(config);
1308
-
1309
- if (request.params.name === toolDef.name) {
1310
- if (typeof feature.handler !== 'function') {
1311
- return {
1312
- content: [
1313
- {
1314
- type: 'text',
1315
- text: `Tool "${toolDef.name}" is not ready. Server may still be initializing.`,
1316
- },
1317
- ],
1318
- isError: true,
1319
- };
1320
- }
1321
- let result;
1322
- try {
1323
- result = await feature.handler(request, feature.instance);
1324
- } catch (error) {
1325
- const message = error instanceof Error ? error.message : String(error);
1326
- console.error(`[Server] Tool ${toolDef.name} failed: ${message}`);
1327
- return {
1328
- content: [
1329
- {
1330
- type: 'text',
1331
- text: `Error: ${message || 'Unknown tool failure'}`,
1332
- },
1333
- ],
1334
- isError: true,
1335
- };
1336
- }
1337
- if (toolDef.name === 'f_set_workspace' && !isToolResponseError(result)) {
1338
- trustWorkspacePath(config.searchDirectory);
1339
- }
1340
-
1341
- const searchTools = ['a_semantic_search', 'd_find_similar_code'];
1342
- if (config.unloadModelAfterSearch && searchTools.includes(toolDef.name)) {
1343
- setImmediate(() => {
1344
- const unloadFn = unloadMainEmbedder;
1345
- if (typeof unloadFn !== 'function') return;
1346
- void Promise.resolve()
1347
- .then(() => unloadFn())
1348
- .catch((err) => {
1349
- const message = err instanceof Error ? err.message : String(err);
1350
- console.warn(`[Server] Post-search model unload failed: ${message}`);
1351
- });
1352
- });
1353
- }
1354
-
1355
- return result;
1356
- }
1357
- }
1358
-
1359
- return {
1360
- content: [
1361
- {
1362
- type: 'text',
1363
- text: `Unknown tool: ${request.params.name}`,
1364
- },
1365
- ],
1366
- isError: true,
1367
- };
1368
- });
1369
-
1370
- export async function main(argv = process.argv) {
1371
- const parsed = parseArgs(argv);
1372
- const {
1373
- isServerMode,
1374
- workspaceDir,
1375
- wantsVersion,
1376
- wantsHelp,
1377
- wantsLogs,
1378
- wantsMem,
1379
- wantsNoFollow,
1380
- tailLines,
1381
- wantsStop,
1382
- wantsStart,
1383
- wantsCache,
1384
- wantsClean,
1385
- wantsStatus,
1386
- wantsClearCache,
1387
- startFilter,
1388
- wantsFix,
1389
- unknownFlags,
1390
- } = parsed;
1391
-
1392
- let shutdownRequested = false;
1393
- let shutdownReason = 'natural';
1394
- const requestShutdown = (reason) => {
1395
- if (shutdownRequested) return;
1396
- shutdownRequested = true;
1397
- shutdownReason = String(reason || 'unknown');
1398
- console.info(`[Server] Shutdown requested (${reason}).`);
1399
- void gracefulShutdown(reason);
1400
- };
1401
- const isTestEnv = isTestRuntime();
1402
- registerProcessDiagnostics({
1403
- isServerMode,
1404
- requestShutdown,
1405
- getShutdownReason: () => shutdownReason,
1406
- });
1407
-
1408
- if (isServerMode && !isTestEnv) {
1409
- enableStderrOnlyLogging();
1410
- }
1411
- if (wantsVersion) {
1412
- console.info(packageJson.version);
1413
- process.exit(0);
1414
- }
1415
-
1416
- if (wantsHelp) {
1417
- printHelp();
1418
- process.exit(0);
1419
- }
1420
-
1421
- if (workspaceDir) {
1422
- console.info(`[Server] Workspace mode: ${workspaceDir}`);
1423
- }
1424
-
1425
- if (wantsStop) {
1426
- await stop();
1427
- process.exit(0);
1428
- }
1429
-
1430
- if (wantsStart) {
1431
- await start(startFilter);
1432
- process.exit(0);
1433
- }
1434
-
1435
- if (wantsStatus) {
1436
- await status({ fix: wantsFix, workspaceDir });
1437
- process.exit(0);
1438
- }
1439
-
1440
- if (wantsCache) {
1441
- await status({ fix: wantsClean, cacheOnly: true, workspaceDir });
1442
- process.exit(0);
1443
- }
1444
-
1445
- const clearIndex = parsed.rawArgs.indexOf('--clear');
1446
- if (clearIndex !== -1) {
1447
- const cacheId = parsed.rawArgs[clearIndex + 1];
1448
- if (cacheId && !cacheId.startsWith('--')) {
1449
- let cacheHome;
1450
- if (process.platform === 'win32') {
1451
- cacheHome = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
1452
- } else if (process.platform === 'darwin') {
1453
- cacheHome = path.join(os.homedir(), 'Library', 'Caches');
1454
- } else {
1455
- cacheHome = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
1456
- }
1457
- const globalCacheRoot = path.join(cacheHome, 'heuristic-mcp');
1458
- const trimmedId = String(cacheId).trim();
1459
- const hasSeparators = trimmedId.includes('/') || trimmedId.includes('\\');
1460
- const resolvedCachePath = path.resolve(globalCacheRoot, trimmedId);
1461
- const relPath = path.relative(globalCacheRoot, resolvedCachePath);
1462
- const isWithinRoot = relPath && !relPath.startsWith('..') && !path.isAbsolute(relPath);
1463
-
1464
- if (!trimmedId || hasSeparators || !isWithinRoot) {
1465
- console.error(`[Cache] ❌ Invalid cache id: ${cacheId}`);
1466
- console.error('[Cache] Cache id must be a direct child of the cache root.');
1467
- process.exit(1);
1468
- }
1469
-
1470
- const cachePath = resolvedCachePath;
1471
-
1472
- try {
1473
- await fs.access(cachePath);
1474
- console.info(`[Cache] Removing cache: ${cacheId}`);
1475
- console.info(`[Cache] Path: ${cachePath}`);
1476
- await fs.rm(cachePath, { recursive: true, force: true });
1477
- console.info(`[Cache] ✅ Successfully removed cache ${cacheId}`);
1478
- } catch (error) {
1479
- if (error.code === 'ENOENT') {
1480
- console.error(`[Cache] ❌ Cache not found: ${cacheId}`);
1481
- console.error(`[Cache] Available caches in ${globalCacheRoot}:`);
1482
- const dirs = await fs.readdir(globalCacheRoot).catch(() => []);
1483
- dirs.forEach((dir) => console.error(` - ${dir}`));
1484
- process.exit(1);
1485
- } else {
1486
- console.error(`[Cache] ❌ Failed to remove cache: ${error.message}`);
1487
- process.exit(1);
1488
- }
1489
- }
1490
- process.exit(0);
1491
- }
1492
- }
1493
-
1494
- if (wantsClearCache) {
1495
- await clearCache(workspaceDir);
1496
- process.exit(0);
1497
- }
1498
-
1499
- if (wantsLogs) {
1500
- process.env.SMART_CODING_LOGS = 'true';
1501
- process.env.SMART_CODING_VERBOSE = 'true';
1502
- await logs({
1503
- workspaceDir,
1504
- tailLines,
1505
- follow: !wantsNoFollow,
1506
- });
1507
- process.exit(0);
1508
- }
1509
-
1510
- if (wantsMem) {
1511
- const ok = await printMemorySnapshot(workspaceDir);
1512
- process.exit(ok ? 0 : 1);
1513
- }
1514
-
1515
- if (unknownFlags.length > 0) {
1516
- console.error(`[Error] Unknown option(s): ${unknownFlags.join(', ')}`);
1517
- printHelp();
1518
- process.exit(1);
1519
- }
1520
-
1521
- if (wantsFix && !wantsStatus) {
1522
- console.error('[Error] --fix can only be used with --status (deprecated, use --cache --clean)');
1523
- printHelp();
1524
- process.exit(1);
1525
- }
1526
-
1527
- if (wantsClean && !wantsCache) {
1528
- console.error('[Error] --clean can only be used with --cache');
1529
- printHelp();
1530
- process.exit(1);
1531
- }
1532
-
1533
- registerSignalHandlers(requestShutdown);
1534
-
1535
- const detectedRootPromise = isTestEnv
1536
- ? Promise.resolve(null)
1537
- : new Promise((resolve) => {
1538
- const HANDSHAKE_TIMEOUT_MS = 1000;
1539
- let settled = false;
1540
- const resolveOnce = (value) => {
1541
- if (settled) return;
1542
- settled = true;
1543
- resolve(value);
1544
- };
1545
-
1546
- const timer = setTimeout(() => {
1547
- console.warn(
1548
- `[Server] MCP handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms, proceeding without roots.`
1549
- );
1550
- resolveOnce(null);
1551
- }, HANDSHAKE_TIMEOUT_MS);
1552
-
1553
- server.oninitialized = async () => {
1554
- clearTimeout(timer);
1555
- console.info('[Server] MCP handshake complete.');
1556
- const root = await detectWorkspaceFromRoots();
1557
- resolveOnce(root);
1558
- };
1559
- });
1560
-
1561
- const transport = new StdioServerTransport();
1562
- await server.connect(transport);
1563
- console.info('[Server] MCP transport connected.');
1564
- if (isServerMode) {
1565
- registerStdioShutdownHandlers(requestShutdown);
1566
- }
1567
-
1568
- const detectedRoot = await detectedRootPromise;
1569
-
1570
- const effectiveWorkspace = detectedRoot || workspaceDir;
1571
- if (detectedRoot) {
1572
- console.info(`[Server] Using workspace from MCP roots: ${detectedRoot}`);
1573
- }
1574
- let startBackgroundTasks;
1575
- try {
1576
- const initResult = await initialize(effectiveWorkspace);
1577
- startBackgroundTasks = initResult.startBackgroundTasks;
1578
- } catch (err) {
1579
- configInitError = err;
1580
- configReadyResolve();
1581
- throw err;
1582
- }
1583
-
1584
- console.info('[Server] Heuristic MCP server started.');
1585
-
1586
- try {
1587
- await startBackgroundTasks();
1588
- } catch (err) {
1589
- console.error(`[Server] Background task error: ${err.message}`);
1590
- }
1591
- configReadyResolve();
1592
- // Keep-Alive mechanism: ensure the process stays alive even if StdioServerTransport
1593
- // temporarily loses its active handle status or during complex async chains.
1594
- if (isServerMode && !isTestEnv && !keepAliveTimer) {
1595
- keepAliveTimer = setInterval(() => {
1596
- // Logic to keep event loop active.
1597
- // We don't need to do anything, just the presence of the timer is enough.
1598
- }, SERVER_KEEP_ALIVE_INTERVAL_MS);
1599
- }
1600
-
1601
- console.info('[Server] MCP server is now fully ready to accept requests.');
1602
- }
1603
-
1253
+ if (cache.consumeAutoReindex()) {
1254
+ cache.clearInMemoryState();
1255
+ await recordBinaryStoreCorruption(config.cacheDirectory, {
1256
+ context: 'f_set_workspace read-only attach',
1257
+ action: 'secondary-readonly-blocked',
1258
+ });
1259
+ return {
1260
+ content: [
1261
+ {
1262
+ type: 'text',
1263
+ text: `Attached cache for ${normalizedPath}, but it is corrupt. This secondary read-only instance cannot rebuild it. Restart the MCP client session for this workspace or run indexing from the primary instance.`,
1264
+ },
1265
+ ],
1266
+ isError: true,
1267
+ };
1268
+ }
1269
+ trustWorkspacePath(normalizedPath);
1270
+ return {
1271
+ content: [
1272
+ {
1273
+ type: 'text',
1274
+ text: `Attached in read-only mode to workspace cache: ${normalizedPath}`,
1275
+ },
1276
+ ],
1277
+ };
1278
+ } catch (err) {
1279
+ return {
1280
+ content: [
1281
+ {
1282
+ type: 'text',
1283
+ text: `Error: Failed to attach cache for ${normalizedPath}: ${err.message}`,
1284
+ },
1285
+ ],
1286
+ isError: true,
1287
+ };
1288
+ }
1289
+ }
1290
+
1291
+ if (
1292
+ !workspaceLockAcquired &&
1293
+ ['b_index_codebase', 'c_clear_cache'].includes(request.params?.name)
1294
+ ) {
1295
+ return {
1296
+ content: [
1297
+ {
1298
+ type: 'text',
1299
+ text: 'This server instance is in secondary read-only mode. Use the primary instance for indexing/cache mutation tools.',
1300
+ },
1301
+ ],
1302
+ isError: true,
1303
+ };
1304
+ }
1305
+ const detectedFromRoots = await maybeAutoSwitchWorkspaceFromRoots(request);
1306
+ const detectedFromEnv = await maybeAutoSwitchWorkspace(request);
1307
+ if (detectedFromRoots) {
1308
+ trustWorkspacePath(detectedFromRoots);
1309
+ }
1310
+ if (detectedFromEnv) {
1311
+ trustWorkspacePath(detectedFromEnv);
1312
+ }
1313
+
1314
+ const toolName = request.params?.name;
1315
+ if (
1316
+ config.requireTrustedWorkspaceSignalForTools === true &&
1317
+ shouldRequireTrustedWorkspaceSignalForTool(toolName) &&
1318
+ !detectedFromRoots &&
1319
+ !detectedFromEnv &&
1320
+ !isCurrentWorkspaceTrusted()
1321
+ ) {
1322
+ return {
1323
+ content: [
1324
+ {
1325
+ type: 'text',
1326
+ text:
1327
+ `Workspace context appears stale for "${toolName}" (current: "${config.searchDirectory}"). ` +
1328
+ 'Please reload your IDE window and retry. ' +
1329
+ 'If needed, call MCP tool "f_set_workspace" from your chat/client with your opened folder path.',
1330
+ },
1331
+ ],
1332
+ isError: true,
1333
+ };
1334
+ }
1335
+
1336
+ for (const feature of features) {
1337
+ const toolDef = feature.module.getToolDefinition(config);
1338
+
1339
+ if (request.params.name === toolDef.name) {
1340
+ if (typeof feature.handler !== 'function') {
1341
+ return {
1342
+ content: [
1343
+ {
1344
+ type: 'text',
1345
+ text: `Tool "${toolDef.name}" is not ready. Server may still be initializing.`,
1346
+ },
1347
+ ],
1348
+ isError: true,
1349
+ };
1350
+ }
1351
+ let result;
1352
+ try {
1353
+ result = await feature.handler(request, feature.instance);
1354
+ } catch (error) {
1355
+ const message = error instanceof Error ? error.message : String(error);
1356
+ console.error(`[Server] Tool ${toolDef.name} failed: ${message}`);
1357
+ return {
1358
+ content: [
1359
+ {
1360
+ type: 'text',
1361
+ text: `Error: ${message || 'Unknown tool failure'}`,
1362
+ },
1363
+ ],
1364
+ isError: true,
1365
+ };
1366
+ }
1367
+ if (toolDef.name === 'f_set_workspace' && !isToolResponseError(result)) {
1368
+ trustWorkspacePath(config.searchDirectory);
1369
+ }
1370
+
1371
+ const searchTools = ['a_semantic_search', 'd_find_similar_code'];
1372
+ if (config.unloadModelAfterSearch && searchTools.includes(toolDef.name)) {
1373
+ setImmediate(() => {
1374
+ const unloadFn = unloadMainEmbedder;
1375
+ if (typeof unloadFn !== 'function') return;
1376
+ void Promise.resolve()
1377
+ .then(() => unloadFn())
1378
+ .catch((err) => {
1379
+ const message = err instanceof Error ? err.message : String(err);
1380
+ console.warn(`[Server] Post-search model unload failed: ${message}`);
1381
+ });
1382
+ });
1383
+ }
1384
+
1385
+ return result;
1386
+ }
1387
+ }
1388
+
1389
+ return {
1390
+ content: [
1391
+ {
1392
+ type: 'text',
1393
+ text: `Unknown tool: ${request.params.name}`,
1394
+ },
1395
+ ],
1396
+ isError: true,
1397
+ };
1398
+ });
1399
+
1400
+ export async function main(argv = process.argv) {
1401
+ const parsed = parseArgs(argv);
1402
+ const {
1403
+ isServerMode,
1404
+ workspaceDir,
1405
+ wantsVersion,
1406
+ wantsHelp,
1407
+ wantsLogs,
1408
+ wantsMem,
1409
+ wantsNoFollow,
1410
+ tailLines,
1411
+ wantsStop,
1412
+ wantsStart,
1413
+ wantsCache,
1414
+ wantsClean,
1415
+ wantsStatus,
1416
+ wantsClearCache,
1417
+ startFilter,
1418
+ wantsFix,
1419
+ unknownFlags,
1420
+ } = parsed;
1421
+
1422
+ let shutdownRequested = false;
1423
+ let shutdownReason = 'natural';
1424
+ const requestShutdown = (reason) => {
1425
+ if (shutdownRequested) return;
1426
+ shutdownRequested = true;
1427
+ shutdownReason = String(reason || 'unknown');
1428
+ console.info(`[Server] Shutdown requested (${reason}).`);
1429
+ void gracefulShutdown(reason);
1430
+ };
1431
+ const isTestEnv = isTestRuntime();
1432
+ registerProcessDiagnostics({
1433
+ isServerMode,
1434
+ requestShutdown,
1435
+ getShutdownReason: () => shutdownReason,
1436
+ });
1437
+
1438
+ if (isServerMode && !isTestEnv) {
1439
+ enableStderrOnlyLogging();
1440
+ }
1441
+ if (wantsVersion) {
1442
+ console.info(packageJson.version);
1443
+ process.exit(0);
1444
+ }
1445
+
1446
+ if (wantsHelp) {
1447
+ printHelp();
1448
+ process.exit(0);
1449
+ }
1450
+
1451
+ if (workspaceDir) {
1452
+ console.info(`[Server] Workspace mode: ${workspaceDir}`);
1453
+ }
1454
+
1455
+ if (wantsStop) {
1456
+ await stop();
1457
+ process.exit(0);
1458
+ }
1459
+
1460
+ if (wantsStart) {
1461
+ await start(startFilter);
1462
+ process.exit(0);
1463
+ }
1464
+
1465
+ if (wantsStatus) {
1466
+ await status({ fix: wantsFix, workspaceDir });
1467
+ process.exit(0);
1468
+ }
1469
+
1470
+ if (wantsCache) {
1471
+ await status({ fix: wantsClean, cacheOnly: true, workspaceDir });
1472
+ process.exit(0);
1473
+ }
1474
+
1475
+ const clearIndex = parsed.rawArgs.indexOf('--clear');
1476
+ if (clearIndex !== -1) {
1477
+ const cacheId = parsed.rawArgs[clearIndex + 1];
1478
+ if (cacheId && !cacheId.startsWith('--')) {
1479
+ let cacheHome;
1480
+ if (process.platform === 'win32') {
1481
+ cacheHome = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
1482
+ } else if (process.platform === 'darwin') {
1483
+ cacheHome = path.join(os.homedir(), 'Library', 'Caches');
1484
+ } else {
1485
+ cacheHome = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
1486
+ }
1487
+ const globalCacheRoot = path.join(cacheHome, 'heuristic-mcp');
1488
+ const trimmedId = String(cacheId).trim();
1489
+ const hasSeparators = trimmedId.includes('/') || trimmedId.includes('\\');
1490
+ const resolvedCachePath = path.resolve(globalCacheRoot, trimmedId);
1491
+ const relPath = path.relative(globalCacheRoot, resolvedCachePath);
1492
+ const isWithinRoot = relPath && !relPath.startsWith('..') && !path.isAbsolute(relPath);
1493
+
1494
+ if (!trimmedId || hasSeparators || !isWithinRoot) {
1495
+ console.error(`[Cache] ❌ Invalid cache id: ${cacheId}`);
1496
+ console.error('[Cache] Cache id must be a direct child of the cache root.');
1497
+ process.exit(1);
1498
+ }
1499
+
1500
+ const cachePath = resolvedCachePath;
1501
+
1502
+ try {
1503
+ await fs.access(cachePath);
1504
+ console.info(`[Cache] Removing cache: ${cacheId}`);
1505
+ console.info(`[Cache] Path: ${cachePath}`);
1506
+ await fs.rm(cachePath, { recursive: true, force: true });
1507
+ console.info(`[Cache] ✅ Successfully removed cache ${cacheId}`);
1508
+ } catch (error) {
1509
+ if (error.code === 'ENOENT') {
1510
+ console.error(`[Cache] ❌ Cache not found: ${cacheId}`);
1511
+ console.error(`[Cache] Available caches in ${globalCacheRoot}:`);
1512
+ const dirs = await fs.readdir(globalCacheRoot).catch(() => []);
1513
+ dirs.forEach((dir) => console.error(` - ${dir}`));
1514
+ process.exit(1);
1515
+ } else {
1516
+ console.error(`[Cache] ❌ Failed to remove cache: ${error.message}`);
1517
+ process.exit(1);
1518
+ }
1519
+ }
1520
+ process.exit(0);
1521
+ }
1522
+ }
1523
+
1524
+ if (wantsClearCache) {
1525
+ await clearCache(workspaceDir);
1526
+ process.exit(0);
1527
+ }
1528
+
1529
+ if (wantsLogs) {
1530
+ process.env.SMART_CODING_LOGS = 'true';
1531
+ process.env.SMART_CODING_VERBOSE = 'true';
1532
+ await logs({
1533
+ workspaceDir,
1534
+ tailLines,
1535
+ follow: !wantsNoFollow,
1536
+ });
1537
+ process.exit(0);
1538
+ }
1539
+
1540
+ if (wantsMem) {
1541
+ const ok = await printMemorySnapshot(workspaceDir);
1542
+ process.exit(ok ? 0 : 1);
1543
+ }
1544
+
1545
+ if (unknownFlags.length > 0) {
1546
+ console.error(`[Error] Unknown option(s): ${unknownFlags.join(', ')}`);
1547
+ printHelp();
1548
+ process.exit(1);
1549
+ }
1550
+
1551
+ if (wantsFix && !wantsStatus) {
1552
+ console.error('[Error] --fix can only be used with --status (deprecated, use --cache --clean)');
1553
+ printHelp();
1554
+ process.exit(1);
1555
+ }
1556
+
1557
+ if (wantsClean && !wantsCache) {
1558
+ console.error('[Error] --clean can only be used with --cache');
1559
+ printHelp();
1560
+ process.exit(1);
1561
+ }
1562
+
1563
+ registerSignalHandlers(requestShutdown);
1564
+
1565
+ const detectedRootPromise = isTestEnv
1566
+ ? Promise.resolve(null)
1567
+ : new Promise((resolve) => {
1568
+ const HANDSHAKE_TIMEOUT_MS = 1000;
1569
+ let settled = false;
1570
+ const resolveOnce = (value) => {
1571
+ if (settled) return;
1572
+ settled = true;
1573
+ resolve(value);
1574
+ };
1575
+
1576
+ const timer = setTimeout(() => {
1577
+ console.warn(
1578
+ `[Server] MCP handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms, proceeding without roots.`
1579
+ );
1580
+ resolveOnce(null);
1581
+ }, HANDSHAKE_TIMEOUT_MS);
1582
+
1583
+ server.oninitialized = async () => {
1584
+ clearTimeout(timer);
1585
+ console.info('[Server] MCP handshake complete.');
1586
+ const root = await detectWorkspaceFromRoots();
1587
+ resolveOnce(root);
1588
+ };
1589
+ });
1590
+
1591
+ const transport = new StdioServerTransport();
1592
+ await server.connect(transport);
1593
+ console.info('[Server] MCP transport connected.');
1594
+ if (isServerMode) {
1595
+ registerStdioShutdownHandlers(requestShutdown);
1596
+ }
1597
+
1598
+ const detectedRoot = await detectedRootPromise;
1599
+
1600
+ const effectiveWorkspace = detectedRoot || workspaceDir;
1601
+ if (detectedRoot) {
1602
+ console.info(`[Server] Using workspace from MCP roots: ${detectedRoot}`);
1603
+ }
1604
+ let startBackgroundTasks;
1605
+ try {
1606
+ const initResult = await initialize(effectiveWorkspace);
1607
+ startBackgroundTasks = initResult.startBackgroundTasks;
1608
+ } catch (err) {
1609
+ configInitError = err;
1610
+ configReadyResolve();
1611
+ throw err;
1612
+ }
1613
+
1614
+ console.info('[Server] Heuristic MCP server started.');
1615
+
1616
+ try {
1617
+ await startBackgroundTasks();
1618
+ } catch (err) {
1619
+ console.error(`[Server] Background task error: ${err.message}`);
1620
+ }
1621
+ configReadyResolve();
1622
+ // Keep-Alive mechanism: ensure the process stays alive even if StdioServerTransport
1623
+ // temporarily loses its active handle status or during complex async chains.
1624
+ if (isServerMode && !isTestEnv && !keepAliveTimer) {
1625
+ keepAliveTimer = setInterval(() => {
1626
+ // Logic to keep event loop active.
1627
+ // We don't need to do anything, just the presence of the timer is enough.
1628
+ }, SERVER_KEEP_ALIVE_INTERVAL_MS);
1629
+ }
1630
+
1631
+ console.info('[Server] MCP server is now fully ready to accept requests.');
1632
+ }
1633
+
1604
1634
  async function gracefulShutdown(signal) {
1605
1635
  console.info(`[Server] Received ${signal}, shutting down gracefully...`);
1606
1636
  const exitCode = getShutdownExitCode(signal);
1607
-
1637
+
1608
1638
  if (keepAliveTimer) {
1609
1639
  clearInterval(keepAliveTimer);
1610
1640
  keepAliveTimer = null;
1611
1641
  }
1612
1642
 
1613
- const cleanupTasks = [];
1614
-
1615
- if (indexer && indexer.watcher) {
1616
- cleanupTasks.push(
1617
- indexer.watcher
1618
- .close()
1619
- .then(() => console.info('[Server] File watcher stopped'))
1620
- .catch(() => console.warn('[Server] Error closing watcher'))
1621
- );
1622
- }
1623
-
1624
- if (indexer && indexer.terminateWorkers) {
1625
- cleanupTasks.push(
1626
- (async () => {
1627
- console.info('[Server] Terminating workers...');
1628
- await indexer.terminateWorkers();
1629
- console.info('[Server] Workers terminated');
1630
- })().catch(() => console.info('[Server] Workers shutdown (with warnings)'))
1631
- );
1632
- }
1633
-
1634
- if (cache) {
1635
- cleanupTasks.push(
1636
- (async () => {
1637
- if (!workspaceLockAcquired) {
1638
- console.info('[Server] Secondary/fallback mode: skipping cache save.');
1643
+ const indexerIsBusy =
1644
+ indexer &&
1645
+ (typeof indexer.isBusy === 'function'
1646
+ ? indexer.isBusy()
1647
+ : Boolean(indexer.isIndexing || indexer.processingWatchEvents));
1648
+ if (indexerIsBusy && typeof indexer.requestGracefulStop === 'function') {
1649
+ const waitMs =
1650
+ Number.isInteger(config?.shutdownIndexWaitMs) && config.shutdownIndexWaitMs >= 0
1651
+ ? config.shutdownIndexWaitMs
1652
+ : 3000;
1653
+ try {
1654
+ indexer.requestGracefulStop(`shutdown:${signal}`);
1655
+ if (typeof indexer.waitForIdle === 'function') {
1656
+ const waitResult = await indexer.waitForIdle(waitMs);
1657
+ if (waitResult?.idle) {
1658
+ console.info('[Server] Shutdown checkpoint outcome: success');
1639
1659
  } else {
1640
- await cache.save();
1641
- console.info('[Server] Cache saved');
1660
+ console.warn(`[Server] Shutdown checkpoint outcome: timeout (${waitMs}ms)`);
1642
1661
  }
1643
- if (typeof cache.close === 'function') {
1644
- await cache.close();
1645
- }
1646
- })().catch((err) => console.error(`[Server] Cache shutdown cleanup failed: ${err.message}`))
1647
- );
1648
- }
1649
-
1650
- if (workspaceLockAcquired && config?.cacheDirectory) {
1651
- cleanupTasks.push(
1652
- releaseWorkspaceLock({ cacheDirectory: config.cacheDirectory }).catch((err) =>
1653
- console.warn(`[Server] Failed to release workspace lock: ${err.message}`)
1654
- )
1655
- );
1662
+ }
1663
+ } catch (err) {
1664
+ console.warn(`[Server] Shutdown checkpoint outcome: failed (${err.message})`);
1665
+ }
1656
1666
  }
1657
1667
 
1658
- await Promise.allSettled(cleanupTasks);
1659
- console.info('[Server] Goodbye!');
1660
-
1661
- unregisterStdioShutdownHandlers();
1662
- await flushLogsSafely({ close: true, timeoutMs: 1500 });
1663
-
1664
- setTimeout(() => process.exit(exitCode), 100);
1665
- }
1666
-
1667
- function isLikelyCliEntrypoint(argvPath) {
1668
- const base = path.basename(argvPath || '').toLowerCase();
1669
- return (
1670
- base === 'heuristic-mcp' ||
1671
- base === 'heuristic-mcp.js' ||
1672
- base === 'heuristic-mcp.mjs' ||
1673
- base === 'heuristic-mcp.cjs' ||
1674
- base === 'heuristic-mcp.cmd'
1675
- );
1676
- }
1677
-
1678
- const isMain =
1679
- process.argv[1] &&
1680
- (path.resolve(process.argv[1]).toLowerCase() === fileURLToPath(import.meta.url).toLowerCase() ||
1681
- isLikelyCliEntrypoint(process.argv[1])) &&
1682
- !(process.env.VITEST === 'true' || process.env.NODE_ENV === 'test');
1683
-
1684
- if (isMain) {
1685
- main().catch(async (err) => {
1686
- console.error(err);
1687
- await flushLogsSafely({ close: true, timeoutMs: 500 });
1688
- process.exit(1);
1689
- });
1690
- }
1668
+ const cleanupTasks = [];
1669
+
1670
+ if (indexer && indexer.watcher) {
1671
+ cleanupTasks.push(
1672
+ indexer.watcher
1673
+ .close()
1674
+ .then(() => console.info('[Server] File watcher stopped'))
1675
+ .catch(() => console.warn('[Server] Error closing watcher'))
1676
+ );
1677
+ }
1678
+
1679
+ if (indexer && indexer.terminateWorkers) {
1680
+ cleanupTasks.push(
1681
+ (async () => {
1682
+ console.info('[Server] Terminating workers...');
1683
+ await indexer.terminateWorkers();
1684
+ console.info('[Server] Workers terminated');
1685
+ })().catch(() => console.info('[Server] Workers shutdown (with warnings)'))
1686
+ );
1687
+ }
1688
+
1689
+ if (cache) {
1690
+ cleanupTasks.push(
1691
+ (async () => {
1692
+ if (!workspaceLockAcquired) {
1693
+ console.info('[Server] Secondary/fallback mode: skipping cache save.');
1694
+ } else {
1695
+ await cache.save();
1696
+ console.info('[Server] Cache saved');
1697
+ }
1698
+ if (typeof cache.close === 'function') {
1699
+ await cache.close();
1700
+ }
1701
+ })().catch((err) => console.error(`[Server] Cache shutdown cleanup failed: ${err.message}`))
1702
+ );
1703
+ }
1704
+
1705
+ if (workspaceLockAcquired && config?.cacheDirectory) {
1706
+ cleanupTasks.push(
1707
+ releaseWorkspaceLock({ cacheDirectory: config.cacheDirectory }).catch((err) =>
1708
+ console.warn(`[Server] Failed to release workspace lock: ${err.message}`)
1709
+ )
1710
+ );
1711
+ }
1712
+
1713
+ await Promise.allSettled(cleanupTasks);
1714
+ console.info('[Server] Goodbye!');
1715
+
1716
+ unregisterStdioShutdownHandlers();
1717
+ await flushLogsSafely({ close: true, timeoutMs: 1500 });
1718
+
1719
+ setTimeout(() => process.exit(exitCode), 100);
1720
+ }
1721
+
1722
+ function isLikelyCliEntrypoint(argvPath) {
1723
+ const base = path.basename(argvPath || '').toLowerCase();
1724
+ return (
1725
+ base === 'heuristic-mcp' ||
1726
+ base === 'heuristic-mcp.js' ||
1727
+ base === 'heuristic-mcp.mjs' ||
1728
+ base === 'heuristic-mcp.cjs' ||
1729
+ base === 'heuristic-mcp.cmd'
1730
+ );
1731
+ }
1732
+
1733
+ const isMain =
1734
+ process.argv[1] &&
1735
+ (path.resolve(process.argv[1]).toLowerCase() === fileURLToPath(import.meta.url).toLowerCase() ||
1736
+ isLikelyCliEntrypoint(process.argv[1])) &&
1737
+ !(process.env.VITEST === 'true' || process.env.NODE_ENV === 'test');
1738
+
1739
+ if (isMain) {
1740
+ main().catch(async (err) => {
1741
+ console.error(err);
1742
+ await flushLogsSafely({ close: true, timeoutMs: 500 });
1743
+ process.exit(1);
1744
+ });
1745
+ }