diff --git a/CHANGELOG.md b/CHANGELOG.md index 1abb7a2..ecc2cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,8 @@ ### 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 +- simplify the setup story around a roots-first contract: roots-capable multi-project sessions, single-project fallback, and explicit `project` retries +- clarify that issue #63 fixed the architecture and workspace-aware workflow, but issue #2 is still only partially solved when the client does not provide roots or active-project context - remove the repo-local `init` / marker-file story from the public setup guidance ## [1.9.0](https://github.com/PatrickSys/codebase-context/compare/v1.8.2...v1.9.0) (2026-03-19) diff --git a/docs/capabilities.md b/docs/capabilities.md index 60796fb..547472a 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -21,6 +21,7 @@ Per-project config overrides supported today: - `projects[].analyzerHints.analyzer`: prefers a registered analyzer by name for that project and falls back safely when the name is missing or invalid - `projects[].analyzerHints.extensions`: adds project-local source extensions for indexing and auto-refresh watching without changing defaults for other projects + Copy-pasteable client config templates are shipped in the package: - `templates/mcp/stdio/.mcp.json` — stdio setup for `.mcp.json`-style clients @@ -104,6 +105,7 @@ Behavior matrix: Rules: - If the client provides workspace context, that becomes the trusted workspace boundary for the session. In practice this usually comes from MCP roots. +- Treat seamless multi-project routing as evidence-backed only for roots-capable hosts. Without roots, explicit fallback is still required. - 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. diff --git a/docs/client-setup.md b/docs/client-setup.md index 4acdb02..23304e6 100644 --- a/docs/client-setup.md +++ b/docs/client-setup.md @@ -18,6 +18,18 @@ npx -y codebase-context --http --port 4000 Copy-pasteable templates: [`templates/mcp/stdio/.mcp.json`](../templates/mcp/stdio/.mcp.json) and [`templates/mcp/http/.mcp.json`](../templates/mcp/http/.mcp.json). +## Project routing contract + +Automatic multi-project routing is evidence-backed only when the MCP host announces workspace roots. Treat that as the primary path. + +If the host does not send roots, or still cannot tell which project is active, use one of the explicit fallbacks instead: + +- start the server with a single bootstrap path +- set `CODEBASE_ROOT` +- retry tool calls with `project` + +If multiple projects are available and no active project can be inferred safely, the server returns `selection_required` instead of guessing. + ## Claude Code ```bash @@ -197,9 +209,9 @@ Check these three flows: 1. **Single project** — call `search_codebase` or `metadata`. Routing is automatic. -2. **Multiple projects, one server entry** — open two repos or a monorepo. Call `codebase://context`. Expected: workspace overview, then automatic routing once a project is active. +2. **Multiple projects on a roots-capable host** — open two repos or a monorepo. Call `codebase://context`. Expected: workspace overview, then automatic routing once a project is active. -3. **Ambiguous selection** — start without a bootstrap path, call `search_codebase`. Expected: `selection_required`. Retry with `project` set to `apps/dashboard` or `/repos/customer-portal`. +3. **Ambiguous or no-roots selection** — start without a bootstrap path, call `search_codebase`. Expected: `selection_required`. Retry with `project` set to `apps/dashboard` or `/repos/customer-portal`. For monorepos, test all three selector forms: diff --git a/scripts/run-vitest.mjs b/scripts/run-vitest.mjs index 32e164e..c529a5c 100644 --- a/scripts/run-vitest.mjs +++ b/scripts/run-vitest.mjs @@ -1,3 +1,5 @@ +// Spawns vitest via process.execPath to avoid bin-resolution failures when +// Node is invoked directly (e.g. `node scripts/run-vitest.mjs`) without pnpm. import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; diff --git a/src/index.ts b/src/index.ts index a520ed2..5f1005c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1667,10 +1667,8 @@ async function applyServerConfig( configRoots.set(rootKey, { rootPath: proj.root }); registerKnownRoot(proj.root); const runtimeOverrides = buildProjectRuntimeOverrides(proj); - if (Object.keys(runtimeOverrides).length > 0) { - const project = getOrCreateProject(proj.root); - project.runtimeOverrides = runtimeOverrides; - } + const project = getOrCreateProject(proj.root); + project.runtimeOverrides = runtimeOverrides; } catch { console.error(`[config] Skipping inaccessible project root: ${proj.root}`); } diff --git a/src/utils/language-detection.ts b/src/utils/language-detection.ts index 7999d84..2cc8bac 100644 --- a/src/utils/language-detection.ts +++ b/src/utils/language-detection.ts @@ -176,6 +176,9 @@ function buildCodeExtensions(extraExtensions?: Iterable): Set { return merged; } +// Cached default set — built once at module load, reused by callers that pass no extra extensions. +const defaultCodeExtensions: ReadonlySet = buildCodeExtensions(); + /** * Detect language from file path */ @@ -193,7 +196,11 @@ export function isCodeFile( ): boolean { const ext = path.extname(filePath).toLowerCase(); const supportedExtensions = - extensions instanceof Set ? extensions : buildCodeExtensions(extensions); + extensions instanceof Set + ? extensions + : extensions + ? buildCodeExtensions(extensions) + : defaultCodeExtensions; return supportedExtensions.has(ext); } diff --git a/tests/mcp-client-templates.test.ts b/tests/mcp-client-templates.test.ts index 1d40405..03cf820 100644 --- a/tests/mcp-client-templates.test.ts +++ b/tests/mcp-client-templates.test.ts @@ -133,4 +133,27 @@ describe('docs/capabilities.md transport documentation', () => { expect(caps).toContain('Codex'); expect(caps).toContain('Windsurf'); }); + + it('states the roots-first routing fallback explicitly', () => { + expect(caps).toContain('roots-capable hosts'); + expect(caps).toContain('explicit fallback is still required'); + }); +}); + +describe('docs/client-setup.md multi-project guidance', () => { + const clientSetup = readText('docs/client-setup.md'); + + it('documents the project routing contract', () => { + expect(clientSetup).toContain( + 'Automatic multi-project routing is evidence-backed only when the MCP host announces workspace roots.' + ); + expect(clientSetup).toContain( + 'the server returns `selection_required` instead of guessing' + ); + }); + + it('keeps the three verification flows aligned with the roots-first contract', () => { + expect(clientSetup).toContain('Multiple projects on a roots-capable host'); + expect(clientSetup).toContain('Ambiguous or no-roots selection'); + }); }); diff --git a/tests/multi-project-routing.test.ts b/tests/multi-project-routing.test.ts index e091be9..3ee1d68 100644 --- a/tests/multi-project-routing.test.ts +++ b/tests/multi-project-routing.test.ts @@ -362,6 +362,45 @@ describe('multi-project routing', () => { } }); + it('triggers a background rebuild for a corrupted explicit project without falling back to cwd', async () => { + delete process.env.CODEBASE_ROOT; + delete process.argv[2]; + + await fs.rm(path.join(secondaryRoot, CODEBASE_CONTEXT_DIRNAME, INDEX_META_FILENAME), { + force: true + }); + + 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, 21, 'search_codebase', { + query: 'feature', + project: secondaryRoot + }); + const payload = parsePayload(response) as { + status: string; + message: string; + index?: { action?: string; reason?: string }; + }; + + expect(payload.status).toBe('indexing'); + expect(payload.message).toContain('retry shortly'); + expect(payload.index?.action).toBe('rebuild-started'); + expect(String(payload.index?.reason || '')).toContain('Index meta'); + } 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];