Skip to content

Commit 75d7a86

Browse files
committed
fix: route MCP requests per project root
1 parent e08b1f3 commit 75d7a86

File tree

9 files changed

+991
-254
lines changed

9 files changed

+991
-254
lines changed

src/index.ts

Lines changed: 357 additions & 170 deletions
Large diffs are not rendered by default.

src/project-state.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import path from 'path';
2+
import {
3+
CODEBASE_CONTEXT_DIRNAME,
4+
MEMORY_FILENAME,
5+
INTELLIGENCE_FILENAME,
6+
KEYWORD_INDEX_FILENAME,
7+
VECTOR_DB_DIRNAME
8+
} from './constants/codebase-context.js';
9+
import { createAutoRefreshController } from './core/auto-refresh.js';
10+
import type { AutoRefreshController } from './core/auto-refresh.js';
11+
import type { ToolPaths, IndexState } from './tools/types.js';
12+
13+
export interface ProjectState {
14+
rootPath: string;
15+
paths: ToolPaths;
16+
indexState: IndexState;
17+
autoRefresh: AutoRefreshController;
18+
stopWatcher?: () => void;
19+
}
20+
21+
export function makePaths(rootPath: string): ToolPaths {
22+
return {
23+
baseDir: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME),
24+
memory: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME),
25+
intelligence: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, INTELLIGENCE_FILENAME),
26+
keywordIndex: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME),
27+
vectorDb: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, VECTOR_DB_DIRNAME)
28+
};
29+
}
30+
31+
export function makeLegacyPaths(rootPath: string) {
32+
return {
33+
intelligence: path.join(rootPath, '.codebase-intelligence.json'),
34+
keywordIndex: path.join(rootPath, '.codebase-index.json'),
35+
vectorDb: path.join(rootPath, '.codebase-index')
36+
};
37+
}
38+
39+
export function normalizeRootKey(rootPath: string): string {
40+
let normalized = path.resolve(rootPath);
41+
// Strip trailing separator
42+
while (normalized.length > 1 && (normalized.endsWith('/') || normalized.endsWith('\\'))) {
43+
normalized = normalized.slice(0, -1);
44+
}
45+
// Case-insensitive on Windows
46+
if (process.platform === 'win32') {
47+
normalized = normalized.toLowerCase();
48+
}
49+
return normalized;
50+
}
51+
52+
const projects = new Map<string, ProjectState>();
53+
54+
export function createProjectState(rootPath: string): ProjectState {
55+
return {
56+
rootPath,
57+
paths: makePaths(rootPath),
58+
indexState: { status: 'idle' },
59+
autoRefresh: createAutoRefreshController()
60+
};
61+
}
62+
63+
export function getOrCreateProject(rootPath: string): ProjectState {
64+
const key = normalizeRootKey(rootPath);
65+
let project = projects.get(key);
66+
if (!project) {
67+
project = createProjectState(rootPath);
68+
projects.set(key, project);
69+
}
70+
return project;
71+
}
72+
73+
export function getProject(rootPath: string): ProjectState | undefined {
74+
return projects.get(normalizeRootKey(rootPath));
75+
}
76+
77+
export function getAllProjects(): ProjectState[] {
78+
return Array.from(projects.values());
79+
}
80+
81+
export function removeProject(rootPath: string): void {
82+
const key = normalizeRootKey(rootPath);
83+
const project = projects.get(key);
84+
project?.stopWatcher?.();
85+
projects.delete(key);
86+
}
87+
88+
export function clearProjects(): void {
89+
for (const project of projects.values()) {
90+
project.stopWatcher?.();
91+
}
92+
projects.clear();
93+
}

src/tools/index.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,38 @@ import { definition as d10, handle as h10 } from './get-memory.js';
1515

1616
import type { ToolContext, ToolResponse } from './types.js';
1717

