codebase-context 1.8.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +215 -13
  2. package/dist/cli.d.ts +1 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +12 -4
  5. package/dist/cli.js.map +1 -1
  6. package/dist/constants/codebase-context.d.ts +12 -0
  7. package/dist/constants/codebase-context.d.ts.map +1 -1
  8. package/dist/constants/codebase-context.js +36 -0
  9. package/dist/constants/codebase-context.js.map +1 -1
  10. package/dist/core/file-watcher.d.ts.map +1 -1
  11. package/dist/core/file-watcher.js +2 -12
  12. package/dist/core/file-watcher.js.map +1 -1
  13. package/dist/core/indexer.d.ts.map +1 -1
  14. package/dist/core/indexer.js +2 -9
  15. package/dist/core/indexer.js.map +1 -1
  16. package/dist/index.d.ts +10 -14
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +884 -192
  19. package/dist/index.js.map +1 -1
  20. package/dist/project-state.d.ts +24 -0
  21. package/dist/project-state.d.ts.map +1 -0
  22. package/dist/project-state.js +68 -0
  23. package/dist/project-state.js.map +1 -0
  24. package/dist/resources/uri.d.ts +4 -1
  25. package/dist/resources/uri.d.ts.map +1 -1
  26. package/dist/resources/uri.js +18 -1
  27. package/dist/resources/uri.js.map +1 -1
  28. package/dist/tools/index.d.ts.map +1 -1
  29. package/dist/tools/index.js +31 -1
  30. package/dist/tools/index.js.map +1 -1
  31. package/dist/tools/search-codebase.d.ts.map +1 -1
  32. package/dist/tools/search-codebase.js +14 -41
  33. package/dist/tools/search-codebase.js.map +1 -1
  34. package/dist/tools/types.d.ts +12 -0
  35. package/dist/tools/types.d.ts.map +1 -1
  36. package/dist/utils/project-discovery.d.ts +12 -0
  37. package/dist/utils/project-discovery.d.ts.map +1 -0
  38. package/dist/utils/project-discovery.js +183 -0
  39. package/dist/utils/project-discovery.js.map +1 -0
  40. package/docs/capabilities.md +37 -7
  41. package/docs/cli.md +3 -1
  42. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -5,54 +5,374 @@
5
5
  */
6
6
  import { promises as fs } from 'fs';
7
7
  import path from 'path';
8
+ import { fileURLToPath } from 'url';
8
9
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
- import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
11
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, RootsListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
11
12
  import { CodebaseIndexer } from './core/indexer.js';
12
13
  import { analyzerRegistry } from './core/analyzer-registry.js';
13
14
  import { AngularAnalyzer } from './analyzers/angular/index.js';
14
15
  import { GenericAnalyzer } from './analyzers/generic/index.js';
15
16
  import { IndexCorruptedError } from './errors/index.js';
16
- import { CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME, INTELLIGENCE_FILENAME, KEYWORD_INDEX_FILENAME, VECTOR_DB_DIRNAME } from './constants/codebase-context.js';
17
17
  import { appendMemoryFile } from './memory/store.js';
18
18
  import { handleCliCommand } from './cli.js';
19
19
  import { startFileWatcher } from './core/file-watcher.js';
20
- import { createAutoRefreshController } from './core/auto-refresh.js';
21
20
  import { parseGitLogLineToMemory } from './memory/git-memory.js';
22
21
  import { isComplementaryPatternCategory, shouldSkipLegacyTestingFrameworkCategory } from './patterns/semantics.js';
23
- import { CONTEXT_RESOURCE_URI, isContextResourceUri } from './resources/uri.js';
22
+ import { CONTEXT_RESOURCE_URI, buildProjectContextResourceUri, getProjectPathFromContextResourceUri, isContextResourceUri } from './resources/uri.js';
23
+ import { discoverProjectsWithinRoot, findNearestProjectBoundary, isPathWithin } from './utils/project-discovery.js';
24
24
  import { readIndexMeta, validateIndexArtifacts } from './core/index-meta.js';
25
25
  import { TOOLS, dispatchTool } from './tools/index.js';
26
+ import { getOrCreateProject, getAllProjects, getProject, makePaths, makeLegacyPaths, normalizeRootKey, removeProject } from './project-state.js';
26
27
  analyzerRegistry.register(new AngularAnalyzer());
27
28
  analyzerRegistry.register(new GenericAnalyzer());
