diff --git a/package.json b/package.json index a45e973..b80ad1d 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "overrides": { "@modelcontextprotocol/sdk>ajv": "8.18.0", "@modelcontextprotocol/sdk>@hono/node-server": "1.19.11", + "@modelcontextprotocol/sdk>express-rate-limit": "8.2.2", "@huggingface/transformers>onnxruntime-node": "1.24.2", "minimatch": "10.2.3", "rollup": "4.59.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29863e1..9bfeaf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: '@modelcontextprotocol/sdk>ajv': 8.18.0 '@modelcontextprotocol/sdk>@hono/node-server': 1.19.11 + '@modelcontextprotocol/sdk>express-rate-limit': 8.2.2 '@huggingface/transformers>onnxruntime-node': 1.24.2 minimatch: 10.2.3 rollup: 4.59.0 @@ -1305,8 +1306,8 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - express-rate-limit@8.2.1: - resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + express-rate-limit@8.2.2: + resolution: {integrity: sha512-Ybv7bqtOgA914MLwaHWVFXMpMYeR1MQu/D+z2MaLYteqBsTIp9sY3AU7mGNLMJv8eLg8uQMpE20I+L2Lv49nSg==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -1551,8 +1552,8 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - ip-address@10.0.1: - resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -2788,7 +2789,7 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.0.6 express: 5.2.1 - express-rate-limit: 8.2.1(express@5.2.1) + express-rate-limit: 8.2.2(express@5.2.1) hono: 4.12.5 jose: 6.1.3 json-schema-typed: 8.0.2 @@ -3684,10 +3685,10 @@ snapshots: expect-type@1.3.0: {} - express-rate-limit@8.2.1(express@5.2.1): + express-rate-limit@8.2.2(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.0.1 + ip-address: 10.1.0 express@5.2.1: dependencies: @@ -3970,7 +3971,7 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 - ip-address@10.0.1: {} + ip-address@10.1.0: {} ipaddr.js@1.9.1: {} diff --git a/src/index.ts b/src/index.ts index dc43f01..0ffea2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { promises as fs } from 'fs'; import path from 'path'; +import { fileURLToPath } from 'url'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { @@ -15,11 +16,11 @@ import { ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, + RootsListChangedNotificationSchema, Resource } from '@modelcontextprotocol/sdk/types.js'; import { CodebaseIndexer } from './core/indexer.js'; import type { - IndexingStats, IntelligenceData, PatternsData, PatternEntry, @@ -29,17 +30,9 @@ import { analyzerRegistry } from './core/analyzer-registry.js'; import { AngularAnalyzer } from './analyzers/angular/index.js'; import { GenericAnalyzer } from './analyzers/generic/index.js'; import { IndexCorruptedError } from './errors/index.js'; -import { - CODEBASE_CONTEXT_DIRNAME, - MEMORY_FILENAME, - INTELLIGENCE_FILENAME, - KEYWORD_INDEX_FILENAME, - VECTOR_DB_DIRNAME -} from './constants/codebase-context.js'; import { appendMemoryFile } from './memory/store.js'; import { handleCliCommand } from './cli.js'; import { startFileWatcher } from './core/file-watcher.js'; -import { createAutoRefreshController } from './core/auto-refresh.js'; import { parseGitLogLineToMemory } from './memory/git-memory.js'; import { isComplementaryPatternCategory, @@ -47,7 +40,16 @@ import { } from './patterns/semantics.js'; import { CONTEXT_RESOURCE_URI, isContextResourceUri } from './resources/uri.js'; import { readIndexMeta, validateIndexArtifacts } from './core/index-meta.js'; -import { TOOLS, dispatchTool, type ToolContext } from './tools/index.js'; +import { TOOLS, dispatchTool, type ToolContext, type ToolResponse } from './tools/index.js'; +import type { ToolPaths } from './tools/types.js'; +import { + getOrCreateProject, + getAllProjects, + makeLegacyPaths, + normalizeRootKey, + removeProject, + type ProjectState +} from './project-state.js'; analyzerRegistry.register(new AngularAnalyzer()); analyzerRegistry.register(new GenericAnalyzer()); @@ -70,22 +72,94 @@ function resolveRootPath(): string { return rootPath; } -const ROOT_PATH = resolveRootPath(); +const primaryRootPath = resolveRootPath(); +const primaryProject = getOrCreateProject(primaryRootPath); +const toolNames = new Set(TOOLS.map((tool) => tool.name)); +const knownRoots = new Map(); +let clientRootsEnabled = false; +const debounceEnv = Number.parseInt(process.env.CODEBASE_CONTEXT_DEBOUNCE_MS ?? '', 10); +const watcherDebounceMs = Number.isFinite(debounceEnv) && debounceEnv >= 0 ? debounceEnv : 2000; + +type ProjectResolution = + | { ok: true; project: ProjectState } + | { ok: false; response: ToolResponse }; + +function registerKnownRoot(rootPath: string): string { + const resolvedRootPath = path.resolve(rootPath); + knownRoots.set(normalizeRootKey(resolvedRootPath), resolvedRootPath); + return resolvedRootPath; +} -// File paths (new structure) -const PATHS = { - baseDir: path.join(ROOT_PATH, CODEBASE_CONTEXT_DIRNAME), - memory: path.join(ROOT_PATH, CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME), - intelligence: path.join(ROOT_PATH, CODEBASE_CONTEXT_DIRNAME, INTELLIGENCE_FILENAME), - keywordIndex: path.join(ROOT_PATH, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME), - vectorDb: path.join(ROOT_PATH, CODEBASE_CONTEXT_DIRNAME, VECTOR_DB_DIRNAME) -}; +function getKnownRootPaths(): string[] { + return Array.from(knownRoots.values()).sort((a, b) => a.localeCompare(b)); +} -const LEGACY_PATHS = { - intelligence: path.join(ROOT_PATH, '.codebase-intelligence.json'), - keywordIndex: path.join(ROOT_PATH, '.codebase-index.json'), - vectorDb: path.join(ROOT_PATH, '.codebase-index') -}; +function syncKnownRoots(rootPaths: string[]): void { + const nextRoots = new Map(); + const normalizedRoots = rootPaths.length > 0 ? rootPaths : [primaryRootPath]; + + for (const rootPath of normalizedRoots) { + const resolvedRootPath = path.resolve(rootPath); + nextRoots.set(normalizeRootKey(resolvedRootPath), resolvedRootPath); + } + + for (const [rootKey, existingRootPath] of knownRoots.entries()) { + if (!nextRoots.has(rootKey)) { + removeProject(existingRootPath); + } + } + + knownRoots.clear(); + for (const [rootKey, rootPath] of nextRoots.entries()) { + knownRoots.set(rootKey, rootPath); + } +} + +function parseProjectDirectory(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + + const trimmedValue = value.trim(); + if (!trimmedValue) return undefined; + + return trimmedValue.startsWith('file://') + ? path.resolve(fileURLToPath(trimmedValue)) + : path.resolve(trimmedValue); +} + +function buildProjectSelectionError( + errorCode: 'ambiguous_project' | 'unknown_project', + message: string +): ToolResponse { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + status: 'error', + errorCode, + message, + availableRoots: getKnownRootPaths() + }, + null, + 2 + ) + } + ], + isError: true + }; +} + +function createToolContext(project: ProjectState): ToolContext { + return { + indexState: project.indexState, + paths: project.paths, + rootPath: project.rootPath, + performIndexing: (incrementalOnly?: boolean) => performIndexing(project, incrementalOnly) + }; +} + +registerKnownRoot(primaryRootPath); export const INDEX_CONSUMING_TOOL_NAMES = [ 'search_codebase', @@ -99,7 +173,7 @@ export const INDEX_CONSUMING_RESOURCE_NAMES = ['Codebase Intelligence'] as const type IndexStatus = 'ready' | 'rebuild-required' | 'indexing' | 'unknown'; type IndexConfidence = 'high' | 'low'; -type IndexAction = 'served' | 'rebuild-started' | 'rebuilt-and-served' | 'rebuild-failed'; +type IndexAction = 'served' | 'rebuild-started' | 'rebuilt-and-served'; export type IndexSignal = { status: IndexStatus; @@ -108,12 +182,12 @@ export type IndexSignal = { reason?: string; }; -async function requireValidIndex(rootPath: string): Promise { +async function requireValidIndex(rootPath: string, paths: ToolPaths): Promise { const meta = await readIndexMeta(rootPath); await validateIndexArtifacts(rootPath, meta); // Optional artifact presence informs confidence. - const hasIntelligence = await fileExists(PATHS.intelligence); + const hasIntelligence = await fileExists(paths.intelligence); return { status: 'ready', @@ -123,8 +197,8 @@ async function requireValidIndex(rootPath: string): Promise { }; } -async function ensureValidIndexOrAutoHeal(): Promise { - if (indexState.status === 'indexing') { +async function ensureValidIndexOrAutoHeal(project: ProjectState): Promise { + if (project.indexState.status === 'indexing') { return { status: 'indexing', confidence: 'low', @@ -134,37 +208,21 @@ async function ensureValidIndexOrAutoHeal(): Promise { } try { - return await requireValidIndex(ROOT_PATH); + return await requireValidIndex(project.rootPath, project.paths); } catch (error) { if (error instanceof IndexCorruptedError) { const reason = error.message; console.error(`[Index] ${reason}`); - console.error('[Auto-Heal] Triggering full re-index...'); - - await performIndexing(); - - if (indexState.status === 'ready') { - try { - let validated = await requireValidIndex(ROOT_PATH); - validated = { ...validated, action: 'rebuilt-and-served', reason }; - return validated; - } catch (revalidateError) { - const msg = - revalidateError instanceof Error ? revalidateError.message : String(revalidateError); - return { - status: 'rebuild-required', - confidence: 'low', - action: 'rebuild-failed', - reason: `Auto-heal completed but index did not validate: ${msg}` - }; - } - } + console.error('[Auto-Heal] Triggering background re-index...'); + + // Fire-and-forget: don't block the tool call + void performIndexing(project); return { - status: 'rebuild-required', + status: 'indexing', confidence: 'low', - action: 'rebuild-failed', - reason: `Auto-heal failed: ${indexState.error || reason}` + action: 'rebuild-started', + reason: `Auto-heal triggered: ${reason}` }; } @@ -172,6 +230,25 @@ async function ensureValidIndexOrAutoHeal(): Promise { } } +async function validateProjectDirectory(rootPath: string): Promise { + try { + const stats = await fs.stat(rootPath); + if (stats.isDirectory()) { + return undefined; + } + + return buildProjectSelectionError( + 'unknown_project', + `project_directory is not a directory: ${rootPath}` + ); + } catch { + return buildProjectSelectionError( + 'unknown_project', + `project_directory does not exist: ${rootPath}` + ); + } +} + /** * Check if file/directory exists */ @@ -188,16 +265,19 @@ async function fileExists(filePath: string): Promise { * Migrate legacy file structure to .codebase-context/ folder. * Idempotent, fail-safe. Rollback compatibility is not required. */ -async function migrateToNewStructure(): Promise { +async function migrateToNewStructure( + paths: ToolPaths, + legacyPaths: ReturnType +): Promise { let migrated = false; try { - await fs.mkdir(PATHS.baseDir, { recursive: true }); + await fs.mkdir(paths.baseDir, { recursive: true }); // intelligence.json - if (!(await fileExists(PATHS.intelligence))) { - if (await fileExists(LEGACY_PATHS.intelligence)) { - await fs.copyFile(LEGACY_PATHS.intelligence, PATHS.intelligence); + if (!(await fileExists(paths.intelligence))) { + if (await fileExists(legacyPaths.intelligence)) { + await fs.copyFile(legacyPaths.intelligence, paths.intelligence); migrated = true; if (process.env.CODEBASE_CONTEXT_DEBUG) { console.error('[DEBUG] Migrated intelligence.json'); @@ -206,9 +286,9 @@ async function migrateToNewStructure(): Promise { } // index.json (keyword index) - if (!(await fileExists(PATHS.keywordIndex))) { - if (await fileExists(LEGACY_PATHS.keywordIndex)) { - await fs.copyFile(LEGACY_PATHS.keywordIndex, PATHS.keywordIndex); + if (!(await fileExists(paths.keywordIndex))) { + if (await fileExists(legacyPaths.keywordIndex)) { + await fs.copyFile(legacyPaths.keywordIndex, paths.keywordIndex); migrated = true; if (process.env.CODEBASE_CONTEXT_DEBUG) { console.error('[DEBUG] Migrated index.json'); @@ -217,9 +297,9 @@ async function migrateToNewStructure(): Promise { } // Vector DB directory - if (!(await fileExists(PATHS.vectorDb))) { - if (await fileExists(LEGACY_PATHS.vectorDb)) { - await fs.rename(LEGACY_PATHS.vectorDb, PATHS.vectorDb); + if (!(await fileExists(paths.vectorDb))) { + if (await fileExists(legacyPaths.vectorDb)) { + await fs.rename(legacyPaths.vectorDb, paths.vectorDb); migrated = true; if (process.env.CODEBASE_CONTEXT_DEBUG) { console.error('[DEBUG] Migrated vector database'); @@ -236,25 +316,13 @@ async function migrateToNewStructure(): Promise { } } -export interface IndexState { - status: 'idle' | 'indexing' | 'ready' | 'error'; - lastIndexed?: Date; - stats?: IndexingStats; - error?: string; - indexer?: CodebaseIndexer; -} +export type { IndexState } from './tools/types.js'; // Read version from package.json so it never drifts const PKG_VERSION: string = JSON.parse( await fs.readFile(new URL('../package.json', import.meta.url), 'utf-8') ).version; -const indexState: IndexState = { - status: 'idle' -}; - -const autoRefresh = createAutoRefreshController(); - const server: Server = new Server( { name: 'codebase-context', @@ -288,10 +356,10 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: RESOURCES }; }); -async function generateCodebaseContext(): Promise { - const intelligencePath = PATHS.intelligence; +async function generateCodebaseContext(project: ProjectState): Promise { + const intelligencePath = project.paths.intelligence; - const index = await ensureValidIndexOrAutoHeal(); + const index = await ensureValidIndexOrAutoHeal(project); if (index.status === 'indexing') { return ( '# Codebase Intelligence\n\n' + @@ -300,14 +368,6 @@ async function generateCodebaseContext(): Promise { (index.reason ? `\nReason: ${index.reason}` : '') ); } - if (index.action === 'rebuild-failed') { - return ( - '# Codebase Intelligence\n\n' + - 'Index rebuild required before intelligence can be served.\n\n' + - `Index: ${index.status} (${index.confidence}, ${index.action})` + - (index.reason ? `\nReason: ${index.reason}` : '') - ); - } try { const content = await fs.readFile(intelligencePath, 'utf-8'); @@ -460,7 +520,11 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; if (isContextResourceUri(uri)) { - const content = await generateCodebaseContext(); + const project = await resolveProjectForResource(); + const content = project + ? await generateCodebaseContext(project) + : '# Codebase Intelligence\n\n' + + 'Multiple project roots are available. Use a tool call with `project_directory` to choose a project.'; return { contents: [ @@ -480,9 +544,9 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => { * Extract memories from conventional git commits (refactor:, migrate:, fix:, revert:). * Scans last 90 days. Deduplicates via content hash. Zero friction alternative to manual memory. */ -async function extractGitMemories(): Promise { +async function extractGitMemories(rootPath: string, memoryPath: string): Promise { // Quick check: skip if not a git repo - if (!(await fileExists(path.join(ROOT_PATH, '.git')))) return 0; + if (!(await fileExists(path.join(rootPath, '.git')))) return 0; const { execSync } = await import('child_process'); @@ -490,7 +554,7 @@ async function extractGitMemories(): Promise { try { // Format: ISO-datehash subject (e.g. "2026-01-15T10:00:00+00:00\tabc1234 fix: race condition") log = execSync('git log --format="%aI\t%h %s" --since="90 days ago" --no-merges', { - cwd: ROOT_PATH, + cwd: rootPath, encoding: 'utf-8', timeout: 5000 }).trim(); @@ -508,22 +572,25 @@ async function extractGitMemories(): Promise { const parsedMemory = parseGitLogLineToMemory(line); if (!parsedMemory) continue; - const result = await appendMemoryFile(PATHS.memory, parsedMemory); + const result = await appendMemoryFile(memoryPath, parsedMemory); if (result.status === 'added') added++; } return added; } -async function performIndexingOnce(incrementalOnly?: boolean): Promise { - indexState.status = 'indexing'; +async function performIndexingOnce( + project: ProjectState, + incrementalOnly?: boolean +): Promise { + project.indexState.status = 'indexing'; const mode = incrementalOnly ? 'incremental' : 'full'; - console.error(`Indexing (${mode}): ${ROOT_PATH}`); + console.error(`Indexing (${mode}): ${project.rootPath}`); try { let lastLoggedProgress = { phase: '', percentage: -1 }; const indexer = new CodebaseIndexer({ - rootPath: ROOT_PATH, + rootPath: project.rootPath, incrementalOnly, onProgress: (progress) => { // Only log when phase or percentage actually changes (prevents duplicate logs) @@ -538,12 +605,12 @@ async function performIndexingOnce(incrementalOnly?: boolean): Promise { } }); - indexState.indexer = indexer; + project.indexState.indexer = indexer; const stats = await indexer.index(); - indexState.status = 'ready'; - indexState.lastIndexed = new Date(); - indexState.stats = stats; + project.indexState.status = 'ready'; + project.indexState.lastIndexed = new Date(); + project.indexState.stats = stats; console.error( `Complete: ${stats.indexedFiles} files, ${stats.totalChunks} chunks in ${( @@ -553,7 +620,7 @@ async function performIndexingOnce(incrementalOnly?: boolean): Promise { // Auto-extract memories from git history (non-blocking, best-effort) try { - const gitMemories = await extractGitMemories(); + const gitMemories = await extractGitMemories(project.rootPath, project.paths.memory); if (gitMemories > 0) { console.error( `[git-memory] Extracted ${gitMemories} new memor${gitMemories === 1 ? 'y' : 'ies'} from git history` @@ -563,18 +630,20 @@ async function performIndexingOnce(incrementalOnly?: boolean): Promise { // Git memory extraction is optional — never fail indexing over it } } catch (error) { - indexState.status = 'error'; - indexState.error = error instanceof Error ? error.message : String(error); - console.error('Indexing failed:', indexState.error); + project.indexState.status = 'error'; + project.indexState.error = error instanceof Error ? error.message : String(error); + console.error('Indexing failed:', project.indexState.error); } } -async function performIndexing(incrementalOnly?: boolean): Promise { +async function performIndexing(project: ProjectState, incrementalOnly?: boolean): Promise { let nextMode = incrementalOnly; for (;;) { - await performIndexingOnce(nextMode); + await performIndexingOnce(project, nextMode); - const shouldRunQueuedRefresh = autoRefresh.consumeQueuedRefresh(indexState.status); + const shouldRunQueuedRefresh = project.autoRefresh.consumeQueuedRefresh( + project.indexState.status + ); if (!shouldRunQueuedRefresh) return; if (process.env.CODEBASE_CONTEXT_DEBUG) { @@ -584,24 +653,113 @@ async function performIndexing(incrementalOnly?: boolean): Promise { } } -async function shouldReindex(): Promise { - const indexPath = PATHS.keywordIndex; +async function shouldReindex(paths: ToolPaths): Promise { try { - await fs.access(indexPath); + await fs.access(paths.keywordIndex); return false; } catch { return true; } } +async function refreshKnownRootsFromClient(): Promise { + try { + const { roots } = await server.listRoots(); + const fileRoots = roots + .map((root) => root.uri) + .filter((uri) => uri.startsWith('file://')) + .map((uri) => fileURLToPath(uri)); + + clientRootsEnabled = fileRoots.length > 0; + syncKnownRoots(fileRoots); + } catch { + clientRootsEnabled = false; + syncKnownRoots([primaryRootPath]); + } +} + +async function resolveProjectForTool(args: Record): Promise { + const requestedProjectDirectory = parseProjectDirectory(args.project_directory); + const availableRoots = getKnownRootPaths(); + + if (requestedProjectDirectory) { + const requestedRootKey = normalizeRootKey(requestedProjectDirectory); + const knownRootPath = knownRoots.get(requestedRootKey); + + if (clientRootsEnabled && availableRoots.length > 0 && !knownRootPath) { + return { + ok: false, + response: buildProjectSelectionError( + 'unknown_project', + 'Requested project is not part of the active MCP roots.' + ) + }; + } + + const rootPath = knownRootPath ?? requestedProjectDirectory; + const invalidProjectResponse = await validateProjectDirectory(rootPath); + if (invalidProjectResponse) { + return { ok: false, response: invalidProjectResponse }; + } + + const project = getOrCreateProject(rootPath); + await initProject(project.rootPath, watcherDebounceMs, { + enableWatcher: knownRootPath !== undefined + }); + return { ok: true, project }; + } + + if (availableRoots.length !== 1) { + return { + ok: false, + response: buildProjectSelectionError( + 'ambiguous_project', + 'Multiple project roots are available. Pass project_directory to choose one.' + ) + }; + } + + const [rootPath] = availableRoots; + const project = getOrCreateProject(rootPath); + await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true }); + return { ok: true, project }; +} + +async function resolveProjectForResource(): Promise { + const availableRoots = getKnownRootPaths(); + if (availableRoots.length !== 1) { + return undefined; + } + + const [rootPath] = availableRoots; + const project = getOrCreateProject(rootPath); + await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true }); + return project; +} + server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; + const normalizedArgs = + args && typeof args === 'object' && !Array.isArray(args) + ? (args as Record) + : {}; try { + if (!toolNames.has(name)) { + return await dispatchTool(name, normalizedArgs, createToolContext(primaryProject)); + } + + const projectResolution = await resolveProjectForTool(normalizedArgs); + if (!projectResolution.ok) { + return projectResolution.response; + } + + const project = projectResolution.project; + // Gate INDEX_CONSUMING tools on a valid, healthy index let indexSignal: IndexSignal | undefined; if ((INDEX_CONSUMING_TOOL_NAMES as readonly string[]).includes(name)) { - if (indexState.status === 'indexing') { + if (project.indexState.status === 'indexing') { return { content: [ { @@ -614,44 +772,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { ] }; } - if (indexState.status === 'error') { + if (project.indexState.status === 'error') { return { content: [ { type: 'text', text: JSON.stringify({ status: 'error', - message: `Indexer error: ${indexState.error}` + message: `Indexer error: ${project.indexState.error}` }) } ] }; } - indexSignal = await ensureValidIndexOrAutoHeal(); - if (indexSignal.action === 'rebuild-failed') { + indexSignal = await ensureValidIndexOrAutoHeal(project); + if (indexSignal.action === 'rebuild-started') { return { content: [ { type: 'text', text: JSON.stringify({ - error: 'Index is corrupt and could not be rebuilt automatically.', + status: 'indexing', + message: 'Index rebuild in progress — please retry shortly', index: indexSignal }) } - ], - isError: true + ] }; } } - const ctx: ToolContext = { - indexState, - paths: PATHS, - rootPath: ROOT_PATH, - performIndexing - }; - - const result = await dispatchTool(name, args ?? {}, ctx); + const result = await dispatchTool(name, normalizedArgs, createToolContext(project)); // Inject IndexSignal into response so callers can inspect index health if (indexSignal !== undefined && result.content?.[0]) { @@ -680,38 +831,119 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } }); +/** + * Initialize a project: migrate legacy structure, check index, start watcher. + * Deduplicates via normalized root key. + */ +type InitProjectOptions = { + enableWatcher: boolean; +}; + +async function ensureProjectInitialized(project: ProjectState): Promise { + if (project.initPromise) { + await project.initPromise; + return; + } + + if (project.indexState.status !== 'idle') { + return; + } + + project.initPromise = (async () => { + // Migrate legacy structure + try { + const legacyPaths = makeLegacyPaths(project.rootPath); + const migrated = await migrateToNewStructure(project.paths, legacyPaths); + if (migrated && process.env.CODEBASE_CONTEXT_DEBUG) { + console.error(`[DEBUG] Migrated to .codebase-context/ structure: ${project.rootPath}`); + } + } catch { + // Non-fatal + } + + // Check if indexing is needed + const needsIndex = await shouldReindex(project.paths); + if (needsIndex) { + if (process.env.CODEBASE_CONTEXT_DEBUG) { + console.error(`[DEBUG] Starting indexing: ${project.rootPath}`); + } + void performIndexing(project); + } else { + if (process.env.CODEBASE_CONTEXT_DEBUG) { + console.error(`[DEBUG] Index found. Ready: ${project.rootPath}`); + } + project.indexState.status = 'ready'; + project.indexState.lastIndexed = new Date(); + } + })().finally(() => { + project.initPromise = undefined; + }); + + await project.initPromise; +} + +function ensureProjectWatcher(project: ProjectState, debounceMs: number): void { + if (project.stopWatcher) { + return; + } + + project.stopWatcher = startFileWatcher({ + rootPath: project.rootPath, + debounceMs, + onChanged: () => { + const shouldRunNow = project.autoRefresh.onFileChange( + project.indexState.status === 'indexing' + ); + if (!shouldRunNow) { + if (process.env.CODEBASE_CONTEXT_DEBUG) { + console.error( + `[file-watcher] Index in progress — queueing auto-refresh: ${project.rootPath}` + ); + } + return; + } + if (process.env.CODEBASE_CONTEXT_DEBUG) { + console.error( + `[file-watcher] Changes detected — incremental reindex starting: ${project.rootPath}` + ); + } + void performIndexing(project, true); + } + }); +} + +async function initProject( + rootPath: string, + debounceMs: number, + options: InitProjectOptions +): Promise { + const project = getOrCreateProject(rootPath); + await ensureProjectInitialized(project); + + if (options.enableWatcher) { + ensureProjectWatcher(project, debounceMs); + } +} + async function main() { // Validate root path exists and is a directory try { - const stats = await fs.stat(ROOT_PATH); + const stats = await fs.stat(primaryRootPath); if (!stats.isDirectory()) { - console.error(`ERROR: Root path is not a directory: ${ROOT_PATH}`); + console.error(`ERROR: Root path is not a directory: ${primaryRootPath}`); console.error(`Please specify a valid project directory.`); process.exit(1); } } catch (_error) { - console.error(`ERROR: Root path does not exist: ${ROOT_PATH}`); + console.error(`ERROR: Root path does not exist: ${primaryRootPath}`); console.error(`Please specify a valid project directory.`); process.exit(1); } - // Migrate legacy structure before server starts - try { - const migrated = await migrateToNewStructure(); - if (migrated && process.env.CODEBASE_CONTEXT_DEBUG) { - console.error('[DEBUG] Migrated to .codebase-context/ structure'); - } - } catch (error) { - // Non-fatal: continue with current paths - if (process.env.CODEBASE_CONTEXT_DEBUG) { - console.error('[DEBUG] Migration failed:', error); - } - } - // Server startup banner (guarded to avoid stderr during MCP STDIO handshake) if (process.env.CODEBASE_CONTEXT_DEBUG) { console.error('[DEBUG] Codebase Context MCP Server'); - console.error(`[DEBUG] Root: ${ROOT_PATH}`); + console.error(`[DEBUG] Root: ${primaryRootPath}`); console.error( `[DEBUG] Analyzers: ${analyzerRegistry .getAll() @@ -723,63 +955,56 @@ async function main() { // Check for package.json to confirm it's a project root (guarded to avoid stderr during handshake) if (process.env.CODEBASE_CONTEXT_DEBUG) { try { - await fs.access(path.join(ROOT_PATH, 'package.json')); - console.error(`[DEBUG] Project detected: ${path.basename(ROOT_PATH)}`); + await fs.access(path.join(primaryRootPath, 'package.json')); + console.error(`[DEBUG] Project detected: ${path.basename(primaryRootPath)}`); } catch { console.error(`[DEBUG] WARNING: No package.json found. This may not be a project root.`); } } - const needsIndex = await shouldReindex(); - - if (needsIndex) { - if (process.env.CODEBASE_CONTEXT_DEBUG) console.error('[DEBUG] Starting indexing...'); - performIndexing(); - } else { - if (process.env.CODEBASE_CONTEXT_DEBUG) console.error('[DEBUG] Index found. Ready.'); - indexState.status = 'ready'; - indexState.lastIndexed = new Date(); - } - const transport = new StdioServerTransport(); await server.connect(transport); if (process.env.CODEBASE_CONTEXT_DEBUG) console.error('[DEBUG] Server ready'); - // Auto-refresh: watch for file changes and trigger incremental reindex - const debounceEnv = Number.parseInt(process.env.CODEBASE_CONTEXT_DEBOUNCE_MS ?? '', 10); - const debounceMs = Number.isFinite(debounceEnv) && debounceEnv >= 0 ? debounceEnv : 2000; - const stopWatcher = startFileWatcher({ - rootPath: ROOT_PATH, - debounceMs, - onChanged: () => { - const shouldRunNow = autoRefresh.onFileChange(indexState.status === 'indexing'); - if (!shouldRunNow) { - if (process.env.CODEBASE_CONTEXT_DEBUG) { - console.error('[file-watcher] Index in progress — queueing auto-refresh'); - } - return; - } - if (process.env.CODEBASE_CONTEXT_DEBUG) { - console.error('[file-watcher] Changes detected — incremental reindex starting'); - } - void performIndexing(true); + await refreshKnownRootsFromClient(); + + // Preserve current single-project startup behavior without eagerly indexing every root. + const startupRoots = getKnownRootPaths(); + if (startupRoots.length === 1) { + await initProject(startupRoots[0], watcherDebounceMs, { enableWatcher: true }); + } + + // Subscribe to root changes + server.setNotificationHandler(RootsListChangedNotificationSchema, async () => { + try { + await refreshKnownRootsFromClient(); + } catch { + /* best-effort */ } }); - process.once('exit', stopWatcher); + // Cleanup all watchers on exit + const stopAllWatchers = () => { + for (const project of getAllProjects()) { + project.stopWatcher?.(); + } + }; + + process.once('exit', stopAllWatchers); process.once('SIGINT', () => { - stopWatcher(); + stopAllWatchers(); process.exit(0); }); process.once('SIGTERM', () => { - stopWatcher(); + stopAllWatchers(); process.exit(0); }); } // Export server components for programmatic use -export { server, performIndexing, resolveRootPath, shouldReindex, TOOLS }; +export { server, resolveRootPath, shouldReindex, TOOLS }; +export { performIndexing }; // Only auto-start when run directly as CLI (not when imported as module) // Check if this module is the entry point diff --git a/src/project-state.ts b/src/project-state.ts new file mode 100644 index 0000000..6b260dc --- /dev/null +++ b/src/project-state.ts @@ -0,0 +1,94 @@ +import path from 'path'; +import { + CODEBASE_CONTEXT_DIRNAME, + MEMORY_FILENAME, + INTELLIGENCE_FILENAME, + KEYWORD_INDEX_FILENAME, + VECTOR_DB_DIRNAME +} from './constants/codebase-context.js'; +import { createAutoRefreshController } from './core/auto-refresh.js'; +import type { AutoRefreshController } from './core/auto-refresh.js'; +import type { ToolPaths, IndexState } from './tools/types.js'; + +export interface ProjectState { + rootPath: string; + paths: ToolPaths; + indexState: IndexState; + autoRefresh: AutoRefreshController; + initPromise?: Promise; + stopWatcher?: () => void; +} + +export function makePaths(rootPath: string): ToolPaths { + return { + baseDir: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME), + memory: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME), + intelligence: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, INTELLIGENCE_FILENAME), + keywordIndex: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME), + vectorDb: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, VECTOR_DB_DIRNAME) + }; +} + +export function makeLegacyPaths(rootPath: string) { + return { + intelligence: path.join(rootPath, '.codebase-intelligence.json'), + keywordIndex: path.join(rootPath, '.codebase-index.json'), + vectorDb: path.join(rootPath, '.codebase-index') + }; +} + +export function normalizeRootKey(rootPath: string): string { + let normalized = path.resolve(rootPath); + // Strip trailing separator + while (normalized.length > 1 && (normalized.endsWith('/') || normalized.endsWith('\\'))) { + normalized = normalized.slice(0, -1); + } + // Case-insensitive on Windows + if (process.platform === 'win32') { + normalized = normalized.toLowerCase(); + } + return normalized; +} + +const projects = new Map(); + +export function createProjectState(rootPath: string): ProjectState { + return { + rootPath, + paths: makePaths(rootPath), + indexState: { status: 'idle' }, + autoRefresh: createAutoRefreshController() + }; +} + +export function getOrCreateProject(rootPath: string): ProjectState { + const key = normalizeRootKey(rootPath); + let project = projects.get(key); + if (!project) { + project = createProjectState(rootPath); + projects.set(key, project); + } + return project; +} + +export function getProject(rootPath: string): ProjectState | undefined { + return projects.get(normalizeRootKey(rootPath)); +} + +export function getAllProjects(): ProjectState[] { + return Array.from(projects.values()); +} + +export function removeProject(rootPath: string): void { + const key = normalizeRootKey(rootPath); + const project = projects.get(key); + project?.stopWatcher?.(); + projects.delete(key); +} + +export function clearProjects(): void { + for (const project of projects.values()) { + project.stopWatcher?.(); + } + projects.clear(); +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 9e5f5e7..ac49114 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -15,7 +15,36 @@ import { definition as d10, handle as h10 } from './get-memory.js'; import type { ToolContext, ToolResponse } from './types.js'; -export const TOOLS: Tool[] = [d1, d2, d3, d4, d5, d6, d7, d8, d9, d10]; +const PROJECT_DIRECTORY_PROPERTY: Record = { + type: 'string', + description: + 'Optional absolute path or file:// URI for the project root to use for this call. Must point to an existing directory.' +}; + +function withProjectDirectory(definition: Tool): Tool { + const schema = definition.inputSchema; + if (!schema || schema.type !== 'object') { + return definition; + } + + const properties = { ...(schema.properties ?? {}) }; + if ('project_directory' in properties) { + return definition; + } + + return { + ...definition, + inputSchema: { + ...schema, + properties: { + ...properties, + project_directory: PROJECT_DIRECTORY_PROPERTY + } + } + }; +} + +export const TOOLS: Tool[] = [d1, d2, d3, d4, d5, d6, d7, d8, d9, d10].map(withProjectDirectory); export async function dispatchTool( name: string, diff --git a/src/tools/search-codebase.ts b/src/tools/search-codebase.ts index f2c7526..8fd3237 100644 --- a/src/tools/search-codebase.ts +++ b/src/tools/search-codebase.ts @@ -177,57 +177,25 @@ export async function handle( }); } catch (error) { if (error instanceof IndexCorruptedError) { - console.error('[Auto-Heal] Index corrupted. Triggering full re-index...'); - - await ctx.performIndexing(); - - if (ctx.indexState.status === 'ready') { - console.error('[Auto-Heal] Success. Retrying search...'); - const freshSearcher = new CodebaseSearcher(ctx.rootPath); - try { - results = await freshSearcher.search(queryStr, limit || 5, filters, { - profile: searchProfile - }); - } catch (retryError) { - return { - content: [ + console.error('[Auto-Heal] Index corrupted. Triggering background re-index...'); + void ctx.performIndexing(); + return { + content: [ + { + type: 'text', + text: JSON.stringify( { - type: 'text', - text: JSON.stringify( - { - status: 'error', - message: `Auto-heal retry failed: ${ - retryError instanceof Error ? retryError.message : String(retryError) - }` - }, - null, - 2 - ) - } - ] - }; - } - } else { - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { - status: 'error', - message: `Auto-heal failed: Indexing ended with status '${ctx.indexState.status}'`, - error: ctx.indexState.error - }, - null, - 2 - ) - } - ] - }; - } - } else { - throw error; // Propagate unexpected errors + status: 'indexing', + message: 'Index was corrupt. Rebuild started — retry shortly.' + }, + null, + 2 + ) + } + ] + }; } + throw error; } // Load memories for keyword matching, enriched with confidence diff --git a/tests/index-versioning-migration.test.ts b/tests/index-versioning-migration.test.ts index 89b1867..a541f17 100644 --- a/tests/index-versioning-migration.test.ts +++ b/tests/index-versioning-migration.test.ts @@ -206,17 +206,21 @@ describe('index versioning migration (MIGR-01)', () => { }); afterEach(async () => { + const { clearProjects } = await import('../src/project-state.js'); + clearProjects(); + if (originalArgv) process.argv = originalArgv; if (originalEnvRoot === undefined) delete process.env.CODEBASE_ROOT; else process.env.CODEBASE_ROOT = originalEnvRoot; if (tempRoot) { - await fs.rm(tempRoot, { recursive: true, force: true }); + // Background indexing (fire-and-forget) may still be writing — retry on ENOTEMPTY + await fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); tempRoot = null; } }); - it('refuses legacy indexes without index-meta.json and triggers auto-heal rebuild', async () => { + it('refuses legacy indexes without index-meta.json and triggers background rebuild', async () => { if (!tempRoot) throw new Error('tempRoot not initialized'); const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME); @@ -241,14 +245,14 @@ describe('index versioning migration (MIGR-01)', () => { }); const payload = JSON.parse(response.content[0].text); - expect(payload.status).toBe('success'); + expect(payload.status).toBe('indexing'); + expect(payload.message).toContain('retry'); expect(payload.index).toBeTruthy(); - expect(payload.index.action).toBe('rebuilt-and-served'); + expect(payload.index.action).toBe('rebuild-started'); expect(String(payload.index.reason || '')).toContain('Index meta'); - expect(indexerMocks.index).toHaveBeenCalledTimes(1); }); - it('detects keyword index header mismatch and triggers rebuild (no silent empty results)', async () => { + it('detects keyword index header mismatch and triggers background rebuild', async () => { if (!tempRoot) throw new Error('tempRoot not initialized'); const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME); @@ -302,13 +306,13 @@ describe('index versioning migration (MIGR-01)', () => { }); const payload = JSON.parse(response.content[0].text); - expect(payload.status).toBe('success'); - expect(payload.index.action).toBe('rebuilt-and-served'); + expect(payload.status).toBe('indexing'); + expect(payload.message).toContain('retry'); + expect(payload.index.action).toBe('rebuild-started'); expect(String(payload.index.reason || '')).toContain('Keyword index'); - expect(indexerMocks.index).toHaveBeenCalledTimes(1); }); - it('detects vector DB build marker mismatch and triggers rebuild', async () => { + it('detects vector DB build marker mismatch and triggers background rebuild', async () => { if (!tempRoot) throw new Error('tempRoot not initialized'); const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME); @@ -362,10 +366,10 @@ describe('index versioning migration (MIGR-01)', () => { }); const payload = JSON.parse(response.content[0].text); - expect(payload.status).toBe('success'); - expect(payload.index.action).toBe('rebuilt-and-served'); + expect(payload.status).toBe('indexing'); + expect(payload.message).toContain('retry'); + expect(payload.index.action).toBe('rebuild-started'); expect(String(payload.index.reason || '')).toContain('Vector DB'); - expect(indexerMocks.index).toHaveBeenCalledTimes(1); }); }); @@ -382,9 +386,47 @@ describe('index-consuming allowlist enforcement', () => { tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'index-versioning-allowlist-')); process.env.CODEBASE_ROOT = tempRoot; process.argv[2] = tempRoot; + + const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME); + const buildId = 'allowlist-build'; + const generatedAt = new Date().toISOString(); + + await fs.mkdir(path.join(ctxDir, VECTOR_DB_DIRNAME), { recursive: true }); + await fs.writeFile( + path.join(ctxDir, VECTOR_DB_DIRNAME, 'index-build.json'), + JSON.stringify({ buildId, formatVersion: INDEX_FORMAT_VERSION }), + 'utf-8' + ); + await fs.writeFile( + path.join(ctxDir, KEYWORD_INDEX_FILENAME), + JSON.stringify({ header: { buildId, formatVersion: INDEX_FORMAT_VERSION }, chunks: [] }), + 'utf-8' + ); + await fs.writeFile( + path.join(ctxDir, INDEX_META_FILENAME), + JSON.stringify( + { + metaVersion: INDEX_META_VERSION, + formatVersion: INDEX_FORMAT_VERSION, + buildId, + generatedAt, + toolVersion: 'test', + artifacts: { + keywordIndex: { path: KEYWORD_INDEX_FILENAME }, + vectorDb: { path: VECTOR_DB_DIRNAME, provider: 'lancedb' } + } + }, + null, + 2 + ), + 'utf-8' + ); }); afterEach(async () => { + const { clearProjects } = await import('../src/project-state.js'); + clearProjects(); + if (originalArgv) process.argv = originalArgv; if (originalEnvRoot === undefined) delete process.env.CODEBASE_ROOT; else process.env.CODEBASE_ROOT = originalEnvRoot; diff --git a/tests/multi-project-routing.test.ts b/tests/multi-project-routing.test.ts new file mode 100644 index 0000000..6ecc8d2 --- /dev/null +++ b/tests/multi-project-routing.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { + CODEBASE_CONTEXT_DIRNAME, + INDEX_FORMAT_VERSION, + INDEX_META_FILENAME, + INDEX_META_VERSION, + KEYWORD_INDEX_FILENAME, + VECTOR_DB_DIRNAME +} from '../src/constants/codebase-context.js'; +import { CONTEXT_RESOURCE_URI } from '../src/resources/uri.js'; + +interface SearchResultRow { + summary: string; + snippet: string; + filePath: string; + startLine: number; + endLine: number; + score: number; + language: string; + metadata: Record; +} + +interface ToolCallResponse { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} + +interface ResourceReadResponse { + contents: Array<{ uri: string; mimeType?: string; text?: string }>; +} + +interface TestServer { + _requestHandlers: Map< + string, + (request: unknown) => Promise + >; +} + +const searchMocks = vi.hoisted(() => ({ + search: vi.fn() +})); + +const watcherMocks = vi.hoisted(() => ({ + start: vi.fn() +})); + +vi.mock('../src/core/search.js', async () => { + class CodebaseSearcher { + constructor(private readonly rootPath: string) {} + + async search(query: string, limit: number, filters?: unknown, options?: unknown) { + return searchMocks.search(this.rootPath, query, limit, filters, options); + } + } + + return { CodebaseSearcher }; +}); + +vi.mock('../src/core/indexer.js', () => { + class CodebaseIndexer { + constructor(_options: unknown) {} + + getProgress() { + return { phase: 'complete', percentage: 100 }; + } + + async index() { + return { + totalFiles: 0, + indexedFiles: 0, + skippedFiles: 0, + totalChunks: 0, + totalLines: 0, + duration: 0, + avgChunkSize: 0, + componentsByType: {}, + componentsByLayer: { + presentation: 0, + business: 0, + data: 0, + state: 0, + core: 0, + shared: 0, + feature: 0, + infrastructure: 0, + unknown: 0 + }, + errors: [], + startedAt: new Date(), + completedAt: new Date() + }; + } + } + + return { CodebaseIndexer }; +}); + +vi.mock('../src/core/file-watcher.js', () => ({ + startFileWatcher: watcherMocks.start +})); + +async function seedValidIndex(rootPath: string): Promise { + const ctxDir = path.join(rootPath, CODEBASE_CONTEXT_DIRNAME); + await fs.mkdir(path.join(ctxDir, VECTOR_DB_DIRNAME), { recursive: true }); + + const buildId = `build-${path.basename(rootPath)}`; + const generatedAt = new Date().toISOString(); + + await fs.writeFile( + path.join(ctxDir, VECTOR_DB_DIRNAME, 'index-build.json'), + JSON.stringify({ buildId, formatVersion: INDEX_FORMAT_VERSION }), + 'utf-8' + ); + await fs.writeFile( + path.join(ctxDir, KEYWORD_INDEX_FILENAME), + JSON.stringify({ header: { buildId, formatVersion: INDEX_FORMAT_VERSION }, chunks: [] }), + 'utf-8' + ); + await fs.writeFile( + path.join(ctxDir, INDEX_META_FILENAME), + JSON.stringify( + { + metaVersion: INDEX_META_VERSION, + formatVersion: INDEX_FORMAT_VERSION, + buildId, + generatedAt, + toolVersion: 'test', + artifacts: { + keywordIndex: { path: KEYWORD_INDEX_FILENAME }, + vectorDb: { path: VECTOR_DB_DIRNAME, provider: 'lancedb' } + } + }, + null, + 2 + ), + 'utf-8' + ); +} + +describe('multi-project routing', () => { + let primaryRoot: string; + let secondaryRoot: string; + let originalArgv: string[] | null = null; + let originalEnvRoot: string | undefined; + + beforeEach(async () => { + vi.resetModules(); + searchMocks.search.mockReset(); + watcherMocks.start.mockReset(); + + originalArgv = [...process.argv]; + originalEnvRoot = process.env.CODEBASE_ROOT; + + primaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-primary-root-')); + secondaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-secondary-root-')); + process.env.CODEBASE_ROOT = primaryRoot; + process.argv[2] = primaryRoot; + + await seedValidIndex(primaryRoot); + await seedValidIndex(secondaryRoot); + + watcherMocks.start.mockImplementation( + ({ rootPath }: { rootPath: string }) => + () => + `stopped:${rootPath}` + ); + + searchMocks.search.mockImplementation( + async (rootPath: string): Promise => [ + { + summary: `Result for ${path.basename(rootPath)}`, + snippet: 'snippet', + filePath: path.join(rootPath, 'src', 'feature.ts'), + startLine: 1, + endLine: 2, + score: 0.9, + language: 'ts', + metadata: {} + } + ] + ); + }); + + afterEach(async () => { + const { clearProjects } = await import('../src/project-state.js'); + clearProjects(); + + if (originalArgv) process.argv = originalArgv; + if (originalEnvRoot === undefined) { + delete process.env.CODEBASE_ROOT; + } else { + process.env.CODEBASE_ROOT = originalEnvRoot; + } + + await fs.rm(primaryRoot, { recursive: true, force: true }); + await fs.rm(secondaryRoot, { recursive: true, force: true }); + }); + + it('routes a tool call to the requested project_directory', async () => { + const { server } = await import('../src/index.js'); + const handler = (server as unknown as TestServer)._requestHandlers.get('tools/call'); + + if (!handler) { + throw new Error('tools/call handler not registered'); + } + + const response = (await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { query: 'feature', project_directory: secondaryRoot } + } + })) as ToolCallResponse; + + expect(searchMocks.search).toHaveBeenCalledTimes(1); + expect(searchMocks.search.mock.calls[0]?.[0]).toBe(secondaryRoot); + expect(watcherMocks.start).not.toHaveBeenCalled(); + + const payload = JSON.parse(response.content[0].text) as { + status: string; + results: Array<{ file: string }>; + }; + + expect(payload.status).toBe('success'); + expect(payload.results[0]?.file).toContain('feature.ts'); + }); + + it('keeps ad-hoc project_directory requests scoped to the current call', async () => { + const { server } = await import('../src/index.js'); + const handler = (server as unknown as TestServer)._requestHandlers.get('tools/call'); + + if (!handler) { + throw new Error('tools/call handler not registered'); + } + + await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { query: 'feature', project_directory: secondaryRoot } + } + }); + + const response = (await handler({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { query: 'feature' } + } + })) as ToolCallResponse; + + expect(response.isError).not.toBe(true); + const payload = JSON.parse(response.content[0].text) as { + status: string; + results: Array<{ file: string }>; + }; + + expect(payload.status).toBe('success'); + expect(searchMocks.search).toHaveBeenCalledTimes(2); + expect(searchMocks.search.mock.calls[1]?.[0]).toBe(primaryRoot); + expect(payload.results[0]?.file).toContain('feature.ts'); + }); + + it('rejects unknown project_directory values before initialization starts', async () => { + const { server } = await import('../src/index.js'); + const handler = (server as unknown as TestServer)._requestHandlers.get('tools/call'); + + if (!handler) { + throw new Error('tools/call handler not registered'); + } + + const missingRoot = path.join(primaryRoot, 'does-not-exist'); + const response = (await handler({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { query: 'feature', project_directory: missingRoot } + } + })) as ToolCallResponse; + + expect(response.isError).toBe(true); + expect(searchMocks.search).not.toHaveBeenCalled(); + expect(watcherMocks.start).not.toHaveBeenCalled(); + + const payload = JSON.parse(response.content[0].text) as { + status: string; + errorCode: string; + message: string; + }; + + expect(payload.status).toBe('error'); + expect(payload.errorCode).toBe('unknown_project'); + expect(payload.message).toContain('does not exist'); + }); + + it('serializes concurrent initialization for the same known root', async () => { + const { server } = await import('../src/index.js'); + const handler = (server as unknown as TestServer)._requestHandlers.get('tools/call'); + + if (!handler) { + throw new Error('tools/call handler not registered'); + } + + const makeRequest = (id: number) => + handler({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { + name: 'get_indexing_status', + arguments: {} + } + }); + + await Promise.all([makeRequest(4), makeRequest(5)]); + + expect(watcherMocks.start).toHaveBeenCalledTimes(1); + expect(watcherMocks.start).toHaveBeenCalledWith( + expect.objectContaining({ rootPath: primaryRoot }) + ); + }); + + it('keeps resource reads pinned to known roots after ad-hoc project selection', async () => { + const { server } = await import('../src/index.js'); + const requestHandler = (server as unknown as TestServer)._requestHandlers.get('tools/call'); + const resourceHandler = (server as unknown as TestServer)._requestHandlers.get( + 'resources/read' + ); + + if (!requestHandler || !resourceHandler) { + throw new Error('required handlers not registered'); + } + + await requestHandler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { query: 'feature', project_directory: secondaryRoot } + } + }); + + const response = (await resourceHandler({ + jsonrpc: '2.0', + id: 3, + method: 'resources/read', + params: { uri: CONTEXT_RESOURCE_URI } + })) as ResourceReadResponse; + + expect(response.contents[0]?.uri).toBe(CONTEXT_RESOURCE_URI); + expect(response.contents[0]?.text).toContain('# Codebase Intelligence'); + expect(response.contents[0]?.text).not.toContain('Multiple project roots are available'); + }); +}); diff --git a/tests/project-state.test.ts b/tests/project-state.test.ts new file mode 100644 index 0000000..92820e5 --- /dev/null +++ b/tests/project-state.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import path from 'path'; +import { + makePaths, + normalizeRootKey, + getProject, + getOrCreateProject, + removeProject, + clearProjects +} from '../src/project-state.js'; +import { CODEBASE_CONTEXT_DIRNAME } from '../src/constants/codebase-context.js'; + +describe('project-state', () => { + afterEach(() => { + clearProjects(); + vi.restoreAllMocks(); + }); + + describe('makePaths', () => { + it('produces correct paths from rootPath', () => { + const rootPath = '/projects/my-app'; + const paths = makePaths(rootPath); + + expect(paths.baseDir).toBe(path.join(rootPath, CODEBASE_CONTEXT_DIRNAME)); + expect(paths.memory).toContain('memory.json'); + expect(paths.intelligence).toContain('intelligence.json'); + expect(paths.keywordIndex).toContain('index.json'); + expect(paths.vectorDb).toContain('index'); + }); + }); + + describe('normalizeRootKey', () => { + it('strips trailing separators', () => { + const key1 = normalizeRootKey('/projects/my-app/'); + const key2 = normalizeRootKey('/projects/my-app'); + expect(key1).toBe(key2); + }); + + it('resolves relative paths', () => { + const key = normalizeRootKey('./foo'); + expect(path.isAbsolute(key)).toBe(true); + }); + + it('lowercases on Windows', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + try { + const key = normalizeRootKey('/Projects/MyApp'); + expect(key).toBe(key.toLowerCase()); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + } + }); + }); + + describe('getOrCreateProject', () => { + it('returns same instance for same path', () => { + const p1 = getOrCreateProject('/projects/my-app'); + const p2 = getOrCreateProject('/projects/my-app'); + expect(p1).toBe(p2); + }); + + it('returns same instance for path with trailing separator', () => { + const p1 = getOrCreateProject('/projects/my-app'); + const p2 = getOrCreateProject('/projects/my-app/'); + expect(p1).toBe(p2); + }); + + it('returns different instances for different paths', () => { + const p1 = getOrCreateProject('/projects/app-a'); + const p2 = getOrCreateProject('/projects/app-b'); + expect(p1).not.toBe(p2); + }); + + it('creates project with idle status', () => { + const p = getOrCreateProject('/projects/my-app'); + expect(p.indexState.status).toBe('idle'); + }); + + it('getProject returns undefined before creation and instance after creation', () => { + expect(getProject('/projects/my-app')).toBeUndefined(); + const created = getOrCreateProject('/projects/my-app'); + expect(getProject('/projects/my-app')).toBe(created); + }); + }); + + describe('clearProjects', () => { + it('empties the map', () => { + getOrCreateProject('/projects/app-a'); + getOrCreateProject('/projects/app-b'); + clearProjects(); + + // After clearing, getOrCreateProject should return a fresh instance + const p = getOrCreateProject('/projects/app-a'); + expect(p.indexState.status).toBe('idle'); + }); + + it('calls stopWatcher on projects with watchers', () => { + const p = getOrCreateProject('/projects/my-app'); + const stopSpy = vi.fn(); + p.stopWatcher = stopSpy; + + clearProjects(); + expect(stopSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('removeProject', () => { + it('removes a project and stops its watcher', () => { + const project = getOrCreateProject('/projects/my-app'); + const stopSpy = vi.fn(); + project.stopWatcher = stopSpy; + + removeProject('/projects/my-app'); + + expect(stopSpy).toHaveBeenCalledTimes(1); + expect(getProject('/projects/my-app')).toBeUndefined(); + }); + }); +}); diff --git a/tests/search-codebase-auto-heal.test.ts b/tests/search-codebase-auto-heal.test.ts index 848e36c..13cd1a0 100644 --- a/tests/search-codebase-auto-heal.test.ts +++ b/tests/search-codebase-auto-heal.test.ts @@ -143,25 +143,12 @@ describe('search_codebase auto-heal', () => { } }); - it('triggers indexing and retries when IndexCorruptedError is thrown', async () => { + it('fires background re-index and returns retry message when IndexCorruptedError is thrown', async () => { const { IndexCorruptedError } = await import('../src/errors/index.js'); - searchMocks.search - .mockRejectedValueOnce( - new IndexCorruptedError('LanceDB index corrupted: missing vector column') - ) - .mockResolvedValueOnce([ - { - summary: 'Test summary', - snippet: 'Test snippet', - filePath: '/tmp/file.ts', - startLine: 1, - endLine: 2, - score: 0.9, - language: 'ts', - metadata: {} - } - ]); + searchMocks.search.mockRejectedValueOnce( + new IndexCorruptedError('LanceDB index corrupted: missing vector column') + ); const { server } = await import('../src/index.js'); const handler = (server as any)._requestHandlers.get('tools/call'); @@ -179,10 +166,9 @@ describe('search_codebase auto-heal', () => { }); const payload = JSON.parse(response.content[0].text); - expect(payload.status).toBe('success'); - expect(payload.results).toHaveLength(1); - expect(searchMocks.search).toHaveBeenCalledTimes(2); - expect(indexerMocks.index).toHaveBeenCalledTimes(1); + expect(payload.status).toBe('indexing'); + expect(payload.message).toContain('retry'); + expect(searchMocks.search).toHaveBeenCalledTimes(1); }, 15000); it('returns invalid_params when search_codebase query is missing', async () => { diff --git a/tests/tools/dispatch.test.ts b/tests/tools/dispatch.test.ts index 536f6e5..e16574a 100644 --- a/tests/tools/dispatch.test.ts +++ b/tests/tools/dispatch.test.ts @@ -38,6 +38,17 @@ describe('Tool Dispatch', () => { }); }); + it('all tools expose project_directory for multi-root routing', () => { + TOOLS.forEach((tool) => { + expect(tool.inputSchema.type).toBe('object'); + expect(tool.inputSchema.properties).toMatchObject({ + project_directory: expect.objectContaining({ + type: 'string' + }) + }); + }); + }); + it('dispatchTool returns error for unknown tool', async () => { const mockCtx: ToolContext = { indexState: { status: 'idle' },