18-
export const TOOLS: Tool[] = [d1, d2, d3, d4, d5, d6, d7, d8, d9, d10];
18+
const PROJECT_DIRECTORY_PROPERTY: Record<string, string> = {
19+
type: 'string',
20+
description:
21+
'Optional absolute path or file:// URI for the project root to use when multiple roots are available.'
22+
};
23+
24+
function withProjectDirectory(definition: Tool): Tool {
25+
const schema = definition.inputSchema;
26+
if (!schema || schema.type !== 'object') {
27+
return definition;
28+
}
29+
30+
const properties = { ...(schema.properties ?? {}) };
31+
if ('project_directory' in properties) {
32+
return definition;
33+
}
34+
35+
return {
36+
...definition,
37+
inputSchema: {
38+
...schema,
39+
properties: {
40+
...properties,
41+
project_directory: PROJECT_DIRECTORY_PROPERTY
42+
}
43+
}
44+
};
45+
}
46+
47+
export const TOOLS: Tool[] = [d1, d2, d3, d4, d5, d6, d7, d8, d9, d10].map(
48+
withProjectDirectory
49+
);
1950

2051
export async function dispatchTool(
2152
name: string,

src/tools/search-codebase.ts

Lines changed: 17 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -177,57 +177,25 @@ export async function handle(
177177
});
178178
} catch (error) {
179179
if (error instanceof IndexCorruptedError) {
180-
console.error('[Auto-Heal] Index corrupted. Triggering full re-index...');
181-
182-
await ctx.performIndexing();
183-
184-
if (ctx.indexState.status === 'ready') {
185-
console.error('[Auto-Heal] Success. Retrying search...');
186-
const freshSearcher = new CodebaseSearcher(ctx.rootPath);
187-
try {
188-
results = await freshSearcher.search(queryStr, limit || 5, filters, {
189-
profile: searchProfile
190-
});
191-
} catch (retryError) {
192-
return {
193-
content: [
180+
console.error('[Auto-Heal] Index corrupted. Triggering background re-index...');
181+
void ctx.performIndexing();
182+
return {
183+
content: [
184+
{
185+
type: 'text',
186+
text: JSON.stringify(
194187
{
195-
type: 'text',
196-
text: JSON.stringify(
197-
{
198-
status: 'error',
199-
message: `Auto-heal retry failed: ${
200-
retryError instanceof Error ? retryError.message : String(retryError)
201-
}`
202-
},
203-
null,
204-
2
205-
)
206-
}
207-
]
208-
};
209-
}
210-
} else {
211-
return {
212-
content: [
213-
{
214-
type: 'text',
215-
text: JSON.stringify(
216-
{
217-
status: 'error',
218-
message: `Auto-heal failed: Indexing ended with status '${ctx.indexState.status}'`,
219-
error: ctx.indexState.error
220-
},
221-
null,
222-
2
223-
)
224-
}
225-
]
226-
};
227-
}
228-
} else {
229-
throw error; // Propagate unexpected errors
188+
status: 'indexing',
189+
message: 'Index was corrupt. Rebuild started — retry shortly.'
190+
},
191+
null,
192+
2
193+
)
194+
}
195+
]
196+
};
230197
}
198+
throw error;
231199
}
232200

233201
// Load memories for keyword matching, enriched with confidence

tests/index-versioning-migration.test.ts

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -206,17 +206,21 @@ describe('index versioning migration (MIGR-01)', () => {
206206
});
207207

208208
afterEach(async () => {
209+
const { clearProjects } = await import('../src/project-state.js');
210+
clearProjects();
211+
209212
if (originalArgv) process.argv = originalArgv;
210213
if (originalEnvRoot === undefined) delete process.env.CODEBASE_ROOT;
211214
else process.env.CODEBASE_ROOT = originalEnvRoot;
212215

213216
if (tempRoot) {
214-
await fs.rm(tempRoot, { recursive: true, force: true });
217+
// Background indexing (fire-and-forget) may still be writing — retry on ENOTEMPTY
218+
await fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
215219
tempRoot = null;
216220
}
217221
});
218222

