From 75d7a86188b28a91405ef083d45572d46d79ee92 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 7 Mar 2026 14:45:00 +0100 Subject: [PATCH 1/6] fix: route MCP requests per project root --- src/index.ts | 527 +++++++++++++++-------- src/project-state.ts | 93 ++++ src/tools/index.ts | 33 +- src/tools/search-codebase.ts | 66 +-- tests/index-versioning-migration.test.ts | 68 ++- tests/multi-project-routing.test.ts | 299 +++++++++++++ tests/project-state.test.ts | 120 ++++++ tests/search-codebase-auto-heal.test.ts | 28 +- tests/tools/dispatch.test.ts | 11 + 9 files changed, 991 insertions(+), 254 deletions(-) create mode 100644 src/project-state.ts create mode 100644 tests/multi-project-routing.test.ts create mode 100644 tests/project-state.test.ts diff --git a/src/index.ts b/src/index.ts index dc43f01..cd5c570 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', @@ -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}` }; } @@ -188,16 +246,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 +267,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 +278,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 +297,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 +337,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' + @@ -460,7 +509,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 +533,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 +543,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 +561,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 +594,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 +609,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 +619,23 @@ 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 +645,106 @@ 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 ?? registerKnownRoot(requestedProjectDirectory); + const project = getOrCreateProject(rootPath); + await initProject(project.rootPath, watcherDebounceMs); + 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); + 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); + 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 +757,40 @@ 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' || + indexSignal.action === 'rebuild-failed' + ) { 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 +819,93 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } }); +/** + * Initialize a project: migrate legacy structure, check index, start watcher. + * Deduplicates via normalized root key. + */ +async function initProject(rootPath: string, debounceMs: number): Promise { + const project = getOrCreateProject(rootPath); + + // Skip if already initialized + if ( + project.indexState.status === 'indexing' || + project.indexState.status === 'ready' || + project.stopWatcher + ) { + return; + } + + // 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(); + } + + // Start file watcher + 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 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 +917,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); + } + + // 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..a3f7cc6 --- /dev/null +++ b/src/project-state.ts @@ -0,0 +1,93 @@ +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; + 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..7a16434 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -15,7 +15,38 @@ 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 when multiple roots are available.' +}; + +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..6c77800 --- /dev/null +++ b/tests/multi-project-routing.test.ts @@ -0,0 +1,299 @@ +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 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).toHaveBeenCalledWith( + expect.objectContaining({ rootPath: secondaryRoot }) + ); + + 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('returns an ambiguity error after multiple projects are known', 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).toBe(true); + const payload = JSON.parse(response.content[0].text) as { + status: string; + errorCode: string; + availableRoots: string[]; + }; + + expect(payload.status).toBe('error'); + expect(payload.errorCode).toBe('ambiguous_project'); + expect(payload.availableRoots).toEqual(expect.arrayContaining([primaryRoot, secondaryRoot])); + }); + + it('refuses ambiguous resource reads instead of serving the primary project', 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('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' }, From 2189ba014156aff80453f3ccaf397374287f73dd Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 7 Mar 2026 14:47:38 +0100 Subject: [PATCH 2/6] style: format multi-project routing files --- src/index.ts | 10 ++-------- src/tools/index.ts | 4 +--- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index cd5c570..9fa478a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -625,10 +625,7 @@ async function performIndexingOnce( } } -async function performIndexing( - project: ProjectState, - incrementalOnly?: boolean -): Promise { +async function performIndexing(project: ProjectState, incrementalOnly?: boolean): Promise { let nextMode = incrementalOnly; for (;;) { await performIndexingOnce(project, nextMode); @@ -771,10 +768,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } indexSignal = await ensureValidIndexOrAutoHeal(project); - if ( - indexSignal.action === 'rebuild-started' || - indexSignal.action === 'rebuild-failed' - ) { + if (indexSignal.action === 'rebuild-started' || indexSignal.action === 'rebuild-failed') { return { content: [ { diff --git a/src/tools/index.ts b/src/tools/index.ts index 7a16434..aa3c38a 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -44,9 +44,7 @@ function withProjectDirectory(definition: Tool): Tool { }; } -export const TOOLS: Tool[] = [d1, d2, d3, d4, d5, d6, d7, d8, d9, d10].map( - withProjectDirectory -); +export const TOOLS: Tool[] = [d1, d2, d3, d4, d5, d6, d7, d8, d9, d10].map(withProjectDirectory); export async function dispatchTool( name: string, From d4ae7a58a3bd5f6fc708cd28fe2a4044f7597d0e Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 7 Mar 2026 16:34:36 +0100 Subject: [PATCH 3/6] fix: harden multi-project routing --- package.json | 1 + pnpm-lock.yaml | 17 ++-- src/index.ts | 132 ++++++++++++++++++---------- src/project-state.ts | 1 + src/tools/index.ts | 2 +- tests/multi-project-routing.test.ts | 91 ++++++++++++++++--- 6 files changed, 179 insertions(+), 65 deletions(-) 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 9fa478a..0ffea2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -173,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; @@ -230,6 +230,25 @@ async function ensureValidIndexOrAutoHeal(project: ProjectState): 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 */ @@ -349,14 +368,6 @@ async function generateCodebaseContext(project: ProjectState): 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'); @@ -685,9 +696,16 @@ async function resolveProjectForTool(args: Record): Promise): Promise { const [rootPath] = availableRoots; const project = getOrCreateProject(rootPath); - await initProject(project.rootPath, watcherDebounceMs); + await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true }); return project; } @@ -768,7 +786,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } indexSignal = await ensureValidIndexOrAutoHeal(project); - if (indexSignal.action === 'rebuild-started' || indexSignal.action === 'rebuild-failed') { + if (indexSignal.action === 'rebuild-started') { return { content: [ { @@ -817,45 +835,58 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { * Initialize a project: migrate legacy structure, check index, start watcher. * Deduplicates via normalized root key. */ -async function initProject(rootPath: string, debounceMs: number): Promise { - const project = getOrCreateProject(rootPath); +type InitProjectOptions = { + enableWatcher: boolean; +}; - // Skip if already initialized - if ( - project.indexState.status === 'indexing' || - project.indexState.status === 'ready' || - project.stopWatcher - ) { +async function ensureProjectInitialized(project: ProjectState): Promise { + if (project.initPromise) { + await project.initPromise; return; } - // 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 + if (project.indexState.status !== 'idle') { + return; } - // 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}`); + 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 } - void performIndexing(project); - } else { - if (process.env.CODEBASE_CONTEXT_DEBUG) { - console.error(`[DEBUG] Index found. Ready: ${project.rootPath}`); + + // 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(); } - 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; } - // Start file watcher project.stopWatcher = startFileWatcher({ rootPath: project.rootPath, debounceMs, @@ -881,6 +912,19 @@ async function initProject(rootPath: string, debounceMs: number): Promise }); } +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 { @@ -928,7 +972,7 @@ async function main() { // Preserve current single-project startup behavior without eagerly indexing every root. const startupRoots = getKnownRootPaths(); if (startupRoots.length === 1) { - await initProject(startupRoots[0], watcherDebounceMs); + await initProject(startupRoots[0], watcherDebounceMs, { enableWatcher: true }); } // Subscribe to root changes diff --git a/src/project-state.ts b/src/project-state.ts index a3f7cc6..6b260dc 100644 --- a/src/project-state.ts +++ b/src/project-state.ts @@ -15,6 +15,7 @@ export interface ProjectState { paths: ToolPaths; indexState: IndexState; autoRefresh: AutoRefreshController; + initPromise?: Promise; stopWatcher?: () => void; } diff --git a/src/tools/index.ts b/src/tools/index.ts index aa3c38a..ac49114 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -18,7 +18,7 @@ import type { ToolContext, ToolResponse } from './types.js'; const PROJECT_DIRECTORY_PROPERTY: Record = { type: 'string', description: - 'Optional absolute path or file:// URI for the project root to use when multiple roots are available.' + '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 { diff --git a/tests/multi-project-routing.test.ts b/tests/multi-project-routing.test.ts index 6c77800..6ecc8d2 100644 --- a/tests/multi-project-routing.test.ts +++ b/tests/multi-project-routing.test.ts @@ -33,7 +33,10 @@ interface ResourceReadResponse { } interface TestServer { - _requestHandlers: Map Promise>; + _requestHandlers: Map< + string, + (request: unknown) => Promise + >; } const searchMocks = vi.hoisted(() => ({ @@ -160,7 +163,9 @@ describe('multi-project routing', () => { await seedValidIndex(secondaryRoot); watcherMocks.start.mockImplementation( - ({ rootPath }: { rootPath: string }) => () => `stopped:${rootPath}` + ({ rootPath }: { rootPath: string }) => + () => + `stopped:${rootPath}` ); searchMocks.search.mockImplementation( @@ -214,9 +219,7 @@ describe('multi-project routing', () => { expect(searchMocks.search).toHaveBeenCalledTimes(1); expect(searchMocks.search.mock.calls[0]?.[0]).toBe(secondaryRoot); - expect(watcherMocks.start).toHaveBeenCalledWith( - expect.objectContaining({ rootPath: secondaryRoot }) - ); + expect(watcherMocks.start).not.toHaveBeenCalled(); const payload = JSON.parse(response.content[0].text) as { status: string; @@ -227,7 +230,7 @@ describe('multi-project routing', () => { expect(payload.results[0]?.file).toContain('feature.ts'); }); - it('returns an ambiguity error after multiple projects are known', async () => { + 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'); @@ -255,22 +258,85 @@ describe('multi-project routing', () => { } })) 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; - availableRoots: string[]; + message: string; }; expect(payload.status).toBe('error'); - expect(payload.errorCode).toBe('ambiguous_project'); - expect(payload.availableRoots).toEqual(expect.arrayContaining([primaryRoot, secondaryRoot])); + expect(payload.errorCode).toBe('unknown_project'); + expect(payload.message).toContain('does not exist'); }); - it('refuses ambiguous resource reads instead of serving the primary project', async () => { + 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'); + const resourceHandler = (server as unknown as TestServer)._requestHandlers.get( + 'resources/read' + ); if (!requestHandler || !resourceHandler) { throw new Error('required handlers not registered'); @@ -294,6 +360,7 @@ describe('multi-project routing', () => { })) as ResourceReadResponse; expect(response.contents[0]?.uri).toBe(CONTEXT_RESOURCE_URI); - expect(response.contents[0]?.text).toContain('Multiple project roots are available'); + expect(response.contents[0]?.text).toContain('# Codebase Intelligence'); + expect(response.contents[0]?.text).not.toContain('Multiple project roots are available'); }); }); From 388656c50986cdaa93902644f207a5150e13af30 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sun, 8 Mar 2026 19:22:21 +0100 Subject: [PATCH 4/6] feat: add workspace-aware multi-project routing --- CHANGELOG.md | 18 + README.md | 196 ++++++- docs/capabilities.md | 44 +- docs/cli.md | 4 +- src/cli.ts | 17 +- src/index.ts | 871 ++++++++++++++++++++++++---- src/resources/uri.ts | 22 +- src/tools/index.ts | 18 +- src/tools/types.ts | 13 + src/utils/project-discovery.ts | 255 ++++++++ tests/cli.test.ts | 1 + tests/multi-project-routing.test.ts | 439 ++++++++++---- tests/project-discovery.test.ts | 114 ++++ tests/resource-uri.test.ts | 11 + tests/tools/dispatch.test.ts | 5 +- 15 files changed, 1769 insertions(+), 259 deletions(-) create mode 100644 src/utils/project-discovery.ts create mode 100644 tests/project-discovery.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a257a08..cf9ecc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased + +### Features + +- **mcp:** rework multi-project routing so one MCP server can serve multiple projects instead of one hardcoded server entry per repo +- **mcp:** keep explicit `project` as the fallback when the client does not provide enough project context +- **mcp:** accept repo paths, subproject paths, and file paths as `project` selectors when routing is ambiguous + +### Documentation + +- simplify the setup story around three cases: default rootless setup, single-project fallback, and explicit `project` retries +- clarify that issue #63 fixed the architecture and workspace-aware workflow, but issue #2 is not fully solved when the client does not provide enough project context +- remove the repo-local `init` / marker-file story from the public setup guidance + ## [1.8.2](https://github.com/PatrickSys/codebase-context/compare/v1.8.1...v1.8.2) (2026-03-05) @@ -85,10 +99,13 @@ ### Bug Fixes - restore `npx` / `npm exec` installs by removing the published pnpm-only `preinstall` guard +- harden multi-project MCP routing so configured roots are pre-warmed in the background, `codebase://context` falls back to a workspace overview before selection, and ambiguous sessions now recover through an explicit path-based `project` selector instead of an opaque session ref ### Added - **Definition-first ranking**: Exact-name searches now show the file that _defines_ a symbol before files that use it. For example, searching `parseConfig` shows the function definition first, then callers. +- **Path-based multi-project routing**: multi-project and monorepo sessions can route explicitly with `project` using an absolute repo path, `file://` URI, or a relative subproject path such as `apps/dashboard`. +- **Project-scoped context resources**: `codebase://context/project/` serves proactive context for a specific configured project and also makes later tool calls deterministic. ### Refactored @@ -118,6 +135,7 @@ - **2-hop transitive impact** (PR #50): `search --intent edit` impact now shows direct importers (hop 1) and their importers (hop 2), each labeled with distance. Capped at 20. - **Chokidar file watcher** (PR #52): index auto-refreshes in MCP server mode on file save (2 s debounce). No manual `reindex` needed during active editing sessions. - **CLI human formatters** (PR #48): all 9 commands now render as structured human-readable output. `--json` flag on every command for agent/pipe consumption. +- **Multi-project MCP routing**: automatic routing still handles the single-project and already-active-project cases, while ambiguous multi-project sessions now require an explicit path-based `project` selector instead of forcing a selector-first flow. - **`status` + `reindex` formatters** (PR #56): status box with index health, progress, and last-built time. ASCII fallback via `CODEBASE_CONTEXT_ASCII=1`. - **`docs/cli.md` gallery** (PR #56): command reference with output previews for all 9 CLI commands. diff --git a/README.md b/README.md index 3c35fb1..b334b89 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,48 @@ More CLI examples: - `metadata` for a quick codebase overview - Full gallery in `docs/cli.md` +## Table of Contents + +- [Quick Start](#quick-start) +- [Multi-Project and Monorepos](#multi-project-and-monorepos) +- [Test It Yourself](#test-it-yourself) +- [Common First Commands](#common-first-commands) +- [What It Actually Does](#what-it-actually-does) +- [Evaluation Harness (`npm run eval`)](#evaluation-harness-npm-run-eval) +- [How the Search Works](#how-the-search-works) +- [Language Support](#language-support) +- [Configuration](#configuration) +- [Performance](#performance) +- [File Structure](#file-structure) +- [CLI Reference](#cli-reference) +- [What to add to your CLAUDE.md / AGENTS.md](#what-to-add-to-your-claudemd--agentsmd) +- [Links](#links) +- [License](#license) + ## Quick Start +Start with the default setup: + +- Configure one `codebase-context` server with no project path. +- If a tool call later returns `selection_required`, retry with `project`. +- If you only use one repo, you can also append that repo path up front. + +### Pick the right setup + +| Situation | Recommended config | +| --- | --- | +| Default setup | Run `npx -y codebase-context` with no project path | +| Single repo setup | Append one project path or set `CODEBASE_ROOT` | +| Multi-project call is still ambiguous | Retry with `project`, or keep separate server entries if your client cannot preserve project context | + +### Recommended setup + Add it to the configuration of your AI Agent of preference: ### Claude Code ```bash -claude mcp add codebase-context -- npx -y codebase-context /path/to/your/project +claude mcp add codebase-context -- npx -y codebase-context ``` ### Claude Desktop @@ -63,7 +97,7 @@ Add to `claude_desktop_config.json`: "mcpServers": { "codebase-context": { "command": "npx", - "args": ["-y", "codebase-context", "/path/to/your/project"] + "args": ["-y", "codebase-context"] } } } @@ -78,7 +112,7 @@ Add `.vscode/mcp.json` to your project root: "servers": { "codebase-context": { "command": "npx", - "args": ["-y", "codebase-context", "/path/to/your/project"] // Or "${workspaceFolder}" if your workspace is one project only + "args": ["-y", "codebase-context"] // Or append "${workspaceFolder}" for single-project use } } } @@ -93,7 +127,7 @@ Add to `.cursor/mcp.json` in your project: "mcpServers": { "codebase-context": { "command": "npx", - "args": ["-y", "codebase-context", "/path/to/your/project"] + "args": ["-y", "codebase-context"] } } } @@ -108,7 +142,7 @@ Open Settings > MCP and add: "mcpServers": { "codebase-context": { "command": "npx", - "args": ["-y", "codebase-context", "/path/to/your/project"] + "args": ["-y", "codebase-context"] } } } @@ -124,7 +158,7 @@ Add `opencode.json` to your project root: "mcp": { "codebase-context": { "type": "local", - "command": ["npx", "-y", "codebase-context", "/path/to/your/project"], + "command": ["npx", "-y", "codebase-context"], "enabled": true } } @@ -135,11 +169,145 @@ OpenCode also supports interactive setup via `opencode mcp add`. ### Codex +```bash +codex mcp add codebase-context npx -y codebase-context +``` + +That single config entry is the intended starting point. + +### Fallback setup for single-project use + +If you only use one repo, append a project path: + ```bash codex mcp add codebase-context npx -y codebase-context "/path/to/your/project" ``` -## New to this codebase? +Or set: + +```bash +CODEBASE_ROOT=/path/to/your/project +``` + +## Multi-Project and Monorepos + +The MCP server can serve multiple projects in one session without requiring one MCP config entry per repo. + +Three cases matter: + +| Case | What happens | +| --- | --- | +| One project | Routing is automatic | +| Multiple projects and the client provides enough workspace context | The server can route across those projects in one MCP session | +| Multiple projects and the target is still ambiguous | The server does not guess. Use `project` explicitly | + +Important rules: + +- `project` is the explicit override when routing is ambiguous. +- `project` accepts a project root path, file path, `file://` URI, or a relative subproject path under a configured workspace such as `apps/dashboard`. +- If a client reads `codebase://context` before any project is active, the server returns a workspace overview instead of guessing. +- The server does not rely on `cwd` walk-up in MCP mode. + +Typical explicit retry in a monorepo: + +```json +{ + "name": "search_codebase", + "arguments": { + "query": "auth interceptor", + "project": "apps/dashboard" + } +} +``` + +Or target a repo directly: + +```json +{ + "name": "search_codebase", + "arguments": { + "query": "auth interceptor", + "project": "/repos/customer-portal" + } +} +``` + +Or pass a file path and let the server resolve the nearest trusted project boundary: + +```json +{ + "name": "search_codebase", + "arguments": { + "query": "auth interceptor", + "project": "/repos/monorepo/apps/dashboard/src/auth/guard.ts" + } +} +``` + +If you see `selection_required`, the server could not tell which project you meant. Retry the call with `project`. + +`codebase://context` follows the active project in the session. In unresolved multi-project sessions it returns a workspace overview. Project-scoped resources are also available via the URIs listed in that overview. + +The CLI stays intentionally simpler: it targets one repo per invocation via `CODEBASE_ROOT` or the current working directory. Multi-project discovery and routing are MCP-only features, not a second CLI session model. + +## Test It Yourself + +Build the local branch first: + +```bash +pnpm build +``` + +Then point your MCP client at the local build: + +```json +{ + "mcpServers": { + "codebase-context": { + "command": "node", + "args": ["C:/Users/bitaz/Repos/codebase-context/dist/index.js"] + } + } +} +``` + +If the default setup is not enough for your client, use this instead: + +```json +{ + "mcpServers": { + "codebase-context": { + "command": "node", + "args": ["C:/Users/bitaz/Repos/codebase-context/dist/index.js", "C:/path/to/your/project"] + } + } +} +``` + +Check these three flows: + +1. Single project + Ask for `search_codebase` or `metadata`. + Expected: routing is automatic. + +2. Multiple projects with one server entry + Open two repos or one monorepo workspace. + Ask for `codebase://context`. + Expected: workspace overview first, then automatic routing once one project is active or unambiguous. + +3. Ambiguous project selection + Start without a bootstrap path. + Ask for `search_codebase`. + Expected: `selection_required`. + Retry with `project`, for example `apps/dashboard` or `/repos/customer-portal`. + +For monorepos, verify all three selector forms: + +- relative subproject path: `apps/dashboard` +- repo path: `/repos/customer-portal` +- file path: `/repos/monorepo/apps/dashboard/src/auth/guard.ts` + +## Common First Commands Three commands to get what usually takes a new developer weeks to piece together: @@ -342,12 +510,12 @@ Structured filters available: `framework`, `language`, `componentType`, `layer` ## Configuration -| Variable | Default | Description | -| ------------------------ | -------------------------- | --------------------------------------------------------------------------------------------- | -| `EMBEDDING_PROVIDER` | `transformers` | `openai` (fast, cloud) or `transformers` (local, private) | -| `OPENAI_API_KEY` | - | Required only if using `openai` provider | -| `CODEBASE_ROOT` | - | Project root (CLI arg takes precedence) | -| `CODEBASE_CONTEXT_DEBUG` | - | Set to `1` for verbose logging | +| Variable | Default | Description | +| ------------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `EMBEDDING_PROVIDER` | `transformers` | `openai` (fast, cloud) or `transformers` (local, private) | +| `OPENAI_API_KEY` | - | Required only if using `openai` provider | +| `CODEBASE_ROOT` | - | Optional bootstrap root for CLI and single-project MCP clients without roots | +| `CODEBASE_CONTEXT_DEBUG` | - | Set to `1` for verbose logging | | `EMBEDDING_MODEL` | `Xenova/bge-small-en-v1.5` | Local embedding model override (e.g. `onnx-community/granite-embedding-small-english-r2-ONNX` for Granite) | ## Performance @@ -378,7 +546,7 @@ Structured filters available: `framework`, `language`, `componentType`, `layer` ## CLI Reference -All MCP tools are available as CLI commands — no AI agent required. Useful for onboarding, scripting, debugging, and CI workflows. +Repo-scoped analysis commands are available via the CLI — no AI agent required. MCP multi-project routing uses the shared `project` selector when needed; the CLI stays one-root-per-invocation. For formatted examples and “money shots”, see `docs/cli.md`. Set `CODEBASE_ROOT` to your project root, or run from the project directory. diff --git a/docs/capabilities.md b/docs/capabilities.md index 30ed9c3..bc0828b 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -4,7 +4,8 @@ Technical reference for what `codebase-context` ships today. For the user-facing ## CLI Reference -All shipped capabilities are available locally via the CLI (human-readable by default, `--json` for automation). +Repo-scoped capabilities are available locally via the CLI (human-readable by default, `--json` for automation). +Multi-project selection is MCP-only because the CLI already targets one root per invocation. For a “gallery” of commands and examples, see `docs/cli.md`. | Command | Flags | Maps to | @@ -34,17 +35,24 @@ npx codebase-context reindex --incremental ## Tool Surface -10 MCP tools + 1 optional resource (`codebase://context`). **Migration:** `get_component_usage` was removed; use `get_symbol_references` for symbol usage evidence. +10 MCP tools + active/project-scoped context resources. + +Shared selector inputs: + +- `project` (preferred): project root path, file path, `file://` URI, or relative subproject path under a configured root +- `project_directory` (compatibility alias): deprecated alias for `project` + +**Migration:** `get_component_usage` was removed; use `get_symbol_references` for symbol usage evidence. ### Core Tools | Tool | Input | Output | | ----------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `search_codebase` | `query`, optional `intent`, `limit`, `filters`, `includeSnippets` | Ranked results (`file`, `summary`, `score`, `type`, `trend`, `patternWarning`, `relationships`, `hints`) + `searchQuality` + decision card (`ready`, `nextAction`, `patterns`, `bestExample`, `impact`, `whatWouldHelp`) when `intent="edit"`. Hints capped at 3 per category. | -| `get_team_patterns` | optional `category` | Pattern frequencies, trends, golden files, conflicts | -| `get_symbol_references` | `symbol`, optional `limit` | Concrete symbol usage evidence: `usageCount` + top usage snippets + `confidence` + `isComplete`. `confidence: "syntactic"` means static/source-based only (no runtime or dynamic dispatch). When Tree-sitter + file content are available, comments and string literals are excluded from the scan — the count reflects real identifier nodes only. Replaces the removed `get_component_usage`. | -| `remember` | `type`, `category`, `memory`, `reason` | Persists to `.codebase-context/memory.json` | -| `get_memory` | optional `category`, `type`, `query`, `limit` | Memories with confidence decay scoring | +| `search_codebase` | `query`, optional `intent`, `limit`, `filters`, `includeSnippets`, shared `project`/`project_directory` | Ranked results (`file`, `summary`, `score`, `type`, `trend`, `patternWarning`, `relationships`, `hints`) + `searchQuality` + decision card (`ready`, `nextAction`, `patterns`, `bestExample`, `impact`, `whatWouldHelp`) when `intent="edit"`. Hints capped at 3 per category. | +| `get_team_patterns` | optional `category`, shared `project`/`project_directory` | Pattern frequencies, trends, golden files, conflicts | +| `get_symbol_references` | `symbol`, optional `limit`, shared `project`/`project_directory` | Concrete symbol usage evidence: `usageCount` + top usage snippets + `confidence` + `isComplete`. `confidence: "syntactic"` means static/source-based only (no runtime or dynamic dispatch). When Tree-sitter + file content are available, comments and string literals are excluded from the scan — the count reflects real identifier nodes only. Replaces the removed `get_component_usage`. | +| `remember` | `type`, `category`, `memory`, `reason`, shared `project`/`project_directory` | Persists to `.codebase-context/memory.json` | +| `get_memory` | optional `category`, `type`, `query`, `limit`, shared `project`/`project_directory` | Memories with confidence decay scoring | ### Utility Tools @@ -56,6 +64,28 @@ npx codebase-context reindex --incremental | `refresh_index` | Full or incremental re-index + git memory extraction | | `get_indexing_status` | Index state, progress, last stats | +## Project Routing + +Behavior matrix: + +| Situation | Server behavior | +| --- | --- | +| One known project | Automatic routing | +| Multiple known projects + active project already set | Automatic routing to the active project | +| Multiple known projects + no active project | `selection_required` | +| No workspace context and no bootstrap path | `selection_required` until the caller passes `project` | + +Rules: + +- If the client provides workspace context, that becomes the trusted workspace boundary for the session. In practice this usually comes from MCP roots. +- If the server still cannot tell which project to use, a bootstrap path or explicit absolute `project` path remains the fallback. +- `project` is the canonical explicit selector when routing is ambiguous. +- `project` may point at a project path, file path, `file://` URI, or relative subproject path. +- Later tool calls may omit `project`; the server falls back to the active project when one has already been established. +- The server does not rely on `cwd` walk-up in MCP mode. +- `codebase://context` serves the active project. Before selection in an unresolved multi-project session, it returns a workspace overview with candidate projects, readiness state, and project-scoped resource URIs. +- `codebase://context/project/` serves a specific project directly and also makes that project active for later tool calls. + ## Retrieval Pipeline Ordered by execution: diff --git a/docs/cli.md b/docs/cli.md index 4a0897c..0926bd9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,12 +1,14 @@ # CLI Gallery (Human-readable) -`codebase-context` exposes its MCP tools as a local CLI so humans can: +`codebase-context` exposes its tools as a local CLI so humans can: - Onboard themselves onto an unfamiliar repo - Debug what the MCP server is doing - Use outputs in CI/scripts (via `--json`) > Output depends on the repo you run it against. The examples below are illustrative (paths, counts, and detected frameworks will vary). +> +> The CLI is intentionally single-project per invocation. MCP multi-project routing and trusted-root auto-discovery are only for the MCP server; the CLI still targets one root via `CODEBASE_ROOT` or the current working directory. ## How to run diff --git a/src/cli.ts b/src/cli.ts index 3aea7d3..16e5834 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ /** * CLI subcommands for codebase-context. * Memory list/add/remove — vendor-neutral access without any AI agent. - * search/metadata/status/reindex/style-guide/patterns/refs/cycles — all MCP tools. + * search/metadata/status/reindex/style-guide/patterns/refs/cycles — human-facing wrappers over core read/query tools. */ import path from 'path'; @@ -81,13 +81,17 @@ function printUsage(): void { console.log(' --help Show this help'); console.log(''); console.log('Environment:'); - console.log(' CODEBASE_ROOT Project root path (default: cwd)'); + console.log(' CODEBASE_ROOT Project root path (default: cwd for CLI only)'); console.log(' CODEBASE_CONTEXT_ASCII=1 Force ASCII-only box output'); console.log(' CODEBASE_CONTEXT_DEBUG=1 Enable verbose logs'); } +function resolveCliRootPath(): string { + return path.resolve(process.env.CODEBASE_ROOT || process.cwd()); +} + async function initToolContext(): Promise { - const rootPath = path.resolve(process.env.CODEBASE_ROOT || process.cwd()); + const rootPath = resolveCliRootPath(); const paths = { baseDir: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME), @@ -149,7 +153,12 @@ async function initToolContext(): Promise { } }; - return { indexState, paths, rootPath, performIndexing }; + return { + indexState, + paths, + rootPath, + performIndexing + }; } function extractText(result: { content?: Array<{ type: string; text: string }> }): string { diff --git a/src/index.ts b/src/index.ts index 0ffea2f..ea09eb1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,13 +38,26 @@ import { isComplementaryPatternCategory, shouldSkipLegacyTestingFrameworkCategory } from './patterns/semantics.js'; -import { CONTEXT_RESOURCE_URI, isContextResourceUri } from './resources/uri.js'; +import { + CONTEXT_RESOURCE_URI, + buildProjectContextResourceUri, + getProjectPathFromContextResourceUri, + isContextResourceUri +} from './resources/uri.js'; +import { + discoverProjectsWithinRoot, + findNearestProjectBoundary, + isPathWithin, + type DiscoveredProjectCandidate +} from './utils/project-discovery.js'; import { readIndexMeta, validateIndexArtifacts } from './core/index-meta.js'; import { TOOLS, dispatchTool, type ToolContext, type ToolResponse } from './tools/index.js'; -import type { ToolPaths } from './tools/types.js'; +import type { ProjectDescriptor, ToolPaths } from './tools/types.js'; import { getOrCreateProject, getAllProjects, + getProject, + makePaths, makeLegacyPaths, normalizeRootKey, removeProject, @@ -54,29 +67,31 @@ import { analyzerRegistry.register(new AngularAnalyzer()); analyzerRegistry.register(new GenericAnalyzer()); -// Resolve root path with validation -function resolveRootPath(): string { +// Resolve optional bootstrap root with validation handled later in main(). +function resolveRootPath(): string | undefined { const arg = process.argv[2]; const envPath = process.env.CODEBASE_ROOT; - // Priority: CLI arg > env var > cwd - let rootPath = arg || envPath || process.cwd(); - rootPath = path.resolve(rootPath); - - // Warn if using cwd as fallback (guarded to avoid stderr during MCP STDIO handshake) - if (!arg && !envPath && process.env.CODEBASE_CONTEXT_DEBUG) { - console.error(`[DEBUG] No project path specified. Using current directory: ${rootPath}`); - console.error(`[DEBUG] Hint: Specify path as CLI argument or set CODEBASE_ROOT env var`); + // Priority: CLI arg > env var. Do not fall back to cwd in MCP mode. + const configuredRoot = arg || envPath; + if (!configuredRoot) { + return undefined; } - return rootPath; + return path.resolve(configuredRoot); } const primaryRootPath = resolveRootPath(); -const primaryProject = getOrCreateProject(primaryRootPath); const toolNames = new Set(TOOLS.map((tool) => tool.name)); -const knownRoots = new Map(); +const knownRoots = new Map(); +const discoveredProjectPaths = new Map(); let clientRootsEnabled = false; +const projectSourcesByKey = new Map(); +const projectAccessOrder = new Map(); +let activeProjectKey: string | undefined; +let nextProjectAccessOrder = 1; +const MAX_WATCHED_PROJECTS = 5; +const PROJECT_DISCOVERY_MAX_DEPTH = 4; const debounceEnv = Number.parseInt(process.env.CODEBASE_CONTEXT_DEBOUNCE_MS ?? '', 10); const watcherDebounceMs = Number.isFinite(debounceEnv) && debounceEnv >= 0 ? debounceEnv : 2000; @@ -86,61 +101,372 @@ type ProjectResolution = function registerKnownRoot(rootPath: string): string { const resolvedRootPath = path.resolve(rootPath); - knownRoots.set(normalizeRootKey(resolvedRootPath), resolvedRootPath); + knownRoots.set(normalizeRootKey(resolvedRootPath), { rootPath: resolvedRootPath }); + rememberProjectPath(resolvedRootPath, 'root'); return resolvedRootPath; } function getKnownRootPaths(): string[] { - return Array.from(knownRoots.values()).sort((a, b) => a.localeCompare(b)); + return Array.from(knownRoots.values()) + .map((entry) => entry.rootPath) + .sort((a, b) => a.localeCompare(b)); +} + +function getKnownRootLabel(rootPath: string): string | undefined { + return knownRoots.get(normalizeRootKey(rootPath))?.label; +} + +function getContainingKnownRoot(rootPath: string): string | undefined { + const orderedRoots = getKnownRootPaths().sort((a, b) => b.length - a.length); + return orderedRoots.find((knownRootPath) => isPathWithin(knownRootPath, rootPath)); +} + +function classifyProjectSource(rootPath: string): ProjectDescriptor['source'] { + const rootKey = normalizeRootKey(rootPath); + if (knownRoots.has(rootKey)) { + return 'root'; + } + return getContainingKnownRoot(rootPath) ? 'subdirectory' : 'ad_hoc'; +} + +function touchProject(rootPath: string): void { + projectAccessOrder.set(normalizeRootKey(rootPath), nextProjectAccessOrder++); +} + +function rememberProjectPath( + rootPath: string, + source: ProjectDescriptor['source'] = classifyProjectSource(rootPath), + options: { touch?: boolean } = {} +): void { + const resolvedRootPath = path.resolve(rootPath); + const rootKey = normalizeRootKey(resolvedRootPath); + const existingSource = projectSourcesByKey.get(rootKey); + + if (!existingSource || source === 'root' || (source === 'subdirectory' && existingSource === 'ad_hoc')) { + projectSourcesByKey.set(rootKey, source); + } + + if (options.touch !== false) { + touchProject(resolvedRootPath); + } +} + +function registerDiscoveredProjectPath( + rootPath: string, + source: ProjectDescriptor['source'] = 'subdirectory' +): void { + const resolvedRootPath = path.resolve(rootPath); + discoveredProjectPaths.set(normalizeRootKey(resolvedRootPath), resolvedRootPath); + rememberProjectPath(resolvedRootPath, source, { touch: false }); +} + +function clearDiscoveredProjectPaths(): void { + discoveredProjectPaths.clear(); } -function syncKnownRoots(rootPaths: string[]): void { - const nextRoots = new Map(); - const normalizedRoots = rootPaths.length > 0 ? rootPaths : [primaryRootPath]; +function getTrackedRootPathByKey(rootKey: string): string | undefined { + if (knownRoots.has(rootKey)) { + return knownRoots.get(rootKey)?.rootPath; + } + + const project = Array.from(getAllProjects()).find( + (entry) => normalizeRootKey(entry.rootPath) === rootKey + ); + return project?.rootPath; +} - for (const rootPath of normalizedRoots) { - const resolvedRootPath = path.resolve(rootPath); - nextRoots.set(normalizeRootKey(resolvedRootPath), resolvedRootPath); +function forgetProjectPath(rootPath: string): void { + const rootKey = normalizeRootKey(rootPath); + projectSourcesByKey.delete(rootKey); + projectAccessOrder.delete(rootKey); + if (activeProjectKey === rootKey) { + activeProjectKey = undefined; + } +} + +function formatProjectLabel(rootPath: string): string { + const knownRootLabel = getKnownRootLabel(rootPath); + if (knownRootLabel) { + return knownRootLabel; + } + + const containingRoot = getContainingKnownRoot(rootPath); + if (containingRoot) { + const relativePath = path.relative(containingRoot, rootPath); + if (!relativePath) { + return getKnownRootLabel(containingRoot) ?? (path.basename(rootPath) || rootPath); + } + return relativePath.replace(/\\/g, '/'); + } + return path.basename(rootPath) || rootPath; +} + +function getRelativeProjectPath(rootPath: string): string | undefined { + const containingRoot = getContainingKnownRoot(rootPath); + if (!containingRoot) return undefined; + + const relativePath = path.relative(containingRoot, rootPath).replace(/\\/g, '/'); + return relativePath || undefined; +} + +function getProjectIndexStatus(rootPath: string): ProjectDescriptor['indexStatus'] { + return getProject(rootPath)?.indexState.status ?? 'idle'; +} + +function buildProjectDescriptor(rootPath: string): ProjectDescriptor { + const resolvedRootPath = path.resolve(rootPath); + const rootKey = normalizeRootKey(resolvedRootPath); + rememberProjectPath(resolvedRootPath, classifyProjectSource(resolvedRootPath), { touch: false }); + return { + project: resolvedRootPath, + label: formatProjectLabel(resolvedRootPath), + rootPath: resolvedRootPath, + relativePath: getRelativeProjectPath(resolvedRootPath), + active: activeProjectKey === rootKey, + source: projectSourcesByKey.get(rootKey) ?? classifyProjectSource(resolvedRootPath), + indexStatus: getProjectIndexStatus(resolvedRootPath) + }; +} + +function listProjectDescriptors(): ProjectDescriptor[] { + const rootPaths = new Map(); + for (const rootPath of getKnownRootPaths()) { + rootPaths.set(normalizeRootKey(rootPath), rootPath); + } + for (const [projectKey, rootPath] of discoveredProjectPaths.entries()) { + rootPaths.set(projectKey, rootPath); + } + for (const project of getAllProjects()) { + rootPaths.set(normalizeRootKey(project.rootPath), project.rootPath); + } + + const descriptors = Array.from(rootPaths.values()) + .map((rootPath) => buildProjectDescriptor(rootPath)) + .sort((a, b) => { + if (a.active !== b.active) return a.active ? -1 : 1; + if (a.source !== b.source) { + const weight: Record = { + root: 0, + subdirectory: 1, + ad_hoc: 2 + }; + return weight[a.source] - weight[b.source]; + } + return a.label.localeCompare(b.label); + }); + + const duplicates = new Set(); + const counts = new Map(); + for (const descriptor of descriptors) { + counts.set(descriptor.label, (counts.get(descriptor.label) ?? 0) + 1); + } + for (const [label, count] of counts.entries()) { + if (count > 1) { + duplicates.add(label); + } + } + + return descriptors.map((descriptor) => { + if (!duplicates.has(descriptor.label)) { + return descriptor; + } + + const containingRoot = getContainingKnownRoot(descriptor.rootPath); + const rootHint = + (containingRoot && getKnownRootLabel(containingRoot)) || + (containingRoot && path.basename(containingRoot)) || + path.basename(descriptor.rootPath); + + return { + ...descriptor, + label: `${descriptor.label} (${rootHint})` + }; + }); +} + +function getActiveProjectDescriptor(): ProjectDescriptor | undefined { + if (!activeProjectKey) return undefined; + const trackedRootPath = getTrackedRootPathByKey(activeProjectKey); + + if (!trackedRootPath) { + activeProjectKey = undefined; + return undefined; } - for (const [rootKey, existingRootPath] of knownRoots.entries()) { + return buildProjectDescriptor(trackedRootPath); +} + +function setActiveProject(rootPath: string): void { + const resolvedRootPath = path.resolve(rootPath); + activeProjectKey = normalizeRootKey(resolvedRootPath); + rememberProjectPath(resolvedRootPath); +} + +function syncKnownRoots(rootEntries: Array<{ rootPath: string; label?: string }>): void { + const nextRoots = new Map(); + const normalizedRoots = + rootEntries.length > 0 + ? rootEntries + : primaryRootPath + ? [{ rootPath: primaryRootPath }] + : []; + + for (const entry of normalizedRoots) { + const resolvedRootPath = path.resolve(entry.rootPath); + nextRoots.set(normalizeRootKey(resolvedRootPath), { + rootPath: resolvedRootPath, + label: entry.label?.trim() || undefined + }); + } + + for (const [rootKey, existingRoot] of knownRoots.entries()) { if (!nextRoots.has(rootKey)) { - removeProject(existingRootPath); + removeProject(existingRoot.rootPath); + forgetProjectPath(existingRoot.rootPath); + } + } + + for (const project of getAllProjects()) { + const stillAllowed = Array.from(nextRoots.values()).some((knownRoot) => + isPathWithin(knownRoot.rootPath, project.rootPath) + ); + if (!stillAllowed) { + removeProject(project.rootPath); + forgetProjectPath(project.rootPath); } } knownRoots.clear(); - for (const [rootKey, rootPath] of nextRoots.entries()) { - knownRoots.set(rootKey, rootPath); + clearDiscoveredProjectPaths(); + for (const [rootKey, rootEntry] of nextRoots.entries()) { + knownRoots.set(rootKey, rootEntry); + rememberProjectPath(rootEntry.rootPath, 'root', { touch: false }); + } + + if (activeProjectKey) { + if (!getTrackedRootPathByKey(activeProjectKey)) { + activeProjectKey = undefined; + } } } -function parseProjectDirectory(value: unknown): string | undefined { +function parseProjectSelector(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); + return trimmedValue; +} + +function parseProjectDirectory(value: unknown): string | undefined { + const selector = parseProjectSelector(value); + if (!selector) return undefined; + + return selector.startsWith('file://') ? path.resolve(fileURLToPath(selector)) : path.resolve(selector); +} + +function getProjectSourceForResolvedPath(rootPath: string): ProjectDescriptor['source'] { + return getContainingKnownRoot(rootPath) ? 'subdirectory' : 'ad_hoc'; +} + +async function resolveProjectFromAbsolutePath(resolvedPath: string): Promise { + const absolutePath = path.resolve(resolvedPath); + const containingRoot = getContainingKnownRoot(absolutePath); + + if (clientRootsEnabled && getKnownRootPaths().length > 0 && !containingRoot) { + return { + ok: false, + response: buildProjectSelectionError( + 'unknown_project', + 'Requested project is not under an active MCP root.' + ) + }; + } + + let stats; + try { + stats = await fs.stat(absolutePath); + } catch { + return { + ok: false, + response: buildProjectSelectionError('unknown_project', `project does not exist: ${absolutePath}`) + }; + } + + const lookupPath = stats.isDirectory() ? absolutePath : path.dirname(absolutePath); + const exactDescriptor = listProjectDescriptors().find( + (descriptor) => normalizeRootKey(descriptor.rootPath) === normalizeRootKey(lookupPath) + ); + if (exactDescriptor) { + const project = getOrCreateProject(exactDescriptor.rootPath); + if (exactDescriptor.source === 'subdirectory') { + registerDiscoveredProjectPath(exactDescriptor.rootPath, 'subdirectory'); + } else { + rememberProjectPath(exactDescriptor.rootPath, exactDescriptor.source, { touch: false }); + } + return { ok: true, project }; + } + + const nearestBoundary = await findNearestProjectBoundary(absolutePath, containingRoot); + const resolvedProjectPath = + nearestBoundary?.rootPath ?? + containingRoot ?? + (stats.isDirectory() ? absolutePath : undefined); + + if (!resolvedProjectPath) { + return { + ok: false, + response: buildProjectSelectionError( + 'unknown_project', + `project was not found from path: ${absolutePath}` + ) + }; + } + + const invalidProjectResponse = await validateResolvedProjectPath(resolvedProjectPath); + if (invalidProjectResponse) { + return { ok: false, response: invalidProjectResponse }; + } + + const projectSource = getProjectSourceForResolvedPath(resolvedProjectPath); + if (projectSource === 'subdirectory') { + registerDiscoveredProjectPath(resolvedProjectPath, 'subdirectory'); + } else { + rememberProjectPath(resolvedProjectPath, projectSource, { touch: false }); + } + + const project = getOrCreateProject(resolvedProjectPath); + return { ok: true, project }; +} + +function buildProjectSelectionPayload( + status: 'success' | 'selection_required' | 'error', + message: string, + project?: ProjectState, + extras: Record = {} +): Record { + return { + status, + message, + activeProject: project ? buildProjectDescriptor(project.rootPath) : getActiveProjectDescriptor() ?? null, + availableProjects: listProjectDescriptors(), + ...extras + }; } function buildProjectSelectionError( - errorCode: 'ambiguous_project' | 'unknown_project', - message: string + errorCode: 'selection_required' | 'unknown_project', + message: string, + extras: Record = {} ): ToolResponse { + const status = errorCode === 'selection_required' ? 'selection_required' : 'error'; return { content: [ { type: 'text', text: JSON.stringify( - { - status: 'error', - errorCode, - message, - availableRoots: getKnownRootPaths() - }, + { ...buildProjectSelectionPayload(status, message, undefined, extras), errorCode }, null, 2 ) @@ -155,11 +481,28 @@ function createToolContext(project: ProjectState): ToolContext { indexState: project.indexState, paths: project.paths, rootPath: project.rootPath, - performIndexing: (incrementalOnly?: boolean) => performIndexing(project, incrementalOnly) + project: buildProjectDescriptor(project.rootPath), + performIndexing: (incrementalOnly?: boolean) => performIndexing(project, incrementalOnly), + listProjects: () => listProjectDescriptors(), + getActiveProject: () => getActiveProjectDescriptor() }; } -registerKnownRoot(primaryRootPath); +function createWorkspaceToolContext(): ToolContext { + const fallbackRootPath = primaryRootPath ?? path.resolve(process.cwd()); + return { + indexState: { status: 'idle' }, + paths: makePaths(fallbackRootPath), + rootPath: fallbackRootPath, + performIndexing: () => undefined, + listProjects: () => listProjectDescriptors(), + getActiveProject: () => getActiveProjectDescriptor() + }; +} + +if (primaryRootPath) { + registerKnownRoot(primaryRootPath); +} export const INDEX_CONSUMING_TOOL_NAMES = [ 'search_codebase', @@ -230,25 +573,140 @@ async function ensureValidIndexOrAutoHeal(project: ProjectState): Promise { +async function validateResolvedProjectPath(rootPath: string): Promise { try { const stats = await fs.stat(rootPath); - if (stats.isDirectory()) { - return undefined; + if (!stats.isDirectory()) { + return buildProjectSelectionError( + 'unknown_project', + `project is not a directory: ${rootPath}` + ); } - return buildProjectSelectionError( - 'unknown_project', - `project_directory is not a directory: ${rootPath}` - ); + if (clientRootsEnabled && getKnownRootPaths().length > 0 && !getContainingKnownRoot(rootPath)) { + return buildProjectSelectionError( + 'unknown_project', + 'Requested project is not under an active MCP root.' + ); + } + + return undefined; } catch { return buildProjectSelectionError( 'unknown_project', - `project_directory does not exist: ${rootPath}` + `project does not exist: ${rootPath}` ); } } +async function resolveProjectSelector(selector: string): Promise { + const trimmedSelector = selector.trim(); + if (!trimmedSelector) { + return { + ok: false, + response: buildProjectSelectionError( + 'unknown_project', + 'project must be a non-empty absolute path, file:// URI, or relative subproject path.' + ) + }; + } + + if (trimmedSelector.startsWith('file://') || path.isAbsolute(trimmedSelector)) { + const resolvedPath = parseProjectDirectory(trimmedSelector); + if (!resolvedPath) { + return { + ok: false, + response: buildProjectSelectionError( + 'unknown_project', + 'project must be a non-empty absolute path, file:// URI, or relative subproject path.' + ) + }; + } + return resolveProjectFromAbsolutePath(resolvedPath); + } + + const normalizedSelector = trimmedSelector.replace(/\\/g, '/').replace(/^\.\/+/, ''); + const descriptorMatches = listProjectDescriptors().filter( + (descriptor) => + descriptor.label === normalizedSelector || + descriptor.relativePath === normalizedSelector || + path.basename(descriptor.rootPath) === normalizedSelector + ); + + if (descriptorMatches.length === 1) { + const matchedRootPath = descriptorMatches[0].rootPath; + if (descriptorMatches[0].source === 'subdirectory') { + registerDiscoveredProjectPath(matchedRootPath, 'subdirectory'); + } else { + rememberProjectPath(matchedRootPath, classifyProjectSource(matchedRootPath), { touch: false }); + } + const project = getOrCreateProject(matchedRootPath); + return { ok: true, project }; + } + + if (descriptorMatches.length > 1) { + return { + ok: false, + response: buildProjectSelectionError( + 'selection_required', + `Project selector "${normalizedSelector}" matches multiple known projects. Retry with an absolute path.`, + { + reason: 'project_selector_ambiguous', + nextAction: 'retry_with_project' + } + ) + }; + } + + const matchingProjects = getKnownRootPaths() + .map((rootPath) => ({ rootPath, candidatePath: path.resolve(rootPath, normalizedSelector) })) + .filter(({ rootPath, candidatePath }) => isPathWithin(rootPath, candidatePath)) + .map(({ candidatePath }) => candidatePath); + + const resolvedMatches = new Map(); + for (const candidatePath of matchingProjects) { + const resolution = await resolveProjectFromAbsolutePath(candidatePath); + if (resolution.ok) { + resolvedMatches.set(normalizeRootKey(resolution.project.rootPath), resolution.project); + continue; + } + + const payload = JSON.parse( + resolution.response.content?.[0]?.text ?? '{}' + ) as { errorCode?: string }; + if (payload.errorCode !== 'unknown_project') { + return resolution; + } + } + + if (resolvedMatches.size === 1) { + const project = Array.from(resolvedMatches.values())[0]; + return { ok: true, project }; + } + + if (resolvedMatches.size > 1) { + return { + ok: false, + response: buildProjectSelectionError( + 'selection_required', + `Relative project path "${normalizedSelector}" matches multiple configured roots. Retry with an absolute path.`, + { + reason: 'relative_project_ambiguous', + nextAction: 'retry_with_project' + } + ) + }; + } + + return { + ok: false, + response: buildProjectSelectionError( + 'unknown_project', + `Relative project path "${normalizedSelector}" was not found under any configured root.` + ) + }; +} + /** * Check if file/directory exists */ @@ -340,20 +798,31 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: TOOLS }; }); -// MCP Resources - Proactive context injection -const RESOURCES: Resource[] = [ - { - uri: CONTEXT_RESOURCE_URI, - name: 'Codebase Intelligence', - description: - 'Automatic codebase context: libraries used, team patterns, and conventions. ' + - 'Read this BEFORE generating code to follow team standards.', - mimeType: 'text/plain' +function buildResources(): Resource[] { + const resources: Resource[] = [ + { + uri: CONTEXT_RESOURCE_URI, + name: 'Codebase Intelligence', + 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.', + mimeType: 'text/plain' + } + ]; + + for (const project of listProjectDescriptors()) { + resources.push({ + uri: buildProjectContextResourceUri(project.rootPath), + name: `Codebase Intelligence (${project.label})`, + description: `Project-scoped context for ${project.label}.`, + mimeType: 'text/plain' + }); } -]; + + return resources; +} server.setRequestHandler(ListResourcesRequestSchema, async () => { - return { resources: RESOURCES }; + return { resources: buildResources() }; }); async function generateCodebaseContext(project: ProjectState): Promise { @@ -516,22 +985,71 @@ async function generateCodebaseContext(project: ProjectState): Promise { } } +function buildProjectSelectionMessage(): string { + const projects = listProjectDescriptors(); + if (projects.length === 0) { + return [ + '# Codebase Workspace', + '', + 'This MCP session is waiting for project context.', + 'If your host supports MCP roots, project discovery will begin after the client announces its workspace roots.', + 'Otherwise retry the tool call with `project` using an absolute project path, file path, or file:// URI.' + ].join('\n'); + } + + const lines = [ + '# Codebase Workspace', + '', + 'This MCP session is using client-announced roots as the workspace boundary.', + 'Automatic routing is only possible when one project is unambiguous or this session already has an active project.', + 'If the MCP client does not provide enough context, retry tool calls with `project` using a root path, subproject path, or file path.', + '', + 'Available projects:', + '' + ]; + for (const project of projects) { + const projectPathHint = project.relativePath ? `${project.relativePath} | ${project.rootPath}` : project.rootPath; + lines.push(`- ${project.label} [${project.indexStatus}]`); + lines.push(` project: ${projectPathHint}`); + lines.push(` resource: ${buildProjectContextResourceUri(project.rootPath)}`); + } + lines.push(''); + lines.push('Recommended flow: retry the tool call with `project`.'); + return lines.join('\n'); +} + server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; + const explicitProjectPath = getProjectPathFromContextResourceUri(uri); + + if (explicitProjectPath) { + const selection = await resolveProjectSelector(explicitProjectPath); + if (!selection.ok) { + throw new Error(`Unknown project resource: ${uri}`); + } + + const project = selection.project; + await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true }); + setActiveProject(project.rootPath); + return { + contents: [ + { + uri: buildProjectContextResourceUri(project.rootPath), + mimeType: 'text/plain', + text: await generateCodebaseContext(project) + } + ] + }; + } if (isContextResourceUri(uri)) { 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: [ { uri: CONTEXT_RESOURCE_URI, mimeType: 'text/plain', - text: content + text: project ? await generateCodebaseContext(project) : buildProjectSelectionMessage() } ] }; @@ -662,78 +1180,147 @@ async function shouldReindex(paths: ToolPaths): Promise { } } +async function refreshDiscoveredProjectsForKnownRoots(): Promise { + clearDiscoveredProjectPaths(); + await Promise.all( + getKnownRootPaths().map(async (rootPath) => { + const candidates = await discoverProjectsWithinRoot(rootPath, { + maxDepth: PROJECT_DISCOVERY_MAX_DEPTH + }); + for (const candidate of candidates) { + registerDiscoveredProjectPath(candidate.rootPath, 'subdirectory'); + } + }) + ); +} + 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)); + .map((root) => ({ + uri: root.uri, + label: typeof root.name === 'string' && root.name.trim() ? root.name.trim() : undefined + })) + .filter((root) => root.uri.startsWith('file://')) + .map((root) => ({ + rootPath: fileURLToPath(root.uri), + label: root.label + })); clientRootsEnabled = fileRoots.length > 0; syncKnownRoots(fileRoots); } catch { clientRootsEnabled = false; - syncKnownRoots([primaryRootPath]); + syncKnownRoots(primaryRootPath ? [{ rootPath: primaryRootPath }] : []); } -} -async function resolveProjectForTool(args: Record): Promise { - const requestedProjectDirectory = parseProjectDirectory(args.project_directory); - const availableRoots = getKnownRootPaths(); + await refreshDiscoveredProjectsForKnownRoots(); - if (requestedProjectDirectory) { - const requestedRootKey = normalizeRootKey(requestedProjectDirectory); - const knownRootPath = knownRoots.get(requestedRootKey); + await Promise.all( + getKnownRootPaths().map((rootPath) => + initProject(rootPath, watcherDebounceMs, { enableWatcher: false }).catch(() => { + /* best-effort prewarm */ + }) + ) + ); +} - if (clientRootsEnabled && availableRoots.length > 0 && !knownRootPath) { - return { - ok: false, - response: buildProjectSelectionError( - 'unknown_project', - 'Requested project is not part of the active MCP roots.' - ) - }; +async function resolveExplicitProjectSelection(selection: { + project?: string; + projectDirectory?: string; +}): Promise { + const explicitProject = selection.project ?? selection.projectDirectory; + if (explicitProject) { + const resolution = await resolveProjectSelector(explicitProject); + if (!resolution.ok) { + return resolution; } - const rootPath = knownRootPath ?? requestedProjectDirectory; - const invalidProjectResponse = await validateProjectDirectory(rootPath); - if (invalidProjectResponse) { - return { ok: false, response: invalidProjectResponse }; - } + await initProject(resolution.project.rootPath, watcherDebounceMs, { enableWatcher: true }); + setActiveProject(resolution.project.rootPath); + return resolution; + } - const project = getOrCreateProject(rootPath); - await initProject(project.rootPath, watcherDebounceMs, { - enableWatcher: knownRootPath !== undefined + return { + ok: false, + response: buildProjectSelectionError('selection_required', 'No project selector was provided.') + }; +} + +async function resolveProjectForTool(args: Record): Promise { + const requestedProject = parseProjectSelector(args.project); + const requestedProjectDirectory = parseProjectSelector(args.project_directory); + + if (requestedProject || requestedProjectDirectory) { + return resolveExplicitProjectSelection({ + project: requestedProject, + projectDirectory: requestedProjectDirectory }); + } + + const activeProject = activeProjectKey ? getTrackedRootPathByKey(activeProjectKey) : undefined; + if (activeProject) { + const project = getOrCreateProject(activeProject); + await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true }); + touchProject(project.rootPath); return { ok: true, project }; } - if (availableRoots.length !== 1) { + const availableProjects = listProjectDescriptors(); + if (availableProjects.length === 0) { return { ok: false, response: buildProjectSelectionError( - 'ambiguous_project', - 'Multiple project roots are available. Pass project_directory to choose one.' + 'selection_required', + 'No active project is available yet. Retry with project or wait for MCP roots to arrive.', + { + reason: clientRootsEnabled + ? 'workspace_waiting_for_project_selection' + : 'workspace_waiting_for_roots_or_project', + nextAction: 'retry_with_project' + } ) }; } - const [rootPath] = availableRoots; - const project = getOrCreateProject(rootPath); - await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true }); - return { ok: true, project }; + if (availableProjects.length === 1) { + const project = getOrCreateProject(availableProjects[0].rootPath); + await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true }); + setActiveProject(project.rootPath); + return { ok: true, project }; + } + + return { + ok: false, + response: buildProjectSelectionError( + 'selection_required', + 'Multiple projects are available and no active project could be inferred. Retry with project.', + { + reason: 'multiple_projects_configured_no_active_context', + nextAction: 'retry_with_project' + } + ) + }; } async function resolveProjectForResource(): Promise { - const availableRoots = getKnownRootPaths(); - if (availableRoots.length !== 1) { + const activeProject = activeProjectKey ? getTrackedRootPathByKey(activeProjectKey) : undefined; + if (activeProject) { + const project = getOrCreateProject(activeProject); + await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true }); + touchProject(project.rootPath); + return project; + } + + const availableProjects = listProjectDescriptors(); + if (availableProjects.length !== 1) { return undefined; } - const [rootPath] = availableRoots; - const project = getOrCreateProject(rootPath); + const project = getOrCreateProject(availableProjects[0].rootPath); await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true }); + setActiveProject(project.rootPath); return project; } @@ -746,7 +1333,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { try { if (!toolNames.has(name)) { - return await dispatchTool(name, normalizedArgs, createToolContext(primaryProject)); + return await dispatchTool(name, normalizedArgs, createWorkspaceToolContext()); } const projectResolution = await resolveProjectForTool(normalizedArgs); @@ -804,13 +1391,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const result = await dispatchTool(name, normalizedArgs, createToolContext(project)); - // Inject IndexSignal into response so callers can inspect index health + // Inject routing/index metadata into JSON responses so agents can reuse the resolved project safely. if (indexSignal !== undefined && result.content?.[0]) { try { const parsed = JSON.parse(result.content[0].text); result.content[0] = { type: 'text', - text: JSON.stringify({ ...parsed, index: indexSignal }) + text: JSON.stringify({ + ...parsed, + index: indexSignal, + project: buildProjectDescriptor(project.rootPath) + }) + }; + } catch { + /* response wasn't JSON, skip injection */ + } + } else if (result.content?.[0]) { + try { + const parsed = JSON.parse(result.content[0].text); + result.content[0] = { + type: 'text', + text: JSON.stringify({ ...parsed, project: buildProjectDescriptor(project.rootPath) }) }; } catch { /* response wasn't JSON, skip injection */ @@ -884,6 +1485,7 @@ async function ensureProjectInitialized(project: ProjectState): Promise { function ensureProjectWatcher(project: ProjectState, debounceMs: number): void { if (project.stopWatcher) { + touchProject(project.rootPath); return; } @@ -910,6 +1512,26 @@ function ensureProjectWatcher(project: ProjectState, debounceMs: number): void { void performIndexing(project, true); } }); + + touchProject(project.rootPath); + const watchedProjects = getAllProjects().filter((entry) => entry.stopWatcher); + if (watchedProjects.length <= MAX_WATCHED_PROJECTS) { + return; + } + + const evictionCandidates = watchedProjects + .filter((entry) => normalizeRootKey(entry.rootPath) !== activeProjectKey) + .sort((a, b) => { + const accessA = projectAccessOrder.get(normalizeRootKey(a.rootPath)) ?? 0; + const accessB = projectAccessOrder.get(normalizeRootKey(b.rootPath)) ?? 0; + return accessA - accessB; + }); + + const projectToEvict = evictionCandidates[0]; + if (projectToEvict?.stopWatcher) { + projectToEvict.stopWatcher(); + delete projectToEvict.stopWatcher; + } } async function initProject( @@ -917,8 +1539,10 @@ async function initProject( debounceMs: number, options: InitProjectOptions ): Promise { + rememberProjectPath(rootPath); const project = getOrCreateProject(rootPath); await ensureProjectInitialized(project); + touchProject(project.rootPath); if (options.enableWatcher) { ensureProjectWatcher(project, debounceMs); @@ -926,24 +1550,30 @@ async function initProject( } async function main() { - // Validate root path exists and is a directory - try { - const stats = await fs.stat(primaryRootPath); - if (!stats.isDirectory()) { - console.error(`ERROR: Root path is not a directory: ${primaryRootPath}`); + if (primaryRootPath) { + // Validate bootstrap root path exists and is a directory when explicitly configured. + try { + const stats = await fs.stat(primaryRootPath); + if (!stats.isDirectory()) { + 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: ${primaryRootPath}`); console.error(`Please specify a valid project directory.`); process.exit(1); } - } catch (_error) { - console.error(`ERROR: Root path does not exist: ${primaryRootPath}`); - console.error(`Please specify a valid project directory.`); - process.exit(1); } // 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: ${primaryRootPath}`); + console.error( + primaryRootPath + ? `[DEBUG] Bootstrap root: ${primaryRootPath}` + : '[DEBUG] Bootstrap root: ' + ); console.error( `[DEBUG] Analyzers: ${analyzerRegistry .getAll() @@ -953,7 +1583,7 @@ 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) { + if (process.env.CODEBASE_CONTEXT_DEBUG && primaryRootPath) { try { await fs.access(path.join(primaryRootPath, 'package.json')); console.error(`[DEBUG] Project detected: ${path.basename(primaryRootPath)}`); @@ -969,10 +1599,11 @@ async function main() { await refreshKnownRootsFromClient(); - // Preserve current single-project startup behavior without eagerly indexing every root. + // Keep the current single-project auto-select behavior while roots are pre-warmed in background. const startupRoots = getKnownRootPaths(); if (startupRoots.length === 1) { await initProject(startupRoots[0], watcherDebounceMs, { enableWatcher: true }); + setActiveProject(startupRoots[0]); } // Subscribe to root changes @@ -1003,7 +1634,7 @@ async function main() { } // Export server components for programmatic use -export { server, resolveRootPath, shouldReindex, TOOLS }; +export { server, refreshKnownRootsFromClient, resolveRootPath, shouldReindex, TOOLS }; export { performIndexing }; // Only auto-start when run directly as CLI (not when imported as module) diff --git a/src/resources/uri.ts b/src/resources/uri.ts index b88ad68..dc67d9c 100644 --- a/src/resources/uri.ts +++ b/src/resources/uri.ts @@ -1,9 +1,15 @@ const CONTEXT_RESOURCE_URI = 'codebase://context'; +const PROJECT_CONTEXT_RESOURCE_PREFIX = `${CONTEXT_RESOURCE_URI}/project/`; export function normalizeResourceUri(uri: string): string { if (!uri) return uri; if (uri === CONTEXT_RESOURCE_URI) return uri; if (uri.endsWith(`/${CONTEXT_RESOURCE_URI}`)) return CONTEXT_RESOURCE_URI; + const scopedMarker = `/${PROJECT_CONTEXT_RESOURCE_PREFIX}`; + const scopedIndex = uri.indexOf(scopedMarker); + if (scopedIndex >= 0) { + return uri.slice(scopedIndex + 1); + } return uri; } @@ -11,4 +17,18 @@ export function isContextResourceUri(uri: string): boolean { return normalizeResourceUri(uri) === CONTEXT_RESOURCE_URI; } -export { CONTEXT_RESOURCE_URI }; +export function buildProjectContextResourceUri(projectPath: string): string { + return `${PROJECT_CONTEXT_RESOURCE_PREFIX}${encodeURIComponent(projectPath)}`; +} + +export function getProjectPathFromContextResourceUri(uri: string): string | undefined { + const normalized = normalizeResourceUri(uri); + if (!normalized.startsWith(PROJECT_CONTEXT_RESOURCE_PREFIX)) { + return undefined; + } + + const encodedProjectPath = normalized.slice(PROJECT_CONTEXT_RESOURCE_PREFIX.length); + return encodedProjectPath ? decodeURIComponent(encodedProjectPath) : undefined; +} + +export { CONTEXT_RESOURCE_URI, PROJECT_CONTEXT_RESOURCE_PREFIX }; diff --git a/src/tools/index.ts b/src/tools/index.ts index ac49114..0e578c0 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -15,36 +15,46 @@ import { definition as d10, handle as h10 } from './get-memory.js'; import type { ToolContext, ToolResponse } from './types.js'; +const PROJECT_PROPERTY: Record = { + type: 'string', + description: + 'Optional project selector for this call. Accepts a project root path, file path, file:// URI, or a relative subproject path under a configured root.' +}; + 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.' + 'Deprecated compatibility alias for older clients. Prefer project.' }; -function withProjectDirectory(definition: Tool): Tool { +function withProjectSelector(definition: Tool): Tool { const schema = definition.inputSchema; if (!schema || schema.type !== 'object') { return definition; } const properties = { ...(schema.properties ?? {}) }; - if ('project_directory' in properties) { + if ('project' in properties && 'project_directory' in properties) { return definition; } return { ...definition, + description: `Routes to the active/current project automatically when known. ${definition.description}`, inputSchema: { ...schema, properties: { ...properties, + ...('project' in properties ? {} : { project: PROJECT_PROPERTY }), project_directory: PROJECT_DIRECTORY_PROPERTY } } }; } -export const TOOLS: Tool[] = [d1, d2, d3, d4, d5, d6, d7, d8, d9, d10].map(withProjectDirectory); +export const TOOLS: Tool[] = [d1, d2, d3, d4, d5, d6, d7, d8, d9, d10].map( + withProjectSelector +); export async function dispatchTool( name: string, diff --git a/src/tools/types.ts b/src/tools/types.ts index e33c3b7..9a3e6d7 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -26,6 +26,16 @@ export interface ToolPaths { vectorDb: string; } +export interface ProjectDescriptor { + project: string; + label: string; + rootPath: string; + relativePath?: string; + active: boolean; + source: 'root' | 'subdirectory' | 'ad_hoc'; + indexStatus: 'idle' | 'indexing' | 'ready' | 'error'; +} + export interface IndexState { status: 'idle' | 'indexing' | 'ready' | 'error'; lastIndexed?: Date; @@ -38,7 +48,10 @@ export interface ToolContext { indexState: IndexState; paths: ToolPaths; rootPath: string; + project?: ProjectDescriptor; performIndexing: (incrementalOnly?: boolean, reason?: string) => void; + listProjects?: () => ProjectDescriptor[]; + getActiveProject?: () => ProjectDescriptor | undefined; } export interface ToolResponse { diff --git a/src/utils/project-discovery.ts b/src/utils/project-discovery.ts new file mode 100644 index 0000000..ad7367e --- /dev/null +++ b/src/utils/project-discovery.ts @@ -0,0 +1,255 @@ +import { promises as fs } from 'fs'; +import type { Dirent } from 'fs'; +import path from 'path'; + +export type ProjectEvidence = + | 'existing_index' + | 'repo_root' + | 'workspace_manifest' + | 'project_manifest'; + +export interface DiscoveredProjectCandidate { + rootPath: string; + evidence: ProjectEvidence; +} + +export interface DiscoverProjectsOptions { + maxDepth?: number; +} + +const DEFAULT_MAX_DEPTH = 4; + +const IGNORED_DIRECTORY_NAMES = new Set([ + '.git', + '.hg', + '.svn', + '.next', + '.nuxt', + '.turbo', + '.venv', + '.yarn', + 'build', + 'coverage', + 'dist', + 'node_modules', + 'out', + 'target', + 'tmp', + 'vendor' +]); + +const STRONG_DIRECTORY_MARKERS = new Set(['.codebase-context', '.git']); +const WORKSPACE_MARKERS = new Set(['lerna.json', 'nx.json', 'pnpm-workspace.yaml', 'turbo.json']); +const PROJECT_MANIFEST_NAMES = new Set([ + 'Cargo.toml', + 'Gemfile', + 'composer.json', + 'deno.json', + 'deno.jsonc', + 'go.mod', + 'mix.exs', + 'package.json', + 'pom.xml', + 'pyproject.toml' +]); +const PROJECT_MANIFEST_SUFFIXES = ['.csproj', '.fsproj', '.vbproj']; +const GRADLE_MANIFESTS = new Set([ + 'build.gradle', + 'build.gradle.kts', + 'settings.gradle', + 'settings.gradle.kts' +]); + +function normalizePathKey(filePath: string): string { + let normalized = path.resolve(filePath); + while (normalized.length > 1 && (normalized.endsWith('/') || normalized.endsWith('\\'))) { + normalized = normalized.slice(0, -1); + } + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +export function isPathWithin(basePath: string, candidatePath: string): boolean { + const resolvedBasePath = path.resolve(basePath); + const resolvedCandidatePath = path.resolve(candidatePath); + if (resolvedBasePath === resolvedCandidatePath) return true; + const relative = path.relative(resolvedBasePath, resolvedCandidatePath); + return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); +} + +async function isWorkspacePackageJson(directoryPath: string): Promise { + const packageJsonPath = path.join(directoryPath, 'package.json'); + try { + const content = await fs.readFile(packageJsonPath, 'utf-8'); + const parsed = JSON.parse(content) as { workspaces?: unknown }; + if (Array.isArray(parsed.workspaces)) { + return parsed.workspaces.length > 0; + } + if ( + parsed.workspaces && + typeof parsed.workspaces === 'object' && + !Array.isArray(parsed.workspaces) && + 'packages' in parsed.workspaces + ) { + return Array.isArray((parsed.workspaces as { packages?: unknown }).packages); + } + return false; + } catch { + return false; + } +} + +async function classifyDirectory( + directoryPath: string, + fileNames: Set, + directoryNames: Set +): Promise<{ candidate?: DiscoveredProjectCandidate; continueScanning: boolean }> { + if (directoryNames.has('.codebase-context')) { + return { + candidate: { rootPath: directoryPath, evidence: 'existing_index' }, + continueScanning: false + }; + } + + if (directoryNames.has('.git')) { + return { + candidate: { rootPath: directoryPath, evidence: 'repo_root' }, + continueScanning: false + }; + } + + for (const marker of WORKSPACE_MARKERS) { + if (fileNames.has(marker)) { + return { + candidate: { rootPath: directoryPath, evidence: 'workspace_manifest' }, + continueScanning: true + }; + } + } + + if (fileNames.has('package.json') && (await isWorkspacePackageJson(directoryPath))) { + return { + candidate: { rootPath: directoryPath, evidence: 'workspace_manifest' }, + continueScanning: true + }; + } + + for (const fileName of fileNames) { + if (PROJECT_MANIFEST_NAMES.has(fileName) || GRADLE_MANIFESTS.has(fileName)) { + return { + candidate: { rootPath: directoryPath, evidence: 'project_manifest' }, + continueScanning: false + }; + } + if (PROJECT_MANIFEST_SUFFIXES.some((suffix) => fileName.endsWith(suffix))) { + return { + candidate: { rootPath: directoryPath, evidence: 'project_manifest' }, + continueScanning: false + }; + } + } + + return { continueScanning: true }; +} + +export async function discoverProjectsWithinRoot( + trustedRootPath: string, + options: DiscoverProjectsOptions = {} +): Promise { + const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; + const resolvedTrustedRootPath = path.resolve(trustedRootPath); + const discovered = new Map(); + + async function walk(currentPath: string, depth: number): Promise { + if (depth > maxDepth) { + return; + } + + let entries: Dirent[]; + try { + entries = await fs.readdir(currentPath, { withFileTypes: true }); + } catch { + return; + } + + const fileNames = new Set( + entries.filter((entry) => entry.isFile()).map((entry) => entry.name) + ); + const directoryNames = new Set( + entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) + ); + const classification = await classifyDirectory(currentPath, fileNames, directoryNames); + const currentKey = normalizePathKey(currentPath); + const isTrustedRoot = currentKey === normalizePathKey(resolvedTrustedRootPath); + + if (classification.candidate && !isTrustedRoot) { + discovered.set(currentKey, classification.candidate); + } + + const shouldContinueScanning = isTrustedRoot ? true : classification.continueScanning; + if (depth >= maxDepth || !shouldContinueScanning) { + return; + } + + await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .filter((entry) => !IGNORED_DIRECTORY_NAMES.has(entry.name)) + .filter((entry) => !STRONG_DIRECTORY_MARKERS.has(entry.name)) + .map((entry) => walk(path.join(currentPath, entry.name), depth + 1)) + ); + } + + await walk(resolvedTrustedRootPath, 0); + return Array.from(discovered.values()).sort((a, b) => a.rootPath.localeCompare(b.rootPath)); +} + +export async function findNearestProjectBoundary( + inputPath: string, + trustedRootPath?: string +): Promise { + const resolvedTrustedRootPath = trustedRootPath ? path.resolve(trustedRootPath) : undefined; + let currentPath = path.resolve(inputPath); + + for (;;) { + if (resolvedTrustedRootPath && !isPathWithin(resolvedTrustedRootPath, currentPath)) { + return undefined; + } + + let stats; + try { + stats = await fs.stat(currentPath); + } catch { + return undefined; + } + + const directoryPath = stats.isDirectory() ? currentPath : path.dirname(currentPath); + let entries: Dirent[]; + try { + entries = await fs.readdir(directoryPath, { withFileTypes: true }); + } catch { + entries = []; + } + + const fileNames = new Set( + entries.filter((entry) => entry.isFile()).map((entry) => entry.name) + ); + const directoryNames = new Set( + entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) + ); + const classification = await classifyDirectory(directoryPath, fileNames, directoryNames); + if (classification.candidate) { + return classification.candidate; + } + + if (resolvedTrustedRootPath && normalizePathKey(directoryPath) === normalizePathKey(resolvedTrustedRootPath)) { + return undefined; + } + + const parentPath = path.dirname(directoryPath); + if (parentPath === directoryPath) { + return undefined; + } + + currentPath = parentPath; + } +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 933c265..b9326ab 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -195,5 +195,6 @@ describe('CLI', () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + }); diff --git a/tests/multi-project-routing.test.ts b/tests/multi-project-routing.test.ts index 6ecc8d2..bb01d59 100644 --- a/tests/multi-project-routing.test.ts +++ b/tests/multi-project-routing.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; +import { pathToFileURL } from 'url'; import { CODEBASE_CONTEXT_DIRNAME, INDEX_FORMAT_VERSION, @@ -10,7 +11,10 @@ import { KEYWORD_INDEX_FILENAME, VECTOR_DB_DIRNAME } from '../src/constants/codebase-context.js'; -import { CONTEXT_RESOURCE_URI } from '../src/resources/uri.js'; +import { + CONTEXT_RESOURCE_URI, + buildProjectContextResourceUri +} from '../src/resources/uri.js'; interface SearchResultRow { summary: string; @@ -140,9 +144,28 @@ async function seedValidIndex(rootPath: string): Promise { ); } +function parsePayload(response: ToolCallResponse): Record { + return JSON.parse(response.content[0]?.text ?? '{}') as Record; +} + +async function callTool( + handler: (request: unknown) => Promise, + id: number, + name: string, + args: Record +): Promise { + return (await handler({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name, arguments: args } + })) as ToolCallResponse; +} + describe('multi-project routing', () => { let primaryRoot: string; let secondaryRoot: string; + let nestedProjectRoot: string; let originalArgv: string[] | null = null; let originalEnvRoot: string | undefined; @@ -156,11 +179,15 @@ describe('multi-project routing', () => { primaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-primary-root-')); secondaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-secondary-root-')); + nestedProjectRoot = path.join(primaryRoot, 'apps', 'dashboard'); + process.env.CODEBASE_ROOT = primaryRoot; process.argv[2] = primaryRoot; await seedValidIndex(primaryRoot); await seedValidIndex(secondaryRoot); + await fs.mkdir(nestedProjectRoot, { recursive: true }); + await seedValidIndex(nestedProjectRoot); watcherMocks.start.mockImplementation( ({ rootPath }: { rootPath: string }) => @@ -199,139 +226,226 @@ describe('multi-project routing', () => { 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'); + it('starts without a bootstrap root and routes once client roots arrive', async () => { + delete process.env.CODEBASE_ROOT; + delete process.argv[2]; - if (!handler) { - throw new Error('tools/call handler not registered'); + const { server, refreshKnownRootsFromClient } = await import('../src/index.js'); + const typedServer = server as unknown as TestServer & { + listRoots: () => Promise<{ roots: Array<{ uri: string; name?: string }> }>; + }; + const originalListRoots = typedServer.listRoots.bind(typedServer); + const handler = typedServer._requestHandlers.get('tools/call'); + if (!handler) throw new Error('tools/call handler not registered'); + + typedServer.listRoots = vi.fn().mockResolvedValue({ + roots: [{ uri: pathToFileURL(secondaryRoot).href, name: 'Secondary' }] + }); + + try { + await refreshKnownRootsFromClient(); + const response = await callTool(handler, 1, 'search_codebase', { query: 'feature' }); + const payload = parsePayload(response) as { + status: string; + project: { rootPath: string; label: string }; + }; + + expect(payload.status).toBe('success'); + expect(payload.project.rootPath).toBe(secondaryRoot); + expect(payload.project.label).toBe('Secondary'); + } finally { + typedServer.listRoots = originalListRoots; } + }); - 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(); + it('supports explicit project routing without bootstrap roots when the client does not expose roots', async () => { + delete process.env.CODEBASE_ROOT; + delete process.argv[2]; + + const { server, refreshKnownRootsFromClient } = await import('../src/index.js'); + const typedServer = server as unknown as TestServer & { + listRoots: () => Promise<{ roots: Array<{ uri: string; name?: string }> }>; + }; + const originalListRoots = typedServer.listRoots.bind(typedServer); + const handler = typedServer._requestHandlers.get('tools/call'); + if (!handler) throw new Error('tools/call handler not registered'); + + typedServer.listRoots = vi.fn().mockRejectedValue(new Error('roots unsupported')); + + try { + await refreshKnownRootsFromClient(); + const response = await callTool(handler, 2, 'search_codebase', { + query: 'feature', + project: secondaryRoot + }); + const payload = parsePayload(response) as { + status: string; + project: { rootPath: string }; + }; + + expect(payload.status).toBe('success'); + expect(payload.project.rootPath).toBe(secondaryRoot); + } finally { + typedServer.listRoots = originalListRoots; + } + }); + + it('returns selection_required instead of silently falling back to cwd when startup is rootless and unresolved', async () => { + delete process.env.CODEBASE_ROOT; + delete process.argv[2]; + + const { server, refreshKnownRootsFromClient } = await import('../src/index.js'); + const typedServer = server as unknown as TestServer & { + listRoots: () => Promise<{ roots: Array<{ uri: string; name?: string }> }>; + }; + const originalListRoots = typedServer.listRoots.bind(typedServer); + const handler = typedServer._requestHandlers.get('tools/call'); + if (!handler) throw new Error('tools/call handler not registered'); + + typedServer.listRoots = vi.fn().mockRejectedValue(new Error('roots unsupported')); + + try { + await refreshKnownRootsFromClient(); + const response = await callTool(handler, 3, 'search_codebase', { query: 'feature' }); + const payload = parsePayload(response) as { + status: string; + errorCode: string; + }; + + expect(response.isError).toBe(true); + expect(payload.status).toBe('selection_required'); + expect(payload.errorCode).toBe('selection_required'); + expect(searchMocks.search).not.toHaveBeenCalled(); + } finally { + typedServer.listRoots = originalListRoots; + } + }); - const payload = JSON.parse(response.content[0].text) as { + it('auto-selects the only known project when routing without an explicit selector', async () => { + const { server, refreshKnownRootsFromClient } = 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 fs.rm(nestedProjectRoot, { recursive: true, force: true }); + await refreshKnownRootsFromClient(); + const response = await callTool(handler, 4, 'search_codebase', { query: 'feature' }); + const payload = parsePayload(response) as { status: string; - results: Array<{ file: string }>; + project: { project: string; rootPath: string }; }; expect(payload.status).toBe('success'); - expect(payload.results[0]?.file).toContain('feature.ts'); + expect(payload.project.rootPath).toBe(primaryRoot); + expect(payload.project.project).toBe(primaryRoot); + expect(watcherMocks.start).toHaveBeenCalledWith(expect.objectContaining({ rootPath: primaryRoot })); }); - it('keeps ad-hoc project_directory requests scoped to the current call', async () => { + it('explicit project starts a watcher and makes that project active', 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'); - 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 callTool(handler, 5, 'search_codebase', { + query: 'feature', + project: 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 { + const payload = parsePayload(response) as { status: string; - results: Array<{ file: string }>; + project: { project: string; rootPath: 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'); + expect(payload.project.rootPath).toBe(secondaryRoot); + expect(payload.project.project).toBe(secondaryRoot); + expect(watcherMocks.start).toHaveBeenCalledWith( + expect.objectContaining({ rootPath: secondaryRoot }) + ); }); - it('rejects unknown project_directory values before initialization starts', async () => { + it('uses the active project for later tool calls and returns project metadata', 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'); - if (!handler) { - throw new Error('tools/call handler not registered'); - } + const selection = await callTool(handler, 6, 'search_codebase', { + query: 'feature', + project: secondaryRoot + }); + const selectedProject = (parsePayload(selection) as { project: { project: string } }).project.project; - 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 { + const response = await callTool(handler, 7, 'search_codebase', { query: 'feature' }); + const payload = parsePayload(response) as { status: string; - errorCode: string; - message: string; + project: { project: string; rootPath: string }; + results: Array<{ file: string }>; }; - expect(payload.status).toBe('error'); - expect(payload.errorCode).toBe('unknown_project'); - expect(payload.message).toContain('does not exist'); + expect(payload.status).toBe('success'); + expect(payload.project.project).toBe(selectedProject); + expect(payload.project.rootPath).toBe(secondaryRoot); + expect(searchMocks.search).toHaveBeenCalledWith( + secondaryRoot, + 'feature', + 5, + undefined, + { profile: 'explore' } + ); + expect(payload.results[0]?.file).toContain('feature.ts'); }); - it('serializes concurrent initialization for the same known root', async () => { + it('explicit project overrides the active project and updates subsequent routing', 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'); - if (!handler) { - throw new Error('tools/call handler not registered'); - } + await callTool(handler, 8, 'search_codebase', { query: 'feature', project: secondaryRoot }); + await callTool(handler, 9, 'search_codebase', { query: 'feature', project: primaryRoot }); + await callTool(handler, 10, 'search_codebase', { query: 'feature' }); + await callTool(handler, 11, 'search_codebase', { query: 'feature' }); - const makeRequest = (id: number) => - handler({ - jsonrpc: '2.0', - id, - method: 'tools/call', - params: { - name: 'get_indexing_status', - arguments: {} - } - }); + expect(searchMocks.search.mock.calls[0]?.[0]).toBe(secondaryRoot); + expect(searchMocks.search.mock.calls[1]?.[0]).toBe(primaryRoot); + expect(searchMocks.search.mock.calls[2]?.[0]).toBe(primaryRoot); + expect(searchMocks.search.mock.calls[3]?.[0]).toBe(primaryRoot); + }); + + it('requires explicit project selection in ambiguous multi-root sessions without an active project', async () => { + const { server, refreshKnownRootsFromClient } = await import('../src/index.js'); + const typedServer = server as unknown as TestServer & { + listRoots: () => Promise<{ roots: Array<{ uri: string }> }>; + }; + const originalListRoots = typedServer.listRoots.bind(typedServer); + const handler = typedServer._requestHandlers.get('tools/call'); + if (!handler) throw new Error('tools/call handler not registered'); - await Promise.all([makeRequest(4), makeRequest(5)]); + typedServer.listRoots = vi.fn().mockResolvedValue({ + roots: [{ uri: pathToFileURL(primaryRoot).href }, { uri: pathToFileURL(secondaryRoot).href }] + }); - expect(watcherMocks.start).toHaveBeenCalledTimes(1); - expect(watcherMocks.start).toHaveBeenCalledWith( - expect.objectContaining({ rootPath: primaryRoot }) - ); + try { + await refreshKnownRootsFromClient(); + const response = await callTool(handler, 12, 'search_codebase', { query: 'feature' }); + const payload = parsePayload(response) as { + status: string; + errorCode: string; + reason: string; + nextAction: string; + availableProjects: Array<{ project: string; rootPath: string }>; + }; + + expect(response.isError).toBe(true); + expect(payload.status).toBe('selection_required'); + expect(payload.errorCode).toBe('selection_required'); + expect(payload.reason).toBe('multiple_projects_configured_no_active_context'); + expect(payload.nextAction).toBe('retry_with_project'); + expect(payload.availableProjects.length).toBeGreaterThanOrEqual(2); + expect(payload.availableProjects[0]?.project).toBeTruthy(); + } finally { + typedServer.listRoots = originalListRoots; + } }); - it('keeps resource reads pinned to known roots after ad-hoc project selection', async () => { + it('generic context resource follows the active project after 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( @@ -342,25 +456,136 @@ describe('multi-project routing', () => { 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 } - } + await callTool(requestHandler, 13, 'search_codebase', { + query: 'feature', + project: secondaryRoot }); const response = (await resourceHandler({ jsonrpc: '2.0', - id: 3, + id: 14, 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'); + expect(response.contents[0]?.text).not.toContain('Project selection required'); + }); + + it('builds a workspace overview for multiple configured roots before selection', async () => { + const { server, refreshKnownRootsFromClient } = await import('../src/index.js'); + const typedServer = server as unknown as TestServer & { + listRoots: () => Promise<{ roots: Array<{ uri: string }> }>; + }; + const originalListRoots = typedServer.listRoots.bind(typedServer); + const resourceHandler = typedServer._requestHandlers.get('resources/read'); + + if (!resourceHandler) { + throw new Error('resources/read handler not registered'); + } + + typedServer.listRoots = vi.fn().mockResolvedValue({ + roots: [{ uri: pathToFileURL(primaryRoot).href }, { uri: pathToFileURL(secondaryRoot).href }] + }); + + try { + await refreshKnownRootsFromClient(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const response = (await resourceHandler({ + jsonrpc: '2.0', + id: 15, + method: 'resources/read', + params: { uri: CONTEXT_RESOURCE_URI } + })) as ResourceReadResponse; + + expect(response.contents[0]?.text).toContain('# Codebase Workspace'); + expect(response.contents[0]?.text).toContain('client-announced roots as the workspace boundary'); + expect(response.contents[0]?.text).toContain('codebase://context/project/'); + expect(response.contents[0]?.text).toContain('retry tool calls with `project`'); + expect(response.contents[0]?.text).toContain('apps/dashboard'); + expect(response.contents[0]?.text).toMatch(/\[(idle|indexing|ready)\]/); + expect(watcherMocks.start).not.toHaveBeenCalledWith( + expect.objectContaining({ rootPath: secondaryRoot }) + ); + } finally { + typedServer.listRoots = originalListRoots; + } + }); + + it('supports project-scoped resource reads and monorepo subdirectory selection by relative path', async () => { + const { server, refreshKnownRootsFromClient } = 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 refreshKnownRootsFromClient(); + const selection = await callTool(requestHandler, 16, 'search_codebase', { + query: 'feature', + project: 'apps/dashboard' + }); + const payload = parsePayload(selection) as { + status: string; + project: { project: string; label: string; rootPath: string; relativePath?: string }; + }; + + expect(payload.status).toBe('success'); + expect(payload.project.rootPath).toBe(nestedProjectRoot); + expect(payload.project.label).toBe('apps/dashboard'); + expect(payload.project.relativePath).toBe('apps/dashboard'); + + const response = (await resourceHandler({ + jsonrpc: '2.0', + id: 17, + method: 'resources/read', + params: { uri: buildProjectContextResourceUri(payload.project.project) } + })) as ResourceReadResponse; + + expect(response.contents[0]?.uri).toBe( + buildProjectContextResourceUri(payload.project.project) + ); + expect(response.contents[0]?.text).toContain('# Codebase Intelligence'); + }); + + it('resolves a file path selector to the nearest discovered project boundary', async () => { + const filePath = path.join(nestedProjectRoot, 'src', 'auth', 'guard.ts'); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, 'export const guard = true;\n', 'utf-8'); + + const { server, refreshKnownRootsFromClient } = await import('../src/index.js'); + const typedServer = server as unknown as TestServer & { + listRoots: () => Promise<{ roots: Array<{ uri: string; name?: string }> }>; + }; + const originalListRoots = typedServer.listRoots.bind(typedServer); + const handler = typedServer._requestHandlers.get('tools/call'); + if (!handler) throw new Error('tools/call handler not registered'); + + typedServer.listRoots = vi.fn().mockResolvedValue({ + roots: [{ uri: pathToFileURL(primaryRoot).href, name: 'Primary' }] + }); + + try { + await refreshKnownRootsFromClient(); + const response = await callTool(handler, 18, 'search_codebase', { + query: 'feature', + project: filePath + }); + const payload = parsePayload(response) as { + status: string; + project: { rootPath: string; relativePath?: string }; + }; + + expect(payload.status).toBe('success'); + expect(payload.project.rootPath).toBe(nestedProjectRoot); + expect(payload.project.relativePath).toBe('apps/dashboard'); + } finally { + typedServer.listRoots = originalListRoots; + } }); }); diff --git a/tests/project-discovery.test.ts b/tests/project-discovery.test.ts new file mode 100644 index 0000000..c1a0aec --- /dev/null +++ b/tests/project-discovery.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { + discoverProjectsWithinRoot, + findNearestProjectBoundary, + isPathWithin +} from '../src/utils/project-discovery.js'; +import { + CODEBASE_CONTEXT_DIRNAME +} from '../src/constants/codebase-context.js'; + +describe('project-discovery', () => { + let tempRoot: string; + + beforeEach(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'project-discovery-')); + }); + + afterEach(async () => { + await fs.rm(tempRoot, { recursive: true, force: true }); + }); + + it('discovers trusted-root subprojects with generic markers', async () => { + const apiProject = path.join(tempRoot, 'services', 'api'); + const workerProject = path.join(tempRoot, 'packages', 'worker'); + const dotnetProject = path.join(tempRoot, 'apps', 'desktop'); + + await fs.mkdir(path.join(apiProject, '.git'), { recursive: true }); + await fs.mkdir(path.join(workerProject, '.codebase-context'), { recursive: true }); + await fs.mkdir(dotnetProject, { recursive: true }); + await fs.writeFile(path.join(dotnetProject, 'Desktop.csproj'), '', 'utf-8'); + + const discovered = await discoverProjectsWithinRoot(tempRoot); + + expect(discovered.map((entry) => entry.rootPath)).toEqual([ + dotnetProject, + workerProject, + apiProject + ]); + expect(discovered.map((entry) => entry.evidence)).toEqual([ + 'project_manifest', + 'existing_index', + 'repo_root' + ]); + }); + + it('treats existing .codebase-context directories as discoverable projects', async () => { + const initializedProject = path.join(tempRoot, 'apps', 'initialized'); + + await fs.mkdir(path.join(initializedProject, CODEBASE_CONTEXT_DIRNAME), { recursive: true }); + + const discovered = await discoverProjectsWithinRoot(tempRoot); + + expect(discovered).toEqual([ + { + rootPath: initializedProject, + evidence: 'existing_index' + } + ]); + }); + + it('ignores vendor/build directories during discovery', async () => { + const ignoredProject = path.join(tempRoot, 'node_modules', 'some-package'); + const realProject = path.join(tempRoot, 'apps', 'web'); + + await fs.mkdir(ignoredProject, { recursive: true }); + await fs.writeFile(path.join(ignoredProject, 'package.json'), JSON.stringify({ name: 'ignored' })); + + await fs.mkdir(realProject, { recursive: true }); + await fs.writeFile(path.join(realProject, 'package.json'), JSON.stringify({ name: 'web' })); + + const discovered = await discoverProjectsWithinRoot(tempRoot); + + expect(discovered.map((entry) => entry.rootPath)).toEqual([realProject]); + }); + + it('finds the nearest project boundary for a file path', async () => { + const projectRoot = path.join(tempRoot, 'apps', 'dashboard'); + const filePath = path.join(projectRoot, 'src', 'auth', 'guard.ts'); + + await fs.mkdir(path.join(projectRoot, 'src', 'auth'), { recursive: true }); + await fs.writeFile(path.join(projectRoot, 'pyproject.toml'), '[project]\nname = "dashboard"\n'); + await fs.writeFile(filePath, 'export const guard = true;\n'); + + const discovered = await findNearestProjectBoundary(filePath, tempRoot); + + expect(discovered).toEqual({ + rootPath: projectRoot, + evidence: 'project_manifest' + }); + }); + + it('does not escape the trusted root while resolving a boundary', async () => { + const outsideProject = await fs.mkdtemp(path.join(os.tmpdir(), 'outside-project-')); + try { + await fs.writeFile(path.join(outsideProject, 'go.mod'), 'module example.com/outside\n'); + const result = await findNearestProjectBoundary( + path.join(outsideProject, 'main.go'), + tempRoot + ); + expect(result).toBeUndefined(); + } finally { + await fs.rm(outsideProject, { recursive: true, force: true }); + } + }); + + it('detects path containment safely', () => { + expect(isPathWithin('/repo', '/repo/apps/web')).toBe(true); + expect(isPathWithin('/repo', '/repo')).toBe(true); + expect(isPathWithin('/repo', '/repo-other')).toBe(false); + }); +}); diff --git a/tests/resource-uri.test.ts b/tests/resource-uri.test.ts index ff9fbba..37757f6 100644 --- a/tests/resource-uri.test.ts +++ b/tests/resource-uri.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest'; import { + buildProjectContextResourceUri, CONTEXT_RESOURCE_URI, + getProjectPathFromContextResourceUri, isContextResourceUri, normalizeResourceUri } from '../src/resources/uri.js'; @@ -17,8 +19,17 @@ describe('resource URI normalization', () => { expect(isContextResourceUri(namespaced)).toBe(true); }); + it('round-trips project-scoped context URIs', () => { + const projectPath = '/repo/apps/dashboard'; + const uri = buildProjectContextResourceUri(projectPath); + expect(uri).toBe('codebase://context/project/%2Frepo%2Fapps%2Fdashboard'); + expect(getProjectPathFromContextResourceUri(uri)).toBe(projectPath); + expect(getProjectPathFromContextResourceUri(`host/${uri}`)).toBe(projectPath); + }); + it('rejects unknown URIs', () => { expect(isContextResourceUri('codebase://other')).toBe(false); expect(isContextResourceUri('other/codebase://other')).toBe(false); + expect(getProjectPathFromContextResourceUri('codebase://other')).toBeUndefined(); }); }); diff --git a/tests/tools/dispatch.test.ts b/tests/tools/dispatch.test.ts index e16574a..b180d3d 100644 --- a/tests/tools/dispatch.test.ts +++ b/tests/tools/dispatch.test.ts @@ -38,10 +38,13 @@ describe('Tool Dispatch', () => { }); }); - it('all tools expose project_directory for multi-root routing', () => { + it('all tools expose project and project_directory for multi-root routing', () => { TOOLS.forEach((tool) => { expect(tool.inputSchema.type).toBe('object'); expect(tool.inputSchema.properties).toMatchObject({ + project: expect.objectContaining({ + type: 'string' + }), project_directory: expect.objectContaining({ type: 'string' }) From 4ee1832a2099e58481a5f60805ef94496333416f Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sun, 8 Mar 2026 19:23:38 +0100 Subject: [PATCH 5/6] style: format multi-project routing files --- src/index.ts | 48 +++++++++++++++++++--------------- src/tools/index.ts | 7 ++--- src/utils/project-discovery.ts | 13 +++++---- 3 files changed, 35 insertions(+), 33 deletions(-) diff --git a/src/index.ts b/src/index.ts index ea09eb1..0e65e10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -142,7 +142,11 @@ function rememberProjectPath( const rootKey = normalizeRootKey(resolvedRootPath); const existingSource = projectSourcesByKey.get(rootKey); - if (!existingSource || source === 'root' || (source === 'subdirectory' && existingSource === 'ad_hoc')) { + if ( + !existingSource || + source === 'root' || + (source === 'subdirectory' && existingSource === 'ad_hoc') + ) { projectSourcesByKey.set(rootKey, source); } @@ -305,11 +309,7 @@ function setActiveProject(rootPath: string): void { function syncKnownRoots(rootEntries: Array<{ rootPath: string; label?: string }>): void { const nextRoots = new Map(); const normalizedRoots = - rootEntries.length > 0 - ? rootEntries - : primaryRootPath - ? [{ rootPath: primaryRootPath }] - : []; + rootEntries.length > 0 ? rootEntries : primaryRootPath ? [{ rootPath: primaryRootPath }] : []; for (const entry of normalizedRoots) { const resolvedRootPath = path.resolve(entry.rootPath); @@ -363,7 +363,9 @@ function parseProjectDirectory(value: unknown): string | undefined { const selector = parseProjectSelector(value); if (!selector) return undefined; - return selector.startsWith('file://') ? path.resolve(fileURLToPath(selector)) : path.resolve(selector); + return selector.startsWith('file://') + ? path.resolve(fileURLToPath(selector)) + : path.resolve(selector); } function getProjectSourceForResolvedPath(rootPath: string): ProjectDescriptor['source'] { @@ -390,7 +392,10 @@ async function resolveProjectFromAbsolutePath(resolvedPath: string): Promise = { const PROJECT_DIRECTORY_PROPERTY: Record = { type: 'string', - description: - 'Deprecated compatibility alias for older clients. Prefer project.' + description: 'Deprecated compatibility alias for older clients. Prefer project.' }; function withProjectSelector(definition: Tool): Tool { @@ -52,9 +51,7 @@ function withProjectSelector(definition: Tool): Tool { }; } -export const TOOLS: Tool[] = [d1, d2, d3, d4, d5, d6, d7, d8, d9, d10].map( - withProjectSelector -); +export const TOOLS: Tool[] = [d1, d2, d3, d4, d5, d6, d7, d8, d9, d10].map(withProjectSelector); export async function dispatchTool( name: string, diff --git a/src/utils/project-discovery.ts b/src/utils/project-discovery.ts index ad7367e..64e1156 100644 --- a/src/utils/project-discovery.ts +++ b/src/utils/project-discovery.ts @@ -171,9 +171,7 @@ export async function discoverProjectsWithinRoot( return; } - const fileNames = new Set( - entries.filter((entry) => entry.isFile()).map((entry) => entry.name) - ); + const fileNames = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name)); const directoryNames = new Set( entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) ); @@ -230,9 +228,7 @@ export async function findNearestProjectBoundary( entries = []; } - const fileNames = new Set( - entries.filter((entry) => entry.isFile()).map((entry) => entry.name) - ); + const fileNames = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name)); const directoryNames = new Set( entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) ); @@ -241,7 +237,10 @@ export async function findNearestProjectBoundary( return classification.candidate; } - if (resolvedTrustedRootPath && normalizePathKey(directoryPath) === normalizePathKey(resolvedTrustedRootPath)) { + if ( + resolvedTrustedRootPath && + normalizePathKey(directoryPath) === normalizePathKey(resolvedTrustedRootPath) + ) { return undefined; } From 27f17001aec9707329fbd24756540eecc4a7f19f Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sun, 8 Mar 2026 19:41:42 +0100 Subject: [PATCH 6/6] fix: tighten multi-project root handling --- README.md | 22 +++--- src/index.ts | 53 +++++++++----- tests/multi-project-routing.test.ts | 105 +++++++++++++++++++++++----- 3 files changed, 133 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index b334b89..7886f00 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,10 @@ Start with the default setup: ### Pick the right setup -| Situation | Recommended config | -| --- | --- | -| Default setup | Run `npx -y codebase-context` with no project path | -| Single repo setup | Append one project path or set `CODEBASE_ROOT` | +| Situation | Recommended config | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Default setup | Run `npx -y codebase-context` with no project path | +| Single repo setup | Append one project path or set `CODEBASE_ROOT` | | Multi-project call is still ambiguous | Retry with `project`, or keep separate server entries if your client cannot preserve project context | ### Recommended setup @@ -195,11 +195,11 @@ The MCP server can serve multiple projects in one session without requiring one Three cases matter: -| Case | What happens | -| --- | --- | -| One project | Routing is automatic | +| Case | What happens | +| ------------------------------------------------------------------ | ------------------------------------------------------------- | +| One project | Routing is automatic | | Multiple projects and the client provides enough workspace context | The server can route across those projects in one MCP session | -| Multiple projects and the target is still ambiguous | The server does not guess. Use `project` explicitly | +| Multiple projects and the target is still ambiguous | The server does not guess. Use `project` explicitly | Important rules: @@ -265,7 +265,7 @@ Then point your MCP client at the local build: "mcpServers": { "codebase-context": { "command": "node", - "args": ["C:/Users/bitaz/Repos/codebase-context/dist/index.js"] + "args": ["/dist/index.js"] } } } @@ -278,7 +278,7 @@ If the default setup is not enough for your client, use this instead: "mcpServers": { "codebase-context": { "command": "node", - "args": ["C:/Users/bitaz/Repos/codebase-context/dist/index.js", "C:/path/to/your/project"] + "args": ["/dist/index.js", "/path/to/your/project"] } } } @@ -514,7 +514,7 @@ Structured filters available: `framework`, `language`, `componentType`, `layer` | ------------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------- | | `EMBEDDING_PROVIDER` | `transformers` | `openai` (fast, cloud) or `transformers` (local, private) | | `OPENAI_API_KEY` | - | Required only if using `openai` provider | -| `CODEBASE_ROOT` | - | Optional bootstrap root for CLI and single-project MCP clients without roots | +| `CODEBASE_ROOT` | - | Optional bootstrap root for CLI and single-project MCP clients without roots | | `CODEBASE_CONTEXT_DEBUG` | - | Set to `1` for verbose logging | | `EMBEDDING_MODEL` | `Xenova/bge-small-en-v1.5` | Local embedding model override (e.g. `onnx-community/granite-embedding-small-english-r2-ONNX` for Granite) | diff --git a/src/index.ts b/src/index.ts index 0e65e10..fad29fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1200,19 +1200,42 @@ async function refreshDiscoveredProjectsForKnownRoots(): Promise { ); } +async function validateClientRootEntries( + rootEntries: Array<{ rootPath: string; label?: string }> +): Promise> { + const validatedRoots = await Promise.all( + rootEntries.map(async (entry) => { + try { + const stats = await fs.stat(entry.rootPath); + if (!stats.isDirectory()) { + return undefined; + } + + return entry; + } catch { + return undefined; + } + }) + ); + + return validatedRoots.filter((entry): entry is { rootPath: string; label?: string } => !!entry); +} + async function refreshKnownRootsFromClient(): Promise { try { const { roots } = await server.listRoots(); - const fileRoots = roots - .map((root) => ({ - uri: root.uri, - label: typeof root.name === 'string' && root.name.trim() ? root.name.trim() : undefined - })) - .filter((root) => root.uri.startsWith('file://')) - .map((root) => ({ - rootPath: fileURLToPath(root.uri), - label: root.label - })); + const fileRoots = await validateClientRootEntries( + roots + .map((root) => ({ + uri: root.uri, + label: typeof root.name === 'string' && root.name.trim() ? root.name.trim() : undefined + })) + .filter((root) => root.uri.startsWith('file://')) + .map((root) => ({ + rootPath: fileURLToPath(root.uri), + label: root.label + })) + ); clientRootsEnabled = fileRoots.length > 0; syncKnownRoots(fileRoots); @@ -1222,14 +1245,6 @@ async function refreshKnownRootsFromClient(): Promise { } await refreshDiscoveredProjectsForKnownRoots(); - - await Promise.all( - getKnownRootPaths().map((rootPath) => - initProject(rootPath, watcherDebounceMs, { enableWatcher: false }).catch(() => { - /* best-effort prewarm */ - }) - ) - ); } async function resolveExplicitProjectSelection(selection: { @@ -1605,7 +1620,7 @@ async function main() { await refreshKnownRootsFromClient(); - // Keep the current single-project auto-select behavior while roots are pre-warmed in background. + // Keep the current single-project auto-select behavior when exactly one startup project is known. const startupRoots = getKnownRootPaths(); if (startupRoots.length === 1) { await initProject(startupRoots[0], watcherDebounceMs, { enableWatcher: true }); diff --git a/tests/multi-project-routing.test.ts b/tests/multi-project-routing.test.ts index bb01d59..340cc2b 100644 --- a/tests/multi-project-routing.test.ts +++ b/tests/multi-project-routing.test.ts @@ -11,10 +11,7 @@ import { KEYWORD_INDEX_FILENAME, VECTOR_DB_DIRNAME } from '../src/constants/codebase-context.js'; -import { - CONTEXT_RESOURCE_URI, - buildProjectContextResourceUri -} from '../src/resources/uri.js'; +import { CONTEXT_RESOURCE_URI, buildProjectContextResourceUri } from '../src/resources/uri.js'; interface SearchResultRow { summary: string; @@ -47,6 +44,10 @@ const searchMocks = vi.hoisted(() => ({ search: vi.fn() })); +const indexerMocks = vi.hoisted(() => ({ + index: vi.fn() +})); + const watcherMocks = vi.hoisted(() => ({ start: vi.fn() })); @@ -72,6 +73,7 @@ vi.mock('../src/core/indexer.js', () => { } async index() { + indexerMocks.index(); return { totalFiles: 0, indexedFiles: 0, @@ -172,6 +174,7 @@ describe('multi-project routing', () => { beforeEach(async () => { vi.resetModules(); searchMocks.search.mockReset(); + indexerMocks.index.mockReset(); watcherMocks.start.mockReset(); originalArgv = [...process.argv]; @@ -258,6 +261,75 @@ describe('multi-project routing', () => { } }); + it('ignores invalid client roots instead of registering or creating them', async () => { + delete process.env.CODEBASE_ROOT; + delete process.argv[2]; + + const missingRoot = path.join(os.tmpdir(), `cc-missing-root-${Date.now()}`); + const { server, refreshKnownRootsFromClient } = await import('../src/index.js'); + const typedServer = server as unknown as TestServer & { + listRoots: () => Promise<{ roots: Array<{ uri: string; name?: string }> }>; + }; + const originalListRoots = typedServer.listRoots.bind(typedServer); + const toolHandler = typedServer._requestHandlers.get('tools/call'); + const resourceHandler = typedServer._requestHandlers.get('resources/read'); + if (!toolHandler || !resourceHandler) throw new Error('required handlers not registered'); + + typedServer.listRoots = vi.fn().mockResolvedValue({ + roots: [{ uri: pathToFileURL(missingRoot).href, name: 'Missing' }] + }); + + try { + await refreshKnownRootsFromClient(); + + const response = await callTool(toolHandler, 90, 'search_codebase', { query: 'feature' }); + const payload = parsePayload(response) as { + status: string; + errorCode: string; + }; + + expect(response.isError).toBe(true); + expect(payload.status).toBe('selection_required'); + expect(payload.errorCode).toBe('selection_required'); + await expect(fs.stat(missingRoot)).rejects.toThrow(); + + const resourceResponse = (await resourceHandler({ + jsonrpc: '2.0', + id: 91, + method: 'resources/read', + params: { uri: CONTEXT_RESOURCE_URI } + })) as ResourceReadResponse; + + expect(resourceResponse.contents[0]?.text).not.toContain(missingRoot); + } finally { + typedServer.listRoots = originalListRoots; + } + }); + + it('does not eagerly index every announced root during background refresh', async () => { + delete process.env.CODEBASE_ROOT; + delete process.argv[2]; + + const unindexedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-unindexed-root-')); + const { server, refreshKnownRootsFromClient } = await import('../src/index.js'); + const typedServer = server as unknown as TestServer & { + listRoots: () => Promise<{ roots: Array<{ uri: string; name?: string }> }>; + }; + const originalListRoots = typedServer.listRoots.bind(typedServer); + + typedServer.listRoots = vi.fn().mockResolvedValue({ + roots: [{ uri: pathToFileURL(unindexedRoot).href, name: 'Unindexed' }] + }); + + try { + await refreshKnownRootsFromClient(); + expect(indexerMocks.index).not.toHaveBeenCalled(); + } finally { + typedServer.listRoots = originalListRoots; + await fs.rm(unindexedRoot, { recursive: true, force: true }); + } + }); + it('supports explicit project routing without bootstrap roots when the client does not expose roots', async () => { delete process.env.CODEBASE_ROOT; delete process.argv[2]; @@ -337,7 +409,9 @@ describe('multi-project routing', () => { expect(payload.status).toBe('success'); expect(payload.project.rootPath).toBe(primaryRoot); expect(payload.project.project).toBe(primaryRoot); - expect(watcherMocks.start).toHaveBeenCalledWith(expect.objectContaining({ rootPath: primaryRoot })); + expect(watcherMocks.start).toHaveBeenCalledWith( + expect.objectContaining({ rootPath: primaryRoot }) + ); }); it('explicit project starts a watcher and makes that project active', async () => { @@ -371,7 +445,8 @@ describe('multi-project routing', () => { query: 'feature', project: secondaryRoot }); - const selectedProject = (parsePayload(selection) as { project: { project: string } }).project.project; + const selectedProject = (parsePayload(selection) as { project: { project: string } }).project + .project; const response = await callTool(handler, 7, 'search_codebase', { query: 'feature' }); const payload = parsePayload(response) as { @@ -383,13 +458,9 @@ describe('multi-project routing', () => { expect(payload.status).toBe('success'); expect(payload.project.project).toBe(selectedProject); expect(payload.project.rootPath).toBe(secondaryRoot); - expect(searchMocks.search).toHaveBeenCalledWith( - secondaryRoot, - 'feature', - 5, - undefined, - { profile: 'explore' } - ); + expect(searchMocks.search).toHaveBeenCalledWith(secondaryRoot, 'feature', 5, undefined, { + profile: 'explore' + }); expect(payload.results[0]?.file).toContain('feature.ts'); }); @@ -501,7 +572,9 @@ describe('multi-project routing', () => { })) as ResourceReadResponse; expect(response.contents[0]?.text).toContain('# Codebase Workspace'); - expect(response.contents[0]?.text).toContain('client-announced roots as the workspace boundary'); + expect(response.contents[0]?.text).toContain( + 'client-announced roots as the workspace boundary' + ); expect(response.contents[0]?.text).toContain('codebase://context/project/'); expect(response.contents[0]?.text).toContain('retry tool calls with `project`'); expect(response.contents[0]?.text).toContain('apps/dashboard'); @@ -547,9 +620,7 @@ describe('multi-project routing', () => { params: { uri: buildProjectContextResourceUri(payload.project.project) } })) as ResourceReadResponse; - expect(response.contents[0]?.uri).toBe( - buildProjectContextResourceUri(payload.project.project) - ); + expect(response.contents[0]?.uri).toBe(buildProjectContextResourceUri(payload.project.project)); expect(response.contents[0]?.text).toContain('# Codebase Intelligence'); });