Skip to content

Commit 27f1700

Browse files
committed
fix: tighten multi-project root handling
1 parent 4ee1832 commit 27f1700

3 files changed

Lines changed: 133 additions & 47 deletions

File tree

README.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,10 @@ Start with the default setup:
7272

7373
### Pick the right setup
7474

75-
| Situation | Recommended config |
76-
| --- | --- |
77-
| Default setup | Run `npx -y codebase-context` with no project path |
78-
| Single repo setup | Append one project path or set `CODEBASE_ROOT` |
75+
| Situation | Recommended config |
76+
| ------------------------------------- | ---------------------------------------------------------------------------------------------------- |
77+
| Default setup | Run `npx -y codebase-context` with no project path |
78+
| Single repo setup | Append one project path or set `CODEBASE_ROOT` |
7979
| Multi-project call is still ambiguous | Retry with `project`, or keep separate server entries if your client cannot preserve project context |
8080

8181
### Recommended setup
@@ -195,11 +195,11 @@ The MCP server can serve multiple projects in one session without requiring one
195195

196196
Three cases matter:
197197

198-
| Case | What happens |
199-
| --- | --- |
200-
| One project | Routing is automatic |
198+
| Case | What happens |
199+
| ------------------------------------------------------------------ | ------------------------------------------------------------- |
200+
| One project | Routing is automatic |
201201
| Multiple projects and the client provides enough workspace context | The server can route across those projects in one MCP session |
202-
| Multiple projects and the target is still ambiguous | The server does not guess. Use `project` explicitly |
202+
| Multiple projects and the target is still ambiguous | The server does not guess. Use `project` explicitly |
203203

204204
Important rules:
205205

@@ -265,7 +265,7 @@ Then point your MCP client at the local build:
265265
"mcpServers": {
266266
"codebase-context": {
267267
"command": "node",
268-
"args": ["C:/Users/bitaz/Repos/codebase-context/dist/index.js"]
268+
"args": ["<path-to-local-build>/dist/index.js"]
269269
}
270270
}
271271
}
@@ -278,7 +278,7 @@ If the default setup is not enough for your client, use this instead:
278278
"mcpServers": {
279279
"codebase-context": {
280280
"command": "node",
281-
"args": ["C:/Users/bitaz/Repos/codebase-context/dist/index.js", "C:/path/to/your/project"]
281+
"args": ["<path-to-local-build>/dist/index.js", "/path/to/your/project"]
282282
}
283283
}
284284
}
@@ -514,7 +514,7 @@ Structured filters available: `framework`, `language`, `componentType`, `layer`
514514
| ------------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------- |
515515
| `EMBEDDING_PROVIDER` | `transformers` | `openai` (fast, cloud) or `transformers` (local, private) |
516516
| `OPENAI_API_KEY` | - | Required only if using `openai` provider |
517-
| `CODEBASE_ROOT` | - | Optional bootstrap root for CLI and single-project MCP clients without roots |
517+
| `CODEBASE_ROOT` | - | Optional bootstrap root for CLI and single-project MCP clients without roots |
518518
| `CODEBASE_CONTEXT_DEBUG` | - | Set to `1` for verbose logging |
519519
| `EMBEDDING_MODEL` | `Xenova/bge-small-en-v1.5` | Local embedding model override (e.g. `onnx-community/granite-embedding-small-english-r2-ONNX` for Granite) |
520520

src/index.ts

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,19 +1200,42 @@ async function refreshDiscoveredProjectsForKnownRoots(): Promise<void> {
12001200
);
12011201
}
12021202

1203+
async function validateClientRootEntries(
1204+
rootEntries: Array<{ rootPath: string; label?: string }>
1205+
): Promise<Array<{ rootPath: string; label?: string }>> {
1206+
const validatedRoots = await Promise.all(
1207+
rootEntries.map(async (entry) => {
1208+
try {
1209+
const stats = await fs.stat(entry.rootPath);
1210+
if (!stats.isDirectory()) {
1211+
return undefined;
1212+
}
1213+
1214+
return entry;
1215+
} catch {
1216+
return undefined;
1217+
}
1218+
})
1219+
);
1220+
1221+
return validatedRoots.filter((entry): entry is { rootPath: string; label?: string } => !!entry);
1222+
}
1223+
12031224
async function refreshKnownRootsFromClient(): Promise<void> {
12041225
try {
12051226
const { roots } = await server.listRoots();
1206-
const fileRoots = roots
1207-
.map((root) => ({
1208-
uri: root.uri,
1209-
label: typeof root.name === 'string' && root.name.trim() ? root.name.trim() : undefined
1210-
}))
1211-
.filter((root) => root.uri.startsWith('file://'))
1212-
.map((root) => ({
1213-
rootPath: fileURLToPath(root.uri),
1214-
label: root.label
1215-
}));
1227+
const fileRoots = await validateClientRootEntries(
1228+
roots
1229+
.map((root) => ({
1230+
uri: root.uri,
1231+
label: typeof root.name === 'string' && root.name.trim() ? root.name.trim() : undefined
1232+
}))
1233+
.filter((root) => root.uri.startsWith('file://'))
1234+
.map((root) => ({
1235+
rootPath: fileURLToPath(root.uri),
1236+
label: root.label
1237+
}))
1238+
);
12161239

12171240
clientRootsEnabled = fileRoots.length > 0;
12181241
syncKnownRoots(fileRoots);
@@ -1222,14 +1245,6 @@ async function refreshKnownRootsFromClient(): Promise<void> {
12221245
}
12231246

12241247
await refreshDiscoveredProjectsForKnownRoots();
1225-
1226-
await Promise.all(
1227-
getKnownRootPaths().map((rootPath) =>
1228-
initProject(rootPath, watcherDebounceMs, { enableWatcher: false }).catch(() => {
1229-
/* best-effort prewarm */
1230-
})
1231-
)
1232-
);
12331248
}
12341249

12351250
async function resolveExplicitProjectSelection(selection: {
@@ -1605,7 +1620,7 @@ async function main() {
16051620

16061621
await refreshKnownRootsFromClient();
16071622

1608-
// Keep the current single-project auto-select behavior while roots are pre-warmed in background.
1623+
// Keep the current single-project auto-select behavior when exactly one startup project is known.
16091624
const startupRoots = getKnownRootPaths();
16101625
if (startupRoots.length === 1) {
16111626
await initProject(startupRoots[0], watcherDebounceMs, { enableWatcher: true });

tests/multi-project-routing.test.ts

Lines changed: 88 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,7 @@ import {
1111
KEYWORD_INDEX_FILENAME,
1212
VECTOR_DB_DIRNAME
1313
} from '../src/constants/codebase-context.js';
14-
import {
15-
CONTEXT_RESOURCE_URI,
16-
buildProjectContextResourceUri
17-
} from '../src/resources/uri.js';
14+
import { CONTEXT_RESOURCE_URI, buildProjectContextResourceUri } from '../src/resources/uri.js';
1815

1916
interface SearchResultRow {
2017
summary: string;
@@ -47,6 +44,10 @@ const searchMocks = vi.hoisted(() => ({
4744
search: vi.fn()
4845
}));
4946

47+
const indexerMocks = vi.hoisted(() => ({
48+
index: vi.fn()
49+
}));
50+
5051
const watcherMocks = vi.hoisted(() => ({
5152
start: vi.fn()
5253
}));
@@ -72,6 +73,7 @@ vi.mock('../src/core/indexer.js', () => {
7273
}
7374

7475
async index() {
76+
indexerMocks.index();
7577
return {
7678
totalFiles: 0,
7779
indexedFiles: 0,
@@ -172,6 +174,7 @@ describe('multi-project routing', () => {
172174
beforeEach(async () => {
173175
vi.resetModules();
174176
searchMocks.search.mockReset();
177+
indexerMocks.index.mockReset();
175178
watcherMocks.start.mockReset();
176179

177180
originalArgv = [...process.argv];
@@ -258,6 +261,75 @@ describe('multi-project routing', () => {
258261
}
259262
});
260263

264+
it('ignores invalid client roots instead of registering or creating them', async () => {
265+
delete process.env.CODEBASE_ROOT;
266+
delete process.argv[2];
267+
268+
const missingRoot = path.join(os.tmpdir(), `cc-missing-root-${Date.now()}`);
269+
const { server, refreshKnownRootsFromClient } = await import('../src/index.js');
270+
const typedServer = server as unknown as TestServer & {
271+
listRoots: () => Promise<{ roots: Array<{ uri: string; name?: string }> }>;
272+
};
273+
const originalListRoots = typedServer.listRoots.bind(typedServer);
274+
const toolHandler = typedServer._requestHandlers.get('tools/call');
275+
const resourceHandler = typedServer._requestHandlers.get('resources/read');
276+
if (!toolHandler || !resourceHandler) throw new Error('required handlers not registered');
277+
278+
typedServer.listRoots = vi.fn().mockResolvedValue({
279+
roots: [{ uri: pathToFileURL(missingRoot).href, name: 'Missing' }]
280+
});
281+
282+
try {
283+
await refreshKnownRootsFromClient();
284+
285+
const response = await callTool(toolHandler, 90, 'search_codebase', { query: 'feature' });
286+
const payload = parsePayload(response) as {
287+
status: string;
288+
errorCode: string;
289+
};
290+
291+
expect(response.isError).toBe(true);
292+
expect(payload.status).toBe('selection_required');
293+
expect(payload.errorCode).toBe('selection_required');
294+
await expect(fs.stat(missingRoot)).rejects.toThrow();
295+
296+
const resourceResponse = (await resourceHandler({
297+
jsonrpc: '2.0',
298+
id: 91,
299+
method: 'resources/read',
300+
params: { uri: CONTEXT_RESOURCE_URI }
301+
})) as ResourceReadResponse;
302+
303+
expect(resourceResponse.contents[0]?.text).not.toContain(missingRoot);
304+
} finally {
305+
typedServer.listRoots = originalListRoots;
306+
}
307+
});
308+
309+
it('does not eagerly index every announced root during background refresh', async () => {
310+
delete process.env.CODEBASE_ROOT;
311+
delete process.argv[2];
312+
313+
const unindexedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-unindexed-root-'));
314+
const { server, refreshKnownRootsFromClient } = await import('../src/index.js');
315+
const typedServer = server as unknown as TestServer & {
316+
listRoots: () => Promise<{ roots: Array<{ uri: string; name?: string }> }>;
317+
};
318+
const originalListRoots = typedServer.listRoots.bind(typedServer);
319+
320+
typedServer.listRoots = vi.fn().mockResolvedValue({
321+
roots: [{ uri: pathToFileURL(unindexedRoot).href, name: 'Unindexed' }]
322+
});
323+
324+
try {
325+
await refreshKnownRootsFromClient();
326+
expect(indexerMocks.index).not.toHaveBeenCalled();
327+
} finally {
328+
typedServer.listRoots = originalListRoots;
329+
await fs.rm(unindexedRoot, { recursive: true, force: true });
330+
}
331+
});
332+
261333
it('supports explicit project routing without bootstrap roots when the client does not expose roots', async () => {
262334
delete process.env.CODEBASE_ROOT;
263335
delete process.argv[2];
@@ -337,7 +409,9 @@ describe('multi-project routing', () => {
337409
expect(payload.status).toBe('success');
338410
expect(payload.project.rootPath).toBe(primaryRoot);
339411
expect(payload.project.project).toBe(primaryRoot);
340-
expect(watcherMocks.start).toHaveBeenCalledWith(expect.objectContaining({ rootPath: primaryRoot }));
412+
expect(watcherMocks.start).toHaveBeenCalledWith(
413+
expect.objectContaining({ rootPath: primaryRoot })
414+
);
341415
});
342416

343417
it('explicit project starts a watcher and makes that project active', async () => {
@@ -371,7 +445,8 @@ describe('multi-project routing', () => {
371445
query: 'feature',
372446
project: secondaryRoot
373447
});
374-
const selectedProject = (parsePayload(selection) as { project: { project: string } }).project.project;
448+
const selectedProject = (parsePayload(selection) as { project: { project: string } }).project
449+
.project;
375450

376451
const response = await callTool(handler, 7, 'search_codebase', { query: 'feature' });
377452
const payload = parsePayload(response) as {
@@ -383,13 +458,9 @@ describe('multi-project routing', () => {
383458
expect(payload.status).toBe('success');
384459
expect(payload.project.project).toBe(selectedProject);
385460
expect(payload.project.rootPath).toBe(secondaryRoot);
386-
expect(searchMocks.search).toHaveBeenCalledWith(
387-
secondaryRoot,
388-
'feature',
389-
5,
390-
undefined,
391-
{ profile: 'explore' }
392-
);
461+
expect(searchMocks.search).toHaveBeenCalledWith(secondaryRoot, 'feature', 5, undefined, {
462+
profile: 'explore'
463+
});
393464
expect(payload.results[0]?.file).toContain('feature.ts');
394465
});
395466

@@ -501,7 +572,9 @@ describe('multi-project routing', () => {
501572
})) as ResourceReadResponse;
502573

503574
expect(response.contents[0]?.text).toContain('# Codebase Workspace');
504-
expect(response.contents[0]?.text).toContain('client-announced roots as the workspace boundary');
575+
expect(response.contents[0]?.text).toContain(
576+
'client-announced roots as the workspace boundary'
577+
);
505578
expect(response.contents[0]?.text).toContain('codebase://context/project/');
506579
expect(response.contents[0]?.text).toContain('retry tool calls with `project`');
507580
expect(response.contents[0]?.text).toContain('apps/dashboard');
@@ -547,9 +620,7 @@ describe('multi-project routing', () => {
547620
params: { uri: buildProjectContextResourceUri(payload.project.project) }
548621
})) as ResourceReadResponse;
549622

550-
expect(response.contents[0]?.uri).toBe(
551-
buildProjectContextResourceUri(payload.project.project)
552-
);
623+
expect(response.contents[0]?.uri).toBe(buildProjectContextResourceUri(payload.project.project));
553624
expect(response.contents[0]?.text).toContain('# Codebase Intelligence');
554625
});
555626

0 commit comments

Comments
 (0)