219-
it('refuses legacy indexes without index-meta.json and triggers auto-heal rebuild', async () => {
223+
it('refuses legacy indexes without index-meta.json and triggers background rebuild', async () => {
220224
if (!tempRoot) throw new Error('tempRoot not initialized');
221225

222226
const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME);
@@ -241,14 +245,14 @@ describe('index versioning migration (MIGR-01)', () => {
241245
});
242246

243247
const payload = JSON.parse(response.content[0].text);
244-
expect(payload.status).toBe('success');
248+
expect(payload.status).toBe('indexing');
249+
expect(payload.message).toContain('retry');
245250
expect(payload.index).toBeTruthy();
246-
expect(payload.index.action).toBe('rebuilt-and-served');
251+
expect(payload.index.action).toBe('rebuild-started');
247252
expect(String(payload.index.reason || '')).toContain('Index meta');
248-
expect(indexerMocks.index).toHaveBeenCalledTimes(1);
249253
});
250254

251-
it('detects keyword index header mismatch and triggers rebuild (no silent empty results)', async () => {
255+
it('detects keyword index header mismatch and triggers background rebuild', async () => {
252256
if (!tempRoot) throw new Error('tempRoot not initialized');
253257

254258
const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME);
@@ -302,13 +306,13 @@ describe('index versioning migration (MIGR-01)', () => {
302306
});
303307

304308
const payload = JSON.parse(response.content[0].text);
305-
expect(payload.status).toBe('success');
306-
expect(payload.index.action).toBe('rebuilt-and-served');
309+
expect(payload.status).toBe('indexing');
310+
expect(payload.message).toContain('retry');
311+
expect(payload.index.action).toBe('rebuild-started');
307312
expect(String(payload.index.reason || '')).toContain('Keyword index');
308-
expect(indexerMocks.index).toHaveBeenCalledTimes(1);
309313
});
310314

311-
it('detects vector DB build marker mismatch and triggers rebuild', async () => {
315+
it('detects vector DB build marker mismatch and triggers background rebuild', async () => {
312316
if (!tempRoot) throw new Error('tempRoot not initialized');
313317

314318
const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME);
@@ -362,10 +366,10 @@ describe('index versioning migration (MIGR-01)', () => {
362366
});
363367

364368
const payload = JSON.parse(response.content[0].text);
365-
expect(payload.status).toBe('success');
366-
expect(payload.index.action).toBe('rebuilt-and-served');
369+
expect(payload.status).toBe('indexing');
370+
expect(payload.message).toContain('retry');
371+
expect(payload.index.action).toBe('rebuild-started');
367372
expect(String(payload.index.reason || '')).toContain('Vector DB');
368-
expect(indexerMocks.index).toHaveBeenCalledTimes(1);
369373
});
370374
});
371375

@@ -382,9 +386,47 @@ describe('index-consuming allowlist enforcement', () => {
382386
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'index-versioning-allowlist-'));
383387
process.env.CODEBASE_ROOT = tempRoot;
384388
process.argv[2] = tempRoot;
389+
390+
const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME);
391+
const buildId = 'allowlist-build';
392+
const generatedAt = new Date().toISOString();
393+
394+
await fs.mkdir(path.join(ctxDir, VECTOR_DB_DIRNAME), { recursive: true });
395+
await fs.writeFile(
396+
path.join(ctxDir, VECTOR_DB_DIRNAME, 'index-build.json'),
397+
JSON.stringify({ buildId, formatVersion: INDEX_FORMAT_VERSION }),
398+
'utf-8'
399+
);
400+
await fs.writeFile(
401+
path.join(ctxDir, KEYWORD_INDEX_FILENAME),
402+
JSON.stringify({ header: { buildId, formatVersion: INDEX_FORMAT_VERSION }, chunks: [] }),
403+
'utf-8'
404+
);
405+
await fs.writeFile(
406+
path.join(ctxDir, INDEX_META_FILENAME),
407+
JSON.stringify(
408+
{
409+
metaVersion: INDEX_META_VERSION,
410+
formatVersion: INDEX_FORMAT_VERSION,
411+
buildId,
412+
generatedAt,
413+
toolVersion: 'test',
414+
artifacts: {
415+
keywordIndex: { path: KEYWORD_INDEX_FILENAME },
416+
vectorDb: { path: VECTOR_DB_DIRNAME, provider: 'lancedb' }
417+
}
418+
},
419+
null,
420+
2
421+
),
422+
'utf-8'
423+
);
385424
});
386425

387426
afterEach(async () => {
427+
const { clearProjects } = await import('../src/project-state.js');
428+
clearProjects();
429+
388430
if (originalArgv) process.argv = originalArgv;
389431
if (originalEnvRoot === undefined) delete process.env.CODEBASE_ROOT;
390432
else process.env.CODEBASE_ROOT = originalEnvRoot;

0 commit comments

Comments
 (0)