28
- // Resolve root path with validation
29
+ // Resolve optional bootstrap root with validation handled later in main().
29
30
  function resolveRootPath() {
30
31
  const arg = process.argv[2];
31
32
  const envPath = process.env.CODEBASE_ROOT;
32
- // Priority: CLI arg > env var > cwd
33
- let rootPath = arg || envPath || process.cwd();
34
- rootPath = path.resolve(rootPath);
35
- // Warn if using cwd as fallback (guarded to avoid stderr during MCP STDIO handshake)
36
- if (!arg && !envPath && process.env.CODEBASE_CONTEXT_DEBUG) {
37
- console.error(`[DEBUG] No project path specified. Using current directory: ${rootPath}`);
38
- console.error(`[DEBUG] Hint: Specify path as CLI argument or set CODEBASE_ROOT env var`);
39
- }
40
- return rootPath;
41
- }
42
- const ROOT_PATH = resolveRootPath();
43
- // File paths (new structure)
44
- const PATHS = {
45
- baseDir: path.join(ROOT_PATH, CODEBASE_CONTEXT_DIRNAME),
46
- memory: path.join(ROOT_PATH, CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME),
47
- intelligence: path.join(ROOT_PATH, CODEBASE_CONTEXT_DIRNAME, INTELLIGENCE_FILENAME),
48
- keywordIndex: path.join(ROOT_PATH, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME),
49
- vectorDb: path.join(ROOT_PATH, CODEBASE_CONTEXT_DIRNAME, VECTOR_DB_DIRNAME)
50
- };
51
- const LEGACY_PATHS = {
52
- intelligence: path.join(ROOT_PATH, '.codebase-intelligence.json'),
53
- keywordIndex: path.join(ROOT_PATH, '.codebase-index.json'),
54
- vectorDb: path.join(ROOT_PATH, '.codebase-index')
55
- };
33
+ // Priority: CLI arg > env var. Do not fall back to cwd in MCP mode.
34
+ const configuredRoot = arg || envPath;
35
+ if (!configuredRoot) {
36
+ return undefined;
37
+ }
38
+ return path.resolve(configuredRoot);
39
+ }
40
+ const primaryRootPath = resolveRootPath();
41
+ const toolNames = new Set(TOOLS.map((tool) => tool.name));
42
+ const knownRoots = new Map();
43
+ const discoveredProjectPaths = new Map();
44
+ let clientRootsEnabled = false;
45
+ const projectSourcesByKey = new Map();
46
+ const projectAccessOrder = new Map();
47
+ let activeProjectKey;
48
+ let nextProjectAccessOrder = 1;
49
+ const MAX_WATCHED_PROJECTS = 5;
50
+ const PROJECT_DISCOVERY_MAX_DEPTH = 4;
51
+ const debounceEnv = Number.parseInt(process.env.CODEBASE_CONTEXT_DEBOUNCE_MS ?? '', 10);
52
+ const watcherDebounceMs = Number.isFinite(debounceEnv) && debounceEnv >= 0 ? debounceEnv : 2000;
53
+ function registerKnownRoot(rootPath) {
54
+ const resolvedRootPath = path.resolve(rootPath);
55
+ knownRoots.set(normalizeRootKey(resolvedRootPath), { rootPath: resolvedRootPath });
56
+ rememberProjectPath(resolvedRootPath, 'root');
57
+ return resolvedRootPath;
58
+ }
59
+ function getKnownRootPaths() {
60
+ return Array.from(knownRoots.values())
61
+ .map((entry) => entry.rootPath)
62
+ .sort((a, b) => a.localeCompare(b));
63
+ }
64
+ function getKnownRootLabel(rootPath) {
65
+ return knownRoots.get(normalizeRootKey(rootPath))?.label;
66
+ }
67
+ function getContainingKnownRoot(rootPath) {
68
+ const orderedRoots = getKnownRootPaths().sort((a, b) => b.length - a.length);
69
+ return orderedRoots.find((knownRootPath) => isPathWithin(knownRootPath, rootPath));
70
+ }
71
+ function classifyProjectSource(rootPath) {
72
+ const rootKey = normalizeRootKey(rootPath);
73
+ if (knownRoots.has(rootKey)) {
74
+ return 'root';
75
+ }
76
+ return getContainingKnownRoot(rootPath) ? 'subdirectory' : 'ad_hoc';
77
+ }
78
+ function touchProject(rootPath) {
79
+ projectAccessOrder.set(normalizeRootKey(rootPath), nextProjectAccessOrder++);
80
+ }
81
+ function rememberProjectPath(rootPath, source = classifyProjectSource(rootPath), options = {}) {
82
+ const resolvedRootPath = path.resolve(rootPath);
83
+ const rootKey = normalizeRootKey(resolvedRootPath);
84
+ const existingSource = projectSourcesByKey.get(rootKey);
85
+ if (!existingSource ||
86
+ source === 'root' ||
87
+ (source === 'subdirectory' && existingSource === 'ad_hoc')) {
88
+ projectSourcesByKey.set(rootKey, source);
89
+ }
90
+ if (options.touch !== false) {
91
+ touchProject(resolvedRootPath);
92
+ }
93
+ }
94
+ function registerDiscoveredProjectPath(rootPath, source = 'subdirectory') {
95
+ const resolvedRootPath = path.resolve(rootPath);
96
+ discoveredProjectPaths.set(normalizeRootKey(resolvedRootPath), resolvedRootPath);
97
+ rememberProjectPath(resolvedRootPath, source, { touch: false });
98
+ }
99
+ function clearDiscoveredProjectPaths() {
100
+ discoveredProjectPaths.clear();
101
+ }
102
+ function getTrackedRootPathByKey(rootKey) {
103
+ if (knownRoots.has(rootKey)) {
104
+ return knownRoots.get(rootKey)?.rootPath;
105
+ }
106
+ const project = Array.from(getAllProjects()).find((entry) => normalizeRootKey(entry.rootPath) === rootKey);
107
+ return project?.rootPath;
108
+ }
109
+ function forgetProjectPath(rootPath) {
110
+ const rootKey = normalizeRootKey(rootPath);
111
+ projectSourcesByKey.delete(rootKey);
112
+ projectAccessOrder.delete(rootKey);
113
+ if (activeProjectKey === rootKey) {
114
+ activeProjectKey = undefined;
115
+ }
116
+ }
117
+ function formatProjectLabel(rootPath) {
118
+ const knownRootLabel = getKnownRootLabel(rootPath);
119
+ if (knownRootLabel) {
120
+ return knownRootLabel;
121
+ }
122
+ const containingRoot = getContainingKnownRoot(rootPath);
123
+ if (containingRoot) {
124
+ const relativePath = path.relative(containingRoot, rootPath);
125
+ if (!relativePath) {
126
+ return getKnownRootLabel(containingRoot) ?? (path.basename(rootPath) || rootPath);
127
+ }
128
+ return relativePath.replace(/\\/g, '/');
129
+ }
130
+ return path.basename(rootPath) || rootPath;
131
+ }
132
+ function getRelativeProjectPath(rootPath) {
133
+ const containingRoot = getContainingKnownRoot(rootPath);
134
+ if (!containingRoot)
135
+ return undefined;
136
+ const relativePath = path.relative(containingRoot, rootPath).replace(/\\/g, '/');
137
+ return relativePath || undefined;
138
+ }
139
+ function getProjectIndexStatus(rootPath) {
140
+ return getProject(rootPath)?.indexState.status ?? 'idle';
141
+ }
142
+ function buildProjectDescriptor(rootPath) {
143
+ const resolvedRootPath = path.resolve(rootPath);
144
+ const rootKey = normalizeRootKey(resolvedRootPath);
145
+ rememberProjectPath(resolvedRootPath, classifyProjectSource(resolvedRootPath), { touch: false });
146
+ return {
147
+ project: resolvedRootPath,
148
+ label: formatProjectLabel(resolvedRootPath),
149
+ rootPath: resolvedRootPath,
150
+ relativePath: getRelativeProjectPath(resolvedRootPath),
151
+ active: activeProjectKey === rootKey,
152
+ source: projectSourcesByKey.get(rootKey) ?? classifyProjectSource(resolvedRootPath),
153
+ indexStatus: getProjectIndexStatus(resolvedRootPath)
154
+ };
155
+ }
156
+ function listProjectDescriptors() {
157
+ const rootPaths = new Map();
158
+ for (const rootPath of getKnownRootPaths()) {
159
+ rootPaths.set(normalizeRootKey(rootPath), rootPath);
160
+ }
161
+ for (const [projectKey, rootPath] of discoveredProjectPaths.entries()) {
162
+ rootPaths.set(projectKey, rootPath);
163
+ }
164
+ for (const project of getAllProjects()) {
165
+ rootPaths.set(normalizeRootKey(project.rootPath), project.rootPath);
166
+ }
167
+ const descriptors = Array.from(rootPaths.values())
168
+ .map((rootPath) => buildProjectDescriptor(rootPath))
169
+ .sort((a, b) => {
170
+ if (a.active !== b.active)
171
+ return a.active ? -1 : 1;
172
+ if (a.source !== b.source) {
173
+ const weight = {
174
+ root: 0,
175
+ subdirectory: 1,
176
+ ad_hoc: 2
177
+ };
178
+ return weight[a.source] - weight[b.source];
179
+ }
180
+ return a.label.localeCompare(b.label);
181
+ });
182
+ const duplicates = new Set();
183
+ const counts = new Map();
184
+ for (const descriptor of descriptors) {
185
+ counts.set(descriptor.label, (counts.get(descriptor.label) ?? 0) + 1);
186
+ }
187
+ for (const [label, count] of counts.entries()) {
188
+ if (count > 1) {
189
+ duplicates.add(label);
190
+ }
191
+ }
192
+ return descriptors.map((descriptor) => {
193
+ if (!duplicates.has(descriptor.label)) {
194
+ return descriptor;
195
+ }
196
+ const containingRoot = getContainingKnownRoot(descriptor.rootPath);
197
+ const rootHint = (containingRoot && getKnownRootLabel(containingRoot)) ||
198
+ (containingRoot && path.basename(containingRoot)) ||
199
+ path.basename(descriptor.rootPath);
200
+ return {
201
+ ...descriptor,
202
+ label: `${descriptor.label} (${rootHint})`
203
+ };
204
+ });
205
+ }
206
+ function getActiveProjectDescriptor() {
207
+ if (!activeProjectKey)
208
+ return undefined;
209
+ const trackedRootPath = getTrackedRootPathByKey(activeProjectKey);
210
+ if (!trackedRootPath) {
211
+ activeProjectKey = undefined;
212
+ return undefined;
213
+ }
214
+ return buildProjectDescriptor(trackedRootPath);
215
+ }
216
+ function setActiveProject(rootPath) {
217
+ const resolvedRootPath = path.resolve(rootPath);
218
+ activeProjectKey = normalizeRootKey(resolvedRootPath);
219
+ rememberProjectPath(resolvedRootPath);
220
+ }
221
+ function syncKnownRoots(rootEntries) {
222
+ const nextRoots = new Map();
223
+ const normalizedRoots = rootEntries.length > 0 ? rootEntries : primaryRootPath ? [{ rootPath: primaryRootPath }] : [];
224
+ for (const entry of normalizedRoots) {
225
+ const resolvedRootPath = path.resolve(entry.rootPath);
226
+ nextRoots.set(normalizeRootKey(resolvedRootPath), {
227
+ rootPath: resolvedRootPath,
228
+ label: entry.label?.trim() || undefined
229
+ });
230
+ }
231
+ for (const [rootKey, existingRoot] of knownRoots.entries()) {
232
+ if (!nextRoots.has(rootKey)) {
233
+ removeProject(existingRoot.rootPath);
234
+ forgetProjectPath(existingRoot.rootPath);
235
+ }
236
+ }
237
+ for (const project of getAllProjects()) {
238
+ const stillAllowed = Array.from(nextRoots.values()).some((knownRoot) => isPathWithin(knownRoot.rootPath, project.rootPath));
239
+ if (!stillAllowed) {
240
+ removeProject(project.rootPath);
241
+ forgetProjectPath(project.rootPath);
242
+ }
243
+ }
244
+ knownRoots.clear();
245
+ clearDiscoveredProjectPaths();
246
+ for (const [rootKey, rootEntry] of nextRoots.entries()) {
247
+ knownRoots.set(rootKey, rootEntry);
248
+ rememberProjectPath(rootEntry.rootPath, 'root', { touch: false });
249
+ }
250
+ if (activeProjectKey) {
251
+ if (!getTrackedRootPathByKey(activeProjectKey)) {
252
+ activeProjectKey = undefined;
253
+ }
254
+ }
255
+ }
256
+ function parseProjectSelector(value) {
257
+ if (typeof value !== 'string')
258
+ return undefined;
259
+ const trimmedValue = value.trim();
260
+ if (!trimmedValue)
261
+ return undefined;
262
+ return trimmedValue;
263
+ }
264
+ function parseProjectDirectory(value) {
265
+ const selector = parseProjectSelector(value);
266
+ if (!selector)
267
+ return undefined;
268
+ return selector.startsWith('file://')
269
+ ? path.resolve(fileURLToPath(selector))
270
+ : path.resolve(selector);
271
+ }
272
+ function getProjectSourceForResolvedPath(rootPath) {
273
+ return getContainingKnownRoot(rootPath) ? 'subdirectory' : 'ad_hoc';
274
+ }
275
+ async function resolveProjectFromAbsolutePath(resolvedPath) {
276
+ const absolutePath = path.resolve(resolvedPath);
277
+ const containingRoot = getContainingKnownRoot(absolutePath);
278
+ if (clientRootsEnabled && getKnownRootPaths().length > 0 && !containingRoot) {
279
+ return {
280
+ ok: false,
281
+ response: buildProjectSelectionError('unknown_project', 'Requested project is not under an active MCP root.')
282
+ };
283
+ }
284
+ let stats;
285
+ try {
286
+ stats = await fs.stat(absolutePath);
287
+ }
288
+ catch {
289
+ return {
290
+ ok: false,
291
+ response: buildProjectSelectionError('unknown_project', `project does not exist: ${absolutePath}`)
292
+ };
293
+ }
294
+ const lookupPath = stats.isDirectory() ? absolutePath : path.dirname(absolutePath);
295
+ const exactDescriptor = listProjectDescriptors().find((descriptor) => normalizeRootKey(descriptor.rootPath) === normalizeRootKey(lookupPath));
296
+ if (exactDescriptor) {
297
+ const project = getOrCreateProject(exactDescriptor.rootPath);
298
+ if (exactDescriptor.source === 'subdirectory') {
299
+ registerDiscoveredProjectPath(exactDescriptor.rootPath, 'subdirectory');
300
+ }
301
+ else {
302
+ rememberProjectPath(exactDescriptor.rootPath, exactDescriptor.source, { touch: false });
303
+ }
304
+ return { ok: true, project };
305
+ }
306
+ const nearestBoundary = await findNearestProjectBoundary(absolutePath, containingRoot);
307
+ const resolvedProjectPath = nearestBoundary?.rootPath ?? containingRoot ?? (stats.isDirectory() ? absolutePath : undefined);
308
+ if (!resolvedProjectPath) {
309
+ return {
310
+ ok: false,
311
+ response: buildProjectSelectionError('unknown_project', `project was not found from path: ${absolutePath}`)
312
+ };
313
+ }
314
+ const invalidProjectResponse = await validateResolvedProjectPath(resolvedProjectPath);
315
+ if (invalidProjectResponse) {
316
+ return { ok: false, response: invalidProjectResponse };
317
+ }
318
+ const projectSource = getProjectSourceForResolvedPath(resolvedProjectPath);
319
+ if (projectSource === 'subdirectory') {
320
+ registerDiscoveredProjectPath(resolvedProjectPath, 'subdirectory');
321
+ }
322
+ else {
323
+ rememberProjectPath(resolvedProjectPath, projectSource, { touch: false });
324
+ }
325
+ const project = getOrCreateProject(resolvedProjectPath);
326
+ return { ok: true, project };
327
+ }
328
+ function buildProjectSelectionPayload(status, message, project, extras = {}) {
329
+ return {
330
+ status,
331
+ message,
332
+ activeProject: project
333
+ ? buildProjectDescriptor(project.rootPath)
334
+ : (getActiveProjectDescriptor() ?? null),
335
+ availableProjects: listProjectDescriptors(),
336
+ ...extras
337
+ };
338
+ }
339
+ function buildProjectSelectionError(errorCode, message, extras = {}) {
340
+ const status = errorCode === 'selection_required' ? 'selection_required' : 'error';
341
+ return {
342
+ content: [
343
+ {
344
+ type: 'text',
345
+ text: JSON.stringify({ ...buildProjectSelectionPayload(status, message, undefined, extras), errorCode }, null, 2)
346
+ }
347
+ ],
348
+ isError: true
349
+ };
350
+ }
351
+ function createToolContext(project) {
352
+ return {
353
+ indexState: project.indexState,
354
+ paths: project.paths,
355
+ rootPath: project.rootPath,
356
+ project: buildProjectDescriptor(project.rootPath),
357
+ performIndexing: (incrementalOnly) => performIndexing(project, incrementalOnly),
358
+ listProjects: () => listProjectDescriptors(),
359
+ getActiveProject: () => getActiveProjectDescriptor()
360
+ };
361
+ }
362
+ function createWorkspaceToolContext() {
363
+ const fallbackRootPath = primaryRootPath ?? path.resolve(process.cwd());
364
+ return {
365
+ indexState: { status: 'idle' },
366
+ paths: makePaths(fallbackRootPath),
367
+ rootPath: fallbackRootPath,
368
+ performIndexing: () => undefined,
369
+ listProjects: () => listProjectDescriptors(),
370
+ getActiveProject: () => getActiveProjectDescriptor()
371
+ };
372
+ }
373
+ if (primaryRootPath) {
374
+ registerKnownRoot(primaryRootPath);
375
+ }
56
376
  export const INDEX_CONSUMING_TOOL_NAMES = [
57
377
  'search_codebase',
58
378
  'get_symbol_references',
@@ -61,11 +381,11 @@ export const INDEX_CONSUMING_TOOL_NAMES = [
61
381
  'get_codebase_metadata'
62
382
  ];
63
383
  export const INDEX_CONSUMING_RESOURCE_NAMES = ['Codebase Intelligence'];
64
- async function requireValidIndex(rootPath) {
384
+ async function requireValidIndex(rootPath, paths) {
65
385
  const meta = await readIndexMeta(rootPath);
66
386
  await validateIndexArtifacts(rootPath, meta);
67
387
  // Optional artifact presence informs confidence.
68
- const hasIntelligence = await fileExists(PATHS.intelligence);
388
+ const hasIntelligence = await fileExists(paths.intelligence);
69
389
  return {
70
390
  status: 'ready',
71
391
  confidence: hasIntelligence ? 'high' : 'low',
@@ -73,8 +393,8 @@ async function requireValidIndex(rootPath) {
73
393
  ...(hasIntelligence ? {} : { reason: 'Optional intelligence artifact missing' })
74
394
  };
75
395
  }
76
- async function ensureValidIndexOrAutoHeal() {
77
- if (indexState.status === 'indexing') {
396
+ async function ensureValidIndexOrAutoHeal(project) {
397
+ if (project.indexState.status === 'indexing') {
78
398
  return {
79
399
  status: 'indexing',
80
400
  confidence: 'low',
@@ -83,40 +403,118 @@ async function ensureValidIndexOrAutoHeal() {
83
403
  };
84
404
  }
85
405
  try {
86
- return await requireValidIndex(ROOT_PATH);
406
+ return await requireValidIndex(project.rootPath, project.paths);
87
407
  }
88
408
  catch (error) {
89
409
  if (error instanceof IndexCorruptedError) {
90
410
  const reason = error.message;
91
411
  console.error(`[Index] ${reason}`);
92
- console.error('[Auto-Heal] Triggering full re-index...');
93
- await performIndexing();
94
- if (indexState.status === 'ready') {
95
- try {
96
- let validated = await requireValidIndex(ROOT_PATH);
97
- validated = { ...validated, action: 'rebuilt-and-served', reason };
98
- return validated;
99
- }
100
- catch (revalidateError) {
101
- const msg = revalidateError instanceof Error ? revalidateError.message : String(revalidateError);
102
- return {
103
- status: 'rebuild-required',
104
- confidence: 'low',
105
- action: 'rebuild-failed',
106
- reason: `Auto-heal completed but index did not validate: ${msg}`
107
- };
108
- }
109
- }
412
+ console.error('[Auto-Heal] Triggering background re-index...');
413
+ // Fire-and-forget: don't block the tool call
414
+ void performIndexing(project);
110
415
  return {
111
- status: 'rebuild-required',
416
+ status: 'indexing',
112
417
  confidence: 'low',
113
- action: 'rebuild-failed',
114
- reason: `Auto-heal failed: ${indexState.error || reason}`
418
+ action: 'rebuild-started',
419
+ reason: `Auto-heal triggered: ${reason}`
115
420
  };
116
421
  }
117
422
  throw error;
118
423
  }
119
424
  }
425
+ async function validateResolvedProjectPath(rootPath) {
426
+ try {
427
+ const stats = await fs.stat(rootPath);
428
+ if (!stats.isDirectory()) {
429
+ return buildProjectSelectionError('unknown_project', `project is not a directory: ${rootPath}`);
430
+ }
431
+ if (clientRootsEnabled && getKnownRootPaths().length > 0 && !getContainingKnownRoot(rootPath)) {
432
+ return buildProjectSelectionError('unknown_project', 'Requested project is not under an active MCP root.');
433
+ }
434
+ return undefined;
435
+ }
436
+ catch {
437
+ return buildProjectSelectionError('unknown_project', `project does not exist: ${rootPath}`);
438
+ }
439
+ }
440
+ async function resolveProjectSelector(selector) {
441
+ const trimmedSelector = selector.trim();
442
+ if (!trimmedSelector) {
443
+ return {
444
+ ok: false,
445
+ response: buildProjectSelectionError('unknown_project', 'project must be a non-empty absolute path, file:// URI, or relative subproject path.')
446
+ };
447
+ }
448
+ if (trimmedSelector.startsWith('file://') || path.isAbsolute(trimmedSelector)) {
449
+ const resolvedPath = parseProjectDirectory(trimmedSelector);
450
+ if (!resolvedPath) {
451
+ return {
452
+ ok: false,
453
+ response: buildProjectSelectionError('unknown_project', 'project must be a non-empty absolute path, file:// URI, or relative subproject path.')
454
+ };
455
+ }
456
+ return resolveProjectFromAbsolutePath(resolvedPath);
457
+ }
458
+ const normalizedSelector = trimmedSelector.replace(/\\/g, '/').replace(/^\.\/+/, '');
459
+ const descriptorMatches = listProjectDescriptors().filter((descriptor) => descriptor.label === normalizedSelector ||
460
+ descriptor.relativePath === normalizedSelector ||
461
+ path.basename(descriptor.rootPath) === normalizedSelector);
462
+ if (descriptorMatches.length === 1) {
463
+ const matchedRootPath = descriptorMatches[0].rootPath;
464
+ if (descriptorMatches[0].source === 'subdirectory') {
465
+ registerDiscoveredProjectPath(matchedRootPath, 'subdirectory');
466
+ }
467
+ else {
468
+ rememberProjectPath(matchedRootPath, classifyProjectSource(matchedRootPath), {
469
+ touch: false
470
+ });
471
+ }
472
+ const project = getOrCreateProject(matchedRootPath);
473
+ return { ok: true, project };
474
+ }
475
+ if (descriptorMatches.length > 1) {
476
+ return {
477
+ ok: false,
478
+ response: buildProjectSelectionError('selection_required', `Project selector "${normalizedSelector}" matches multiple known projects. Retry with an absolute path.`, {
479
+ reason: 'project_selector_ambiguous',
480
+ nextAction: 'retry_with_project'
481
+ })
482
+ };
483
+ }
484
+ const matchingProjects = getKnownRootPaths()
485
+ .map((rootPath) => ({ rootPath, candidatePath: path.resolve(rootPath, normalizedSelector) }))
486
+ .filter(({ rootPath, candidatePath }) => isPathWithin(rootPath, candidatePath))
487
+ .map(({ candidatePath }) => candidatePath);
488
+ const resolvedMatches = new Map();
489
+ for (const candidatePath of matchingProjects) {
490
+ const resolution = await resolveProjectFromAbsolutePath(candidatePath);
491
+ if (resolution.ok) {
492
+ resolvedMatches.set(normalizeRootKey(resolution.project.rootPath), resolution.project);
493
+ continue;
494
+ }
495
+ const payload = JSON.parse(resolution.response.content?.[0]?.text ?? '{}');
496
+ if (payload.errorCode !== 'unknown_project') {
497
+ return resolution;
498
+ }
499
+ }
500
+ if (resolvedMatches.size === 1) {
501
+ const project = Array.from(resolvedMatches.values())[0];
502
+ return { ok: true, project };
503
+ }
504
+ if (resolvedMatches.size > 1) {
505
+ return {
506
+ ok: false,
507
+ response: buildProjectSelectionError('selection_required', `Relative project path "${normalizedSelector}" matches multiple configured roots. Retry with an absolute path.`, {
508
+ reason: 'relative_project_ambiguous',
509
+ nextAction: 'retry_with_project'
510
+ })
511
+ };
512
+ }
513
+ return {
514
+ ok: false,
515
+ response: buildProjectSelectionError('unknown_project', `Relative project path "${normalizedSelector}" was not found under any configured root.`)
516
+ };
517
+ }
120
518
  /**
121
519
  * Check if file/directory exists
122
520
  */
@@ -133,14 +531,14 @@ async function fileExists(filePath) {
133
531
  * Migrate legacy file structure to .codebase-context/ folder.
134
532
  * Idempotent, fail-safe. Rollback compatibility is not required.
135
533
  */
136
- async function migrateToNewStructure() {
534
+ async function migrateToNewStructure(paths, legacyPaths) {
137
535
  let migrated = false;
138
536
  try {
139
- await fs.mkdir(PATHS.baseDir, { recursive: true });
537
+ await fs.mkdir(paths.baseDir, { recursive: true });
140
538
  // intelligence.json
141
- if (!(await fileExists(PATHS.intelligence))) {
142
- if (await fileExists(LEGACY_PATHS.intelligence)) {
143
- await fs.copyFile(LEGACY_PATHS.intelligence, PATHS.intelligence);
539
+ if (!(await fileExists(paths.intelligence))) {
540
+ if (await fileExists(legacyPaths.intelligence)) {
541
+ await fs.copyFile(legacyPaths.intelligence, paths.intelligence);
144
542
  migrated = true;
145
543
  if (process.env.CODEBASE_CONTEXT_DEBUG) {
146
544
  console.error('[DEBUG] Migrated intelligence.json');
@@ -148,9 +546,9 @@ async function migrateToNewStructure() {
148
546
  }
149
547
  }
150
548
  // index.json (keyword index)
151
- if (!(await fileExists(PATHS.keywordIndex))) {
152
- if (await fileExists(LEGACY_PATHS.keywordIndex)) {
153
- await fs.copyFile(LEGACY_PATHS.keywordIndex, PATHS.keywordIndex);
549
+ if (!(await fileExists(paths.keywordIndex))) {
550
+ if (await fileExists(legacyPaths.keywordIndex)) {
551
+ await fs.copyFile(legacyPaths.keywordIndex, paths.keywordIndex);
154
552
  migrated = true;
155
553
  if (process.env.CODEBASE_CONTEXT_DEBUG) {
156
554
  console.error('[DEBUG] Migrated index.json');
@@ -158,9 +556,9 @@ async function migrateToNewStructure() {
158
556
  }
159
557
  }
160
558
  // Vector DB directory
161
- if (!(await fileExists(PATHS.vectorDb))) {
162
- if (await fileExists(LEGACY_PATHS.vectorDb)) {
163
- await fs.rename(LEGACY_PATHS.vectorDb, PATHS.vectorDb);
559
+ if (!(await fileExists(paths.vectorDb))) {
560
+ if (await fileExists(legacyPaths.vectorDb)) {
561
+ await fs.rename(legacyPaths.vectorDb, paths.vectorDb);
164
562
  migrated = true;
165
563
  if (process.env.CODEBASE_CONTEXT_DEBUG) {
166
564
  console.error('[DEBUG] Migrated vector database');
@@ -178,10 +576,6 @@ async function migrateToNewStructure() {
178
576
  }
179
577
  // Read version from package.json so it never drifts
180
578
  const PKG_VERSION = JSON.parse(await fs.readFile(new URL('../package.json', import.meta.url), 'utf-8')).version;
181
- const indexState = {
182
- status: 'idle'
183
- };
184
- const autoRefresh = createAutoRefreshController();
185
579
  const server = new Server({
186
580
  name: 'codebase-context',
187
581
  version: PKG_VERSION
@@ -194,34 +588,37 @@ const server = new Server({
194
588
  server.setRequestHandler(ListToolsRequestSchema, async () => {
195
589
  return { tools: TOOLS };
196
590
  });
197
- // MCP Resources - Proactive context injection
198
- const RESOURCES = [
199
- {
200
- uri: CONTEXT_RESOURCE_URI,
201
- name: 'Codebase Intelligence',
202
- description: 'Automatic codebase context: libraries used, team patterns, and conventions. ' +
203
- 'Read this BEFORE generating code to follow team standards.',
204
- mimeType: 'text/plain'
591
+ function buildResources() {
592
+ const resources = [
593
+ {
594
+ uri: CONTEXT_RESOURCE_URI,
595
+ name: 'Codebase Intelligence',
596
+ description: 'Context for the active project in this MCP session. In multi-project sessions, this falls back to a workspace overview until a project is selected.',
597
+ mimeType: 'text/plain'
598
+ }
599
+ ];
600
+ for (const project of listProjectDescriptors()) {
601
+ resources.push({
602
+ uri: buildProjectContextResourceUri(project.rootPath),
603
+ name: `Codebase Intelligence (${project.label})`,
604
+ description: `Project-scoped context for ${project.label}.`,
605
+ mimeType: 'text/plain'
606
+ });
205
607
  }
206
- ];
608
+ return resources;
609
+ }
207
610
  server.setRequestHandler(ListResourcesRequestSchema, async () => {
208
- return { resources: RESOURCES };
611
+ return { resources: buildResources() };
209
612
  });
210
- async function generateCodebaseContext() {
211
- const intelligencePath = PATHS.intelligence;
212
- const index = await ensureValidIndexOrAutoHeal();
613
+ async function generateCodebaseContext(project) {
614
+ const intelligencePath = project.paths.intelligence;
615
+ const index = await ensureValidIndexOrAutoHeal(project);
213
616
  if (index.status === 'indexing') {
214
617
  return ('# Codebase Intelligence\n\n' +
215
618
  'Index is still being built. Retry in a moment.\n\n' +
216
619
  `Index: ${index.status} (${index.confidence}, ${index.action})` +
217
620
  (index.reason ? `\nReason: ${index.reason}` : ''));
218
621
  }
219
- if (index.action === 'rebuild-failed') {
220
- return ('# Codebase Intelligence\n\n' +
221
- 'Index rebuild required before intelligence can be served.\n\n' +
222
- `Index: ${index.status} (${index.confidence}, ${index.action})` +
223
- (index.reason ? `\nReason: ${index.reason}` : ''));
224
- }
225
622
  try {
226
623
  const content = await fs.readFile(intelligencePath, 'utf-8');
227
624
  const intelligence = JSON.parse(content);
@@ -338,16 +735,68 @@ async function generateCodebaseContext() {
338
735
  `Error: ${error instanceof Error ? error.message : String(error)}`);
339
736
  }
340
737
  }
738
+ function buildProjectSelectionMessage() {
739
+ const projects = listProjectDescriptors();
740
+ if (projects.length === 0) {
741
+ return [
742
+ '# Codebase Workspace',
743
+ '',
744
+ 'This MCP session is waiting for project context.',
745
+ 'If your host supports MCP roots, project discovery will begin after the client announces its workspace roots.',
746
+ 'Otherwise retry the tool call with `project` using an absolute project path, file path, or file:// URI.'
747
+ ].join('\n');
748
+ }
749
+ const lines = [
750
+ '# Codebase Workspace',
751
+ '',
752
+ 'This MCP session is using client-announced roots as the workspace boundary.',
753
+ 'Automatic routing is only possible when one project is unambiguous or this session already has an active project.',
754
+ 'If the MCP client does not provide enough context, retry tool calls with `project` using a root path, subproject path, or file path.',
755
+ '',
756
+ 'Available projects:',
757
+ ''
758
+ ];
759
+ for (const project of projects) {
760
+ const projectPathHint = project.relativePath
761
+ ? `${project.relativePath} | ${project.rootPath}`
762
+ : project.rootPath;
763
+ lines.push(`- ${project.label} [${project.indexStatus}]`);
764
+ lines.push(` project: ${projectPathHint}`);
765
+ lines.push(` resource: ${buildProjectContextResourceUri(project.rootPath)}`);
766
+ }
767
+ lines.push('');
768
+ lines.push('Recommended flow: retry the tool call with `project`.');
769
+ return lines.join('\n');
770
+ }
341
771
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
342
772
  const uri = request.params.uri;
773
+ const explicitProjectPath = getProjectPathFromContextResourceUri(uri);
774
+ if (explicitProjectPath) {
775
+ const selection = await resolveProjectSelector(explicitProjectPath);
776
+ if (!selection.ok) {
777
+ throw new Error(`Unknown project resource: ${uri}`);
778
+ }
779
+ const project = selection.project;
780
+ await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true });
781
+ setActiveProject(project.rootPath);
782
+ return {
783
+ contents: [
784
+ {
785
+ uri: buildProjectContextResourceUri(project.rootPath),
786
+ mimeType: 'text/plain',
787
+ text: await generateCodebaseContext(project)
788
+ }
789
+ ]
790
+ };
791
+ }
343
792
  if (isContextResourceUri(uri)) {
344
- const content = await generateCodebaseContext();
793
+ const project = await resolveProjectForResource();
345
794
  return {
346
795
  contents: [
347
796
  {
348
797
  uri: CONTEXT_RESOURCE_URI,
349
798
  mimeType: 'text/plain',
350
- text: content
799
+ text: project ? await generateCodebaseContext(project) : buildProjectSelectionMessage()
351
800
  }
352
801
  ]
353
802
  };
@@ -358,16 +807,16 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
358
807
  * Extract memories from conventional git commits (refactor:, migrate:, fix:, revert:).
359
808
  * Scans last 90 days. Deduplicates via content hash. Zero friction alternative to manual memory.
360
809
  */
361
- async function extractGitMemories() {
810
+ async function extractGitMemories(rootPath, memoryPath) {
362
811
  // Quick check: skip if not a git repo
363
- if (!(await fileExists(path.join(ROOT_PATH, '.git'))))
812
+ if (!(await fileExists(path.join(rootPath, '.git'))))
364
813
  return 0;
365
814
  const { execSync } = await import('child_process');
366
815
  let log;
367
816
  try {
368
817
  // Format: ISO-date<TAB>hash subject (e.g. "2026-01-15T10:00:00+00:00\tabc1234 fix: race condition")
369
818
  log = execSync('git log --format="%aI\t%h %s" --since="90 days ago" --no-merges', {
370
- cwd: ROOT_PATH,
819
+ cwd: rootPath,
371
820
  encoding: 'utf-8',
372
821
  timeout: 5000
373
822
  }).trim();
@@ -384,20 +833,20 @@ async function extractGitMemories() {
384
833
  const parsedMemory = parseGitLogLineToMemory(line);
385
834
  if (!parsedMemory)
386
835
  continue;
387
- const result = await appendMemoryFile(PATHS.memory, parsedMemory);
836
+ const result = await appendMemoryFile(memoryPath, parsedMemory);
388
837
  if (result.status === 'added')
389
838
  added++;
390
839
  }
391
840
  return added;
392
841
  }
393
- async function performIndexingOnce(incrementalOnly) {
394
- indexState.status = 'indexing';
842
+ async function performIndexingOnce(project, incrementalOnly) {
843
+ project.indexState.status = 'indexing';
395
844
  const mode = incrementalOnly ? 'incremental' : 'full';
396
- console.error(`Indexing (${mode}): ${ROOT_PATH}`);
845
+ console.error(`Indexing (${mode}): ${project.rootPath}`);
397
846
  try {
398
847
  let lastLoggedProgress = { phase: '', percentage: -1 };
399
848
  const indexer = new CodebaseIndexer({
400
- rootPath: ROOT_PATH,
849
+ rootPath: project.rootPath,
401
850
  incrementalOnly,
402
851
  onProgress: (progress) => {
403
852
  // Only log when phase or percentage actually changes (prevents duplicate logs)
@@ -409,15 +858,15 @@ async function performIndexingOnce(incrementalOnly) {
409
858
  }
410
859
  }
411
860
  });
412
- indexState.indexer = indexer;
861
+ project.indexState.indexer = indexer;
413
862
  const stats = await indexer.index();
414
- indexState.status = 'ready';
415
- indexState.lastIndexed = new Date();
416
- indexState.stats = stats;
863
+ project.indexState.status = 'ready';
864
+ project.indexState.lastIndexed = new Date();
865
+ project.indexState.stats = stats;
417
866
  console.error(`Complete: ${stats.indexedFiles} files, ${stats.totalChunks} chunks in ${(stats.duration / 1000).toFixed(2)}s`);
418
867
  // Auto-extract memories from git history (non-blocking, best-effort)
419
868
  try {
420
- const gitMemories = await extractGitMemories();
869
+ const gitMemories = await extractGitMemories(project.rootPath, project.paths.memory);
421
870
  if (gitMemories > 0) {
422
871
  console.error(`[git-memory] Extracted ${gitMemories} new memor${gitMemories === 1 ? 'y' : 'ies'} from git history`);
423
872
  }
@@ -427,16 +876,16 @@ async function performIndexingOnce(incrementalOnly) {
427
876
  }
428
877
  }
429
878
  catch (error) {
430
- indexState.status = 'error';
431
- indexState.error = error instanceof Error ? error.message : String(error);
432
- console.error('Indexing failed:', indexState.error);
879
+ project.indexState.status = 'error';
880
+ project.indexState.error = error instanceof Error ? error.message : String(error);
881
+ console.error('Indexing failed:', project.indexState.error);
433
882
  }
434
883
  }
435
- async function performIndexing(incrementalOnly) {
884
+ async function performIndexing(project, incrementalOnly) {
436
885
  let nextMode = incrementalOnly;
437
886
  for (;;) {
438
- await performIndexingOnce(nextMode);
439
- const shouldRunQueuedRefresh = autoRefresh.consumeQueuedRefresh(indexState.status);
887
+ await performIndexingOnce(project, nextMode);
888
+ const shouldRunQueuedRefresh = project.autoRefresh.consumeQueuedRefresh(project.indexState.status);
440
889
  if (!shouldRunQueuedRefresh)
441
890
  return;
442
891
  if (process.env.CODEBASE_CONTEXT_DEBUG) {
@@ -445,23 +894,156 @@ async function performIndexing(incrementalOnly) {
445
894
  nextMode = true;
446
895
  }
447
896
  }
448
- async function shouldReindex() {
449
- const indexPath = PATHS.keywordIndex;
897
+ async function shouldReindex(paths) {
450
898
  try {
451
- await fs.access(indexPath);
899
+ await fs.access(paths.keywordIndex);
452
900
  return false;
453
901
  }
454
902
  catch {
455
903
  return true;
456
904
  }
457
905
  }
906
+ async function refreshDiscoveredProjectsForKnownRoots() {
907
+ clearDiscoveredProjectPaths();
908
+ await Promise.all(getKnownRootPaths().map(async (rootPath) => {
909
+ const candidates = await discoverProjectsWithinRoot(rootPath, {
910
+ maxDepth: PROJECT_DISCOVERY_MAX_DEPTH
911
+ });
912
+ for (const candidate of candidates) {
913
+ registerDiscoveredProjectPath(candidate.rootPath, 'subdirectory');
914
+ }
915
+ }));
916
+ }
917
+ async function validateClientRootEntries(rootEntries) {
918
+ const validatedRoots = await Promise.all(rootEntries.map(async (entry) => {
919
+ try {
920
+ const stats = await fs.stat(entry.rootPath);
921
+ if (!stats.isDirectory()) {
922
+ return undefined;
923
+ }
924
+ return entry;
925
+ }
926
+ catch {
927
+ return undefined;
928
+ }
929
+ }));
930
+ return validatedRoots.filter((entry) => !!entry);
931
+ }
932
+ async function refreshKnownRootsFromClient() {
933
+ try {
934
+ const { roots } = await server.listRoots();
935
+ const fileRoots = await validateClientRootEntries(roots
936
+ .map((root) => ({
937
+ uri: root.uri,
938
+ label: typeof root.name === 'string' && root.name.trim() ? root.name.trim() : undefined
939
+ }))
940
+ .filter((root) => root.uri.startsWith('file://'))
941
+ .map((root) => ({
942
+ rootPath: fileURLToPath(root.uri),
943
+ label: root.label
944
+ })));
945
+ clientRootsEnabled = fileRoots.length > 0;
946
+ syncKnownRoots(fileRoots);
947
+ }
948
+ catch {
949
+ clientRootsEnabled = false;
950
+ syncKnownRoots(primaryRootPath ? [{ rootPath: primaryRootPath }] : []);
951
+ }
952
+ await refreshDiscoveredProjectsForKnownRoots();
953
+ }
954
+ async function resolveExplicitProjectSelection(selection) {
955
+ const explicitProject = selection.project ?? selection.projectDirectory;
956
+ if (explicitProject) {
957
+ const resolution = await resolveProjectSelector(explicitProject);
958
+ if (!resolution.ok) {
959
+ return resolution;
960
+ }
961
+ await initProject(resolution.project.rootPath, watcherDebounceMs, { enableWatcher: true });
962
+ setActiveProject(resolution.project.rootPath);
963
+ return resolution;
964
+ }
965
+ return {
966
+ ok: false,
967
+ response: buildProjectSelectionError('selection_required', 'No project selector was provided.')
968
+ };
969
+ }
970
+ async function resolveProjectForTool(args) {
971
+ const requestedProject = parseProjectSelector(args.project);
972
+ const requestedProjectDirectory = parseProjectSelector(args.project_directory);
973
+ if (requestedProject || requestedProjectDirectory) {
974
+ return resolveExplicitProjectSelection({
975
+ project: requestedProject,
976
+ projectDirectory: requestedProjectDirectory
977
+ });
978
+ }
979
+ const activeProject = activeProjectKey ? getTrackedRootPathByKey(activeProjectKey) : undefined;
980
+ if (activeProject) {
981
+ const project = getOrCreateProject(activeProject);
982
+ await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true });
983
+ touchProject(project.rootPath);
984
+ return { ok: true, project };
985
+ }
986
+ const availableProjects = listProjectDescriptors();
987
+ if (availableProjects.length === 0) {
988
+ return {
989
+ ok: false,
990
+ response: buildProjectSelectionError('selection_required', 'No active project is available yet. Retry with project or wait for MCP roots to arrive.', {
991
+ reason: clientRootsEnabled
992
+ ? 'workspace_waiting_for_project_selection'
993
+ : 'workspace_waiting_for_roots_or_project',
994
+ nextAction: 'retry_with_project'
995
+ })
996
+ };
997
+ }
998
+ if (availableProjects.length === 1) {
999
+ const project = getOrCreateProject(availableProjects[0].rootPath);
1000
+ await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true });
1001
+ setActiveProject(project.rootPath);
1002
+ return { ok: true, project };
1003
+ }
1004
+ return {
1005
+ ok: false,
1006
+ response: buildProjectSelectionError('selection_required', 'Multiple projects are available and no active project could be inferred. Retry with project.', {
1007
+ reason: 'multiple_projects_configured_no_active_context',
1008
+ nextAction: 'retry_with_project'
1009
+ })
1010
+ };
1011
+ }
1012
+ async function resolveProjectForResource() {
1013
+ const activeProject = activeProjectKey ? getTrackedRootPathByKey(activeProjectKey) : undefined;
1014
+ if (activeProject) {
1015
+ const project = getOrCreateProject(activeProject);
1016
+ await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true });
1017
+ touchProject(project.rootPath);
1018
+ return project;
1019
+ }
1020
+ const availableProjects = listProjectDescriptors();
1021
+ if (availableProjects.length !== 1) {
1022
+ return undefined;
1023
+ }
1024
+ const project = getOrCreateProject(availableProjects[0].rootPath);
1025
+ await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true });
1026
+ setActiveProject(project.rootPath);
1027
+ return project;
1028
+ }
458
1029
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
459
1030
  const { name, arguments: args } = request.params;
1031
+ const normalizedArgs = args && typeof args === 'object' && !Array.isArray(args)
1032
+ ? args
1033
+ : {};
460
1034
  try {
1035
+ if (!toolNames.has(name)) {
1036
+ return await dispatchTool(name, normalizedArgs, createWorkspaceToolContext());
1037
+ }
1038
+ const projectResolution = await resolveProjectForTool(normalizedArgs);
1039
+ if (!projectResolution.ok) {
1040
+ return projectResolution.response;
1041
+ }
1042
+ const project = projectResolution.project;
461
1043
  // Gate INDEX_CONSUMING tools on a valid, healthy index
462
1044
  let indexSignal;
463
1045
  if (INDEX_CONSUMING_TOOL_NAMES.includes(name)) {
464
- if (indexState.status === 'indexing') {
1046
+ if (project.indexState.status === 'indexing') {
465
1047
  return {
466
1048
  content: [
467
1049
  {
@@ -474,49 +1056,59 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
474
1056
  ]
475
1057
  };
476
1058
  }
477
- if (indexState.status === 'error') {
1059
+ if (project.indexState.status === 'error') {
478
1060
  return {
479
1061
  content: [
480
1062
  {
481
1063
  type: 'text',
482
1064
  text: JSON.stringify({
483
1065
  status: 'error',
484
- message: `Indexer error: ${indexState.error}`
1066
+ message: `Indexer error: ${project.indexState.error}`
485
1067
  })
486
1068
  }
487
1069
  ]
488
1070
  };
489
1071
  }
490
- indexSignal = await ensureValidIndexOrAutoHeal();
491
- if (indexSignal.action === 'rebuild-failed') {
1072
+ indexSignal = await ensureValidIndexOrAutoHeal(project);
1073
+ if (indexSignal.action === 'rebuild-started') {
492
1074
  return {
493
1075
  content: [
494
1076
  {
495
1077
  type: 'text',
496
1078
  text: JSON.stringify({
497
- error: 'Index is corrupt and could not be rebuilt automatically.',
1079
+ status: 'indexing',
1080
+ message: 'Index rebuild in progress — please retry shortly',
498
1081
  index: indexSignal
499
1082
  })
500
1083
  }
501
- ],
502
- isError: true
1084
+ ]
503
1085
  };
504
1086
  }
505
1087
  }
506
- const ctx = {
507
- indexState,
508
- paths: PATHS,
509
- rootPath: ROOT_PATH,
510
- performIndexing
511
- };
512
- const result = await dispatchTool(name, args ?? {}, ctx);
513
- // Inject IndexSignal into response so callers can inspect index health
1088
+ const result = await dispatchTool(name, normalizedArgs, createToolContext(project));
1089
+ // Inject routing/index metadata into JSON responses so agents can reuse the resolved project safely.
514
1090
  if (indexSignal !== undefined && result.content?.[0]) {
515
1091
  try {
516
1092
  const parsed = JSON.parse(result.content[0].text);
517
1093
  result.content[0] = {
518
1094
  type: 'text',
519
- text: JSON.stringify({ ...parsed, index: indexSignal })
1095
+ text: JSON.stringify({
1096
+ ...parsed,
1097
+ index: indexSignal,
1098
+ project: buildProjectDescriptor(project.rootPath)
1099
+ })
1100
+ };
1101
+ }
1102
+ catch {
1103
+ /* response wasn't JSON, skip injection */
1104
+ }
1105
+ }
1106
+ else if (result.content?.[0]) {
1107
+ try {
1108
+ const parsed = JSON.parse(result.content[0].text);
1109
+ result.content[0] = {
1110
+ type: 'text',
1111
+ text: JSON.stringify({ ...parsed, project: buildProjectDescriptor(project.rootPath) })
520
1112
  };
521
1113
  }
522
1114
  catch {
@@ -537,101 +1129,201 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
537
1129
  };
538
1130
  }
539
1131
  });
540
- async function main() {
541
- // Validate root path exists and is a directory
542
- try {
543
- const stats = await fs.stat(ROOT_PATH);
544
- if (!stats.isDirectory()) {
545
- console.error(`ERROR: Root path is not a directory: ${ROOT_PATH}`);
546
- console.error(`Please specify a valid project directory.`);
547
- process.exit(1);
548
- }
1132
+ async function ensureProjectInitialized(project) {
1133
+ if (project.initPromise) {
1134
+ await project.initPromise;
1135
+ return;
549
1136
  }
550
- catch (_error) {
551
- console.error(`ERROR: Root path does not exist: ${ROOT_PATH}`);
552
- console.error(`Please specify a valid project directory.`);
553
- process.exit(1);
1137
+ if (project.indexState.status !== 'idle') {
1138
+ return;
554
1139
  }
555
- // Migrate legacy structure before server starts
556
- try {
557
- const migrated = await migrateToNewStructure();
558
- if (migrated && process.env.CODEBASE_CONTEXT_DEBUG) {
559
- console.error('[DEBUG] Migrated to .codebase-context/ structure');
1140
+ project.initPromise = (async () => {
1141
+ // Migrate legacy structure
1142
+ try {
1143
+ const legacyPaths = makeLegacyPaths(project.rootPath);
1144
+ const migrated = await migrateToNewStructure(project.paths, legacyPaths);
1145
+ if (migrated && process.env.CODEBASE_CONTEXT_DEBUG) {
1146
+ console.error(`[DEBUG] Migrated to .codebase-context/ structure: ${project.rootPath}`);
1147
+ }
560
1148
  }
1149
+ catch {
1150
+ // Non-fatal
1151
+ }
1152
+ // Check if indexing is needed
1153
+ const needsIndex = await shouldReindex(project.paths);
1154
+ if (needsIndex) {
1155
+ if (process.env.CODEBASE_CONTEXT_DEBUG) {
1156
+ console.error(`[DEBUG] Starting indexing: ${project.rootPath}`);
1157
+ }
1158
+ void performIndexing(project);
1159
+ }
1160
+ else {
1161
+ if (process.env.CODEBASE_CONTEXT_DEBUG) {
1162
+ console.error(`[DEBUG] Index found. Ready: ${project.rootPath}`);
1163
+ }
1164
+ project.indexState.status = 'ready';
1165
+ project.indexState.lastIndexed = new Date();
1166
+ }
1167
+ })().finally(() => {
1168
+ project.initPromise = undefined;
1169
+ });
1170
+ await project.initPromise;
1171
+ }
1172
+ function ensureProjectWatcher(project, debounceMs) {
1173
+ if (project.stopWatcher) {
1174
+ touchProject(project.rootPath);
1175
+ return;
561
1176
  }
562
- catch (error) {
563
- // Non-fatal: continue with current paths
564
- if (process.env.CODEBASE_CONTEXT_DEBUG) {
565
- console.error('[DEBUG] Migration failed:', error);
1177
+ project.stopWatcher = startFileWatcher({
1178
+ rootPath: project.rootPath,
1179
+ debounceMs,
1180
+ onChanged: () => {
1181
+ const shouldRunNow = project.autoRefresh.onFileChange(project.indexState.status === 'indexing');
1182
+ if (!shouldRunNow) {
1183
+ if (process.env.CODEBASE_CONTEXT_DEBUG) {
1184
+ console.error(`[file-watcher] Index in progress — queueing auto-refresh: ${project.rootPath}`);
1185
+ }
1186
+ return;
1187
+ }
1188
+ if (process.env.CODEBASE_CONTEXT_DEBUG) {
1189
+ console.error(`[file-watcher] Changes detected — incremental reindex starting: ${project.rootPath}`);
1190
+ }
1191
+ void performIndexing(project, true);
1192
+ }
1193
+ });
1194
+ touchProject(project.rootPath);
1195
+ const watchedProjects = getAllProjects().filter((entry) => entry.stopWatcher);
1196
+ if (watchedProjects.length <= MAX_WATCHED_PROJECTS) {
1197
+ return;
1198
+ }
1199
+ const evictionCandidates = watchedProjects
1200
+ .filter((entry) => normalizeRootKey(entry.rootPath) !== activeProjectKey)
1201
+ .sort((a, b) => {
1202
+ const accessA = projectAccessOrder.get(normalizeRootKey(a.rootPath)) ?? 0;
1203
+ const accessB = projectAccessOrder.get(normalizeRootKey(b.rootPath)) ?? 0;
1204
+ return accessA - accessB;
1205
+ });
1206
+ const projectToEvict = evictionCandidates[0];
1207
+ if (projectToEvict?.stopWatcher) {
1208
+ projectToEvict.stopWatcher();
1209
+ delete projectToEvict.stopWatcher;
1210
+ }
1211
+ }
1212
+ async function initProject(rootPath, debounceMs, options) {
1213
+ rememberProjectPath(rootPath);
1214
+ const project = getOrCreateProject(rootPath);
1215
+ await ensureProjectInitialized(project);
1216
+ touchProject(project.rootPath);
1217
+ if (options.enableWatcher) {
1218
+ ensureProjectWatcher(project, debounceMs);
1219
+ }
1220
+ }
1221
+ async function main() {
1222
+ if (primaryRootPath) {
1223
+ // Validate bootstrap root path exists and is a directory when explicitly configured.
1224
+ try {
1225
+ const stats = await fs.stat(primaryRootPath);
1226
+ if (!stats.isDirectory()) {
1227
+ console.error(`ERROR: Root path is not a directory: ${primaryRootPath}`);
1228
+ console.error(`Please specify a valid project directory.`);
1229
+ process.exit(1);
1230
+ }
1231
+ }
1232
+ catch (_error) {
1233
+ console.error(`ERROR: Root path does not exist: ${primaryRootPath}`);
1234
+ console.error(`Please specify a valid project directory.`);
1235
+ process.exit(1);
566
1236
  }
567
1237
  }
568
1238
  // Server startup banner (guarded to avoid stderr during MCP STDIO handshake)
569
1239
  if (process.env.CODEBASE_CONTEXT_DEBUG) {
570
1240
  console.error('[DEBUG] Codebase Context MCP Server');
571
- console.error(`[DEBUG] Root: ${ROOT_PATH}`);
1241
+ console.error(primaryRootPath
1242
+ ? `[DEBUG] Bootstrap root: ${primaryRootPath}`
1243
+ : '[DEBUG] Bootstrap root: <workspace-awaiting>');
572
1244
  console.error(`[DEBUG] Analyzers: ${analyzerRegistry
573
1245
  .getAll()
574
1246
  .map((a) => a.name)
575
1247
  .join(', ')}`);
576
1248
  }
577
1249
  // Check for package.json to confirm it's a project root (guarded to avoid stderr during handshake)
578
- if (process.env.CODEBASE_CONTEXT_DEBUG) {
1250
+ if (process.env.CODEBASE_CONTEXT_DEBUG && primaryRootPath) {
579
1251
  try {
580
- await fs.access(path.join(ROOT_PATH, 'package.json'));
581
- console.error(`[DEBUG] Project detected: ${path.basename(ROOT_PATH)}`);
1252
+ await fs.access(path.join(primaryRootPath, 'package.json'));
1253
+ console.error(`[DEBUG] Project detected: ${path.basename(primaryRootPath)}`);
582
1254
  }
583
1255
  catch {
584
1256
  console.error(`[DEBUG] WARNING: No package.json found. This may not be a project root.`);
585
1257
  }
586
1258
  }
587
- const needsIndex = await shouldReindex();
588
- if (needsIndex) {
589
- if (process.env.CODEBASE_CONTEXT_DEBUG)
590
- console.error('[DEBUG] Starting indexing...');
591
- performIndexing();
592
- }
593
- else {
594
- if (process.env.CODEBASE_CONTEXT_DEBUG)
595
- console.error('[DEBUG] Index found. Ready.');
596
- indexState.status = 'ready';
597
- indexState.lastIndexed = new Date();
1259
+ // Parent death guard — catches SIGKILL, crashes, terminal close on ALL platforms.
1260
+ // process.kill(pid, 0) throws ESRCH when the process no longer exists.
1261
+ const parentPid = process.ppid;
1262
+ if (parentPid > 1) {
1263
+ const parentGuard = setInterval(() => {
1264
+ try {
1265
+ process.kill(parentPid, 0);
1266
+ }
1267
+ catch (err) {
1268
+ // ESRCH = process gone → exit. EPERM = process alive, different UID → ignore.
1269
+ if (err.code === 'ESRCH') {
1270
+ process.exit(0);
1271
+ }
1272
+ }
1273
+ }, 5_000);
1274
+ parentGuard.unref();
598
1275
  }
599
1276
  const transport = new StdioServerTransport();
600
1277
  await server.connect(transport);
601
- if (process.env.CODEBASE_CONTEXT_DEBUG)
602
- console.error('[DEBUG] Server ready');
603
- // Auto-refresh: watch for file changes and trigger incremental reindex
604
- const debounceEnv = Number.parseInt(process.env.CODEBASE_CONTEXT_DEBOUNCE_MS ?? '', 10);
605
- const debounceMs = Number.isFinite(debounceEnv) && debounceEnv >= 0 ? debounceEnv : 2000;
606
- const stopWatcher = startFileWatcher({
607
- rootPath: ROOT_PATH,
608
- debounceMs,
609
- onChanged: () => {
610
- const shouldRunNow = autoRefresh.onFileChange(indexState.status === 'indexing');
611
- if (!shouldRunNow) {
612
- if (process.env.CODEBASE_CONTEXT_DEBUG) {
613
- console.error('[file-watcher] Index in progress — queueing auto-refresh');
614
- }
615
- return;
616
- }
617
- if (process.env.CODEBASE_CONTEXT_DEBUG) {
618
- console.error('[file-watcher] Changes detected — incremental reindex starting');
619
- }
620
- void performIndexing(true);
1278
+ // Register cleanup before any handler that calls process.exit(), so the
1279
+ // exit listener is always in place when stdin/onclose/signals fire.
1280
+ const stopAllWatchers = () => {
1281
+ for (const project of getAllProjects()) {
1282
+ project.stopWatcher?.();
621
1283
  }
622
- });
623
- process.once('exit', stopWatcher);
1284
+ };
1285
+ process.once('exit', stopAllWatchers);
624
1286
  process.once('SIGINT', () => {
625
- stopWatcher();
1287
+ stopAllWatchers();
626
1288
  process.exit(0);
627
1289
  });
628
1290
  process.once('SIGTERM', () => {
629
- stopWatcher();
1291
+ stopAllWatchers();
1292
+ process.exit(0);
1293
+ });
1294
+ process.once('SIGHUP', () => {
1295
+ stopAllWatchers();
630
1296
  process.exit(0);
631
1297
  });
1298
+ // Detect stdin pipe closure — the primary signal that the MCP client is gone.
1299
+ // StdioServerTransport only listens for 'data'/'error', never 'end'.
1300
+ process.stdin.on('end', () => process.exit(0));
1301
+ process.stdin.on('close', () => process.exit(0));
1302
+ // Handle graceful MCP protocol-level disconnect.
1303
+ // Fires after SDK internal cleanup when transport.close() is called.
1304
+ server.onclose = () => process.exit(0);
1305
+ if (process.env.CODEBASE_CONTEXT_DEBUG)
1306
+ console.error('[DEBUG] Server ready');
1307
+ await refreshKnownRootsFromClient();
1308
+ // Keep the current single-project auto-select behavior when exactly one startup project is known.
1309
+ const startupRoots = getKnownRootPaths();
1310
+ if (startupRoots.length === 1) {
1311
+ await initProject(startupRoots[0], watcherDebounceMs, { enableWatcher: true });
1312
+ setActiveProject(startupRoots[0]);
1313
+ }
1314
+ // Subscribe to root changes
1315
+ server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
1316
+ try {
1317
+ await refreshKnownRootsFromClient();
1318
+ }
1319
+ catch {
1320
+ /* best-effort */
1321
+ }
1322
+ });
632
1323
  }
633
1324
  // Export server components for programmatic use
634
- export { server, performIndexing, resolveRootPath, shouldReindex, TOOLS };
1325
+ export { server, refreshKnownRootsFromClient, resolveRootPath, shouldReindex, TOOLS };
1326
+ export { performIndexing };
635
1327
  // Only auto-start when run directly as CLI (not when imported as module)
636
1328
  // Check if this module is the entry point
637
1329
  const isDirectRun = process.argv[1]?.replace(/\\/g, '/').endsWith('index.js') ||