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..7886f00 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": ["/dist/index.js"] + } + } +} +``` + +If the default setup is not enough for your client, use this instead: + +```json +{ + "mcpServers": { + "codebase-context": { + "command": "node", + "args": ["/dist/index.js", "/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..fad29fc 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,377 @@ 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 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; +} + +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 syncKnownRoots(rootPaths: string[]): void { - const nextRoots = new Map(); - const normalizedRoots = rootPaths.length > 0 ? rootPaths : [primaryRootPath]; +function getProjectIndexStatus(rootPath: string): ProjectDescriptor['indexStatus'] { + return getProject(rootPath)?.indexState.status ?? 'idle'; +} - for (const rootPath of normalizedRoots) { - const resolvedRootPath = path.resolve(rootPath); - nextRoots.set(normalizeRootKey(resolvedRootPath), resolvedRootPath); +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); } - for (const [rootKey, existingRootPath] of knownRoots.entries()) { + 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; + } + + 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 +486,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() + }; +} + +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() }; } -registerKnownRoot(primaryRootPath); +if (primaryRootPath) { + registerKnownRoot(primaryRootPath); +} export const INDEX_CONSUMING_TOOL_NAMES = [ 'search_codebase', @@ -230,25 +578,139 @@ 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}` - ); + return buildProjectSelectionError('unknown_project', `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 +802,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 +989,73 @@ 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 +1186,162 @@ 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 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) => root.uri) - .filter((uri) => uri.startsWith('file://')) - .map((uri) => fileURLToPath(uri)); + 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); } catch { clientRootsEnabled = false; - syncKnownRoots([primaryRootPath]); + syncKnownRoots(primaryRootPath ? [{ rootPath: primaryRootPath }] : []); } + + await refreshDiscoveredProjectsForKnownRoots(); } -async function resolveProjectForTool(args: Record): Promise { - const requestedProjectDirectory = parseProjectDirectory(args.project_directory); - const availableRoots = getKnownRootPaths(); +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; + } - if (requestedProjectDirectory) { - const requestedRootKey = normalizeRootKey(requestedProjectDirectory); - const knownRootPath = knownRoots.get(requestedRootKey); + await initProject(resolution.project.rootPath, watcherDebounceMs, { enableWatcher: true }); + setActiveProject(resolution.project.rootPath); + return resolution; + } - if (clientRootsEnabled && availableRoots.length > 0 && !knownRootPath) { - return { - ok: false, - response: buildProjectSelectionError( - 'unknown_project', - 'Requested project is not part of the active MCP roots.' - ) - }; - } + return { + ok: false, + response: buildProjectSelectionError('selection_required', 'No project selector was provided.') + }; +} - const rootPath = knownRootPath ?? requestedProjectDirectory; - const invalidProjectResponse = await validateProjectDirectory(rootPath); - if (invalidProjectResponse) { - return { ok: false, response: invalidProjectResponse }; - } +async function resolveProjectForTool(args: Record): Promise { + const requestedProject = parseProjectSelector(args.project); + const requestedProjectDirectory = parseProjectSelector(args.project_directory); - const project = getOrCreateProject(rootPath); - await initProject(project.rootPath, watcherDebounceMs, { - enableWatcher: knownRootPath !== undefined + 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 +1354,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 +1412,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 +1506,7 @@ async function ensureProjectInitialized(project: ProjectState): Promise { function ensureProjectWatcher(project: ProjectState, debounceMs: number): void { if (project.stopWatcher) { + touchProject(project.rootPath); return; } @@ -910,6 +1533,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 +1560,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 +1571,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 +1604,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 +1620,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 when exactly one startup project is known. const startupRoots = getKnownRootPaths(); if (startupRoots.length === 1) { await initProject(startupRoots[0], watcherDebounceMs, { enableWatcher: true }); + setActiveProject(startupRoots[0]); } // Subscribe to root changes @@ -1003,7 +1655,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..6cd8b82 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -15,36 +15,43 @@ import { definition as d10, handle as h10 } from './get-memory.js'; import type { ToolContext, ToolResponse } from './types.js'; -const PROJECT_DIRECTORY_PROPERTY: Record = { +const PROJECT_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.' + '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: '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..64e1156 --- /dev/null +++ b/src/utils/project-discovery.ts @@ -0,0 +1,254 @@ +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..340cc2b 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,7 @@ 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; @@ -43,6 +44,10 @@ const searchMocks = vi.hoisted(() => ({ search: vi.fn() })); +const indexerMocks = vi.hoisted(() => ({ + index: vi.fn() +})); + const watcherMocks = vi.hoisted(() => ({ start: vi.fn() })); @@ -68,6 +73,7 @@ vi.mock('../src/core/indexer.js', () => { } async index() { + indexerMocks.index(); return { totalFiles: 0, indexedFiles: 0, @@ -140,15 +146,35 @@ 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; beforeEach(async () => { vi.resetModules(); searchMocks.search.mockReset(); + indexerMocks.index.mockReset(); watcherMocks.start.mockReset(); originalArgv = [...process.argv]; @@ -156,11 +182,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 +229,294 @@ 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('ignores invalid client roots instead of registering or creating them', async () => { + delete process.env.CODEBASE_ROOT; + delete process.argv[2]; - const payload = JSON.parse(response.content[0].text) as { + 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]; + + 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; + } + }); + + 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); + }); - await Promise.all([makeRequest(4), makeRequest(5)]); + 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'); - expect(watcherMocks.start).toHaveBeenCalledTimes(1); - expect(watcherMocks.start).toHaveBeenCalledWith( - expect.objectContaining({ rootPath: primaryRoot }) - ); + typedServer.listRoots = vi.fn().mockResolvedValue({ + roots: [{ uri: pathToFileURL(primaryRoot).href }, { uri: pathToFileURL(secondaryRoot).href }] + }); + + 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 +527,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' })