codesearch-mcp 0.0.16-alpha → 0.0.17-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -1
- package/README.md +59 -36
- package/dist/app.d.ts +1 -1
- package/dist/app.js +67 -16
- package/dist/src/commands/BackfillCommand.js +15 -5
- package/dist/src/commands/ConfigCommand.d.ts +12 -0
- package/dist/src/commands/ConfigCommand.js +98 -0
- package/dist/src/commands/IndexCommand.js +1 -1
- package/dist/src/config-store.d.ts +53 -0
- package/dist/src/config-store.js +197 -0
- package/dist/src/config.d.ts +22 -8
- package/dist/src/config.js +103 -58
- package/dist/src/container.d.ts +1 -1
- package/dist/src/container.js +8 -7
- package/dist/src/db/index.d.ts +1 -1
- package/dist/src/db/index.js +2 -1
- package/dist/src/db/schema.d.ts +160 -0
- package/dist/src/db/schema.js +12 -1
- package/dist/src/help.js +28 -12
- package/dist/src/interfaces/IAstParser.d.ts +14 -1
- package/dist/src/interfaces/IEmbedder.d.ts +1 -0
- package/dist/src/project-selector.d.ts +14 -5
- package/dist/src/project-selector.js +74 -60
- package/dist/src/services/AstParserService.d.ts +5 -1
- package/dist/src/services/AstParserService.js +107 -16
- package/dist/src/services/DatabaseService.js +18 -0
- package/dist/src/services/IndexerService.d.ts +7 -0
- package/dist/src/services/IndexerService.js +160 -40
- package/dist/src/services/LoggerService.d.ts +5 -1
- package/dist/src/services/LoggerService.js +8 -5
- package/dist/src/services/McpServer.d.ts +1 -0
- package/dist/src/services/McpServer.js +72 -4
- package/dist/src/services/OllamaEmbedder.d.ts +2 -0
- package/dist/src/services/OllamaEmbedder.js +29 -0
- package/dist/src/services/SearcherService.d.ts +13 -0
- package/dist/src/services/SearcherService.js +118 -22
- package/dist/src/setup.js +57 -62
- package/package.json +3 -3
|
@@ -69,7 +69,9 @@ class IndexerService {
|
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
async backfillSymbols(signal) {
|
|
72
|
+
const t0 = Date.now();
|
|
72
73
|
const rows = await this.db.query(`SELECT DISTINCT "serviceName", "filePath" FROM code_chunk ORDER BY "serviceName", "filePath"`);
|
|
74
|
+
this.logger.debug('IndexerService: backfillSymbols start', { total: rows.length });
|
|
73
75
|
let processed = 0, skipped = 0, failed = 0;
|
|
74
76
|
for (const { serviceName, filePath } of rows) {
|
|
75
77
|
if (signal?.aborted)
|
|
@@ -103,6 +105,58 @@ class IndexerService {
|
|
|
103
105
|
process.stdout.isTTY ? process.stdout.write(msg + '\r') : console.log(msg);
|
|
104
106
|
}
|
|
105
107
|
}
|
|
108
|
+
this.logger.debug('IndexerService: backfillSymbols done', { total: rows.length, processed, skipped, failed, ms: Date.now() - t0 });
|
|
109
|
+
return { total: rows.length, processed, skipped, failed };
|
|
110
|
+
}
|
|
111
|
+
async backfillCalls(signal) {
|
|
112
|
+
const t0 = Date.now();
|
|
113
|
+
const rows = await this.db.query(`SELECT DISTINCT "serviceName", "filePath" FROM code_chunk ORDER BY "serviceName", "filePath"`);
|
|
114
|
+
this.logger.debug('IndexerService: backfillCalls start', { total: rows.length });
|
|
115
|
+
let processed = 0, skipped = 0, failed = 0;
|
|
116
|
+
for (const { serviceName, filePath } of rows) {
|
|
117
|
+
if (signal?.aborted)
|
|
118
|
+
break;
|
|
119
|
+
const absPath = path.join(this.config.workspaceRoot, filePath);
|
|
120
|
+
const projectRoot = path.join(this.config.workspaceRoot, serviceName);
|
|
121
|
+
if (!fs.existsSync(absPath)) {
|
|
122
|
+
skipped++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const { calls: rawCalls } = await this.ast.analyzeFile(absPath, serviceName, projectRoot);
|
|
127
|
+
const calls = rawCalls
|
|
128
|
+
.filter(c => c.calleeFile.startsWith(this.config.workspaceRoot))
|
|
129
|
+
.map(c => ({
|
|
130
|
+
project: serviceName,
|
|
131
|
+
fromFile: filePath,
|
|
132
|
+
fromSymbol: c.fromSymbol,
|
|
133
|
+
calleeName: c.calleeName,
|
|
134
|
+
calleeFile: path.relative(this.config.workspaceRoot, c.calleeFile),
|
|
135
|
+
calleeSymbol: c.calleeSymbol,
|
|
136
|
+
line: c.line,
|
|
137
|
+
col: c.col,
|
|
138
|
+
}));
|
|
139
|
+
await this.db.transaction(async (tx) => {
|
|
140
|
+
await tx.delete(db_1.codeCall).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.codeCall.project, serviceName), (0, drizzle_orm_1.eq)(db_1.codeCall.fromFile, filePath)));
|
|
141
|
+
if (calls.length > 0) {
|
|
142
|
+
await tx.insert(db_1.codeCall).values(calls);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
processed++;
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const reason = err.message;
|
|
149
|
+
console.error(` Failed: ${filePath} — ${reason}`);
|
|
150
|
+
this.logger.error('IndexerService: call backfill failed', { file: filePath, reason });
|
|
151
|
+
failed++;
|
|
152
|
+
}
|
|
153
|
+
const count = processed + failed;
|
|
154
|
+
if (count > 0 && count % 50 === 0) {
|
|
155
|
+
const msg = ` ${count}/${rows.length} files...`;
|
|
156
|
+
process.stdout.isTTY ? process.stdout.write(msg + '\r') : console.log(msg);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
this.logger.debug('IndexerService: backfillCalls done', { total: rows.length, processed, skipped, failed, ms: Date.now() - t0 });
|
|
106
160
|
return { total: rows.length, processed, skipped, failed };
|
|
107
161
|
}
|
|
108
162
|
discoverProjects(rootDir) {
|
|
@@ -125,10 +179,12 @@ class IndexerService {
|
|
|
125
179
|
}
|
|
126
180
|
async scanAndIndex(dir, serviceName, force = false, signal, preCollectedFiles) {
|
|
127
181
|
const startTime = Date.now();
|
|
182
|
+
this.logger.debug('IndexerService: scanAndIndex start', { serviceName, dir, force });
|
|
128
183
|
if (force) {
|
|
129
184
|
await this.db.query(`UPDATE code_chunk SET "lastModified" = NULL, "contentHash" = NULL WHERE "serviceName" = $1`, [serviceName]);
|
|
130
185
|
}
|
|
131
186
|
const files = preCollectedFiles ?? (0, file_utils_1.collectFiles)(dir, (0, file_utils_1.loadIgnore)(dir), dir);
|
|
187
|
+
this.logger.debug('IndexerService: scanAndIndex files collected', { serviceName, count: files.length, force });
|
|
132
188
|
const collectedRelPaths = new Set(files.map(f => path.relative(this.config.workspaceRoot, f)));
|
|
133
189
|
const dbPaths = await this.db.query(`SELECT DISTINCT "filePath" FROM code_chunk WHERE "serviceName" = $1`, [serviceName]);
|
|
134
190
|
for (const { filePath } of dbPaths) {
|
|
@@ -168,37 +224,46 @@ class IndexerService {
|
|
|
168
224
|
console.log(` ⚠ ${msg}`);
|
|
169
225
|
warnings.push(msg);
|
|
170
226
|
}
|
|
227
|
+
if (files.length > this.config.indexer.astBatchSize) {
|
|
228
|
+
console.log(` ⚠ Large project (${files.length} files) — AST analysis uses batches of ${this.config.indexer.astBatchSize}; cross-batch dependency edges may be incomplete`);
|
|
229
|
+
}
|
|
171
230
|
bar.start(files.length, 0, { serviceName, filename: '' });
|
|
172
231
|
const stats = {
|
|
173
232
|
serviceName, indexed: 0, skipped: 0, failed: 0, chunks: 0, elapsedSec: 0, warnings,
|
|
174
233
|
};
|
|
175
234
|
const mtimeRefreshBatch = [];
|
|
176
|
-
const
|
|
177
|
-
for (
|
|
178
|
-
if (signal?.aborted)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
235
|
+
const isLargeProject = files.length > this.config.indexer.astBatchSize;
|
|
236
|
+
for (let batchStart = 0; batchStart < files.length; batchStart += this.config.indexer.astBatchSize) {
|
|
237
|
+
if (signal?.aborted)
|
|
238
|
+
break;
|
|
239
|
+
const batch = files.slice(batchStart, batchStart + this.config.indexer.astBatchSize);
|
|
240
|
+
const analysisMap = await this.ast.analyzeProject(batch, serviceName, dir, { skipDependencyResolution: isLargeProject });
|
|
241
|
+
for (const filePath of batch) {
|
|
242
|
+
if (signal?.aborted) {
|
|
243
|
+
bar.stop();
|
|
244
|
+
console.log(`\n Interrupted — ${serviceName}`);
|
|
245
|
+
stats.elapsedSec = Math.round((Date.now() - startTime) / 100) / 10;
|
|
246
|
+
return stats;
|
|
247
|
+
}
|
|
248
|
+
bar.update({ filename: path.relative(dir, filePath) });
|
|
249
|
+
const mtimeMs = fs.statSync(filePath).mtimeMs;
|
|
250
|
+
const analysis = analysisMap.get(filePath);
|
|
251
|
+
if (analysis === undefined && /\.(ts|tsx|js|jsx)$/.test(filePath) && !/\.d\.ts$/.test(filePath)) {
|
|
252
|
+
this.logger.warn('IndexerService: no pre-computed analysis for TS file', { file: filePath });
|
|
253
|
+
}
|
|
254
|
+
const result = await this.ingestFile(filePath, serviceName, mtimeMs, dir, analysis, meta);
|
|
255
|
+
if (result.pendingMtimeRefresh) {
|
|
256
|
+
mtimeRefreshBatch.push({ relFilePath: result.pendingMtimeRefresh, mtimeMs });
|
|
257
|
+
}
|
|
258
|
+
if (result.status === 'indexed')
|
|
259
|
+
stats.indexed++;
|
|
260
|
+
else if (result.status === 'skipped')
|
|
261
|
+
stats.skipped++;
|
|
262
|
+
else
|
|
263
|
+
stats.failed++;
|
|
264
|
+
stats.chunks += result.chunks;
|
|
265
|
+
bar.increment();
|
|
193
266
|
}
|
|
194
|
-
if (result.status === 'indexed')
|
|
195
|
-
stats.indexed++;
|
|
196
|
-
else if (result.status === 'skipped')
|
|
197
|
-
stats.skipped++;
|
|
198
|
-
else
|
|
199
|
-
stats.failed++;
|
|
200
|
-
stats.chunks += result.chunks;
|
|
201
|
-
bar.increment();
|
|
202
267
|
}
|
|
203
268
|
bar.stop();
|
|
204
269
|
process.stdout.write('\x1B[J');
|
|
@@ -218,25 +283,63 @@ class IndexerService {
|
|
|
218
283
|
}
|
|
219
284
|
this.ast.clearCache(dir);
|
|
220
285
|
stats.elapsedSec = Math.round((Date.now() - startTime) / 100) / 10;
|
|
286
|
+
this.logger.debug('IndexerService: project indexed', {
|
|
287
|
+
project: serviceName,
|
|
288
|
+
indexed: stats.indexed,
|
|
289
|
+
skipped: stats.skipped,
|
|
290
|
+
failed: stats.failed,
|
|
291
|
+
chunks: stats.chunks,
|
|
292
|
+
elapsedSec: stats.elapsedSec,
|
|
293
|
+
});
|
|
221
294
|
return stats;
|
|
222
295
|
}
|
|
223
296
|
getSplitter(filePath) {
|
|
224
297
|
return SCHEMA_EXTENSIONS.test(filePath) ? this.schemaSplitter : this.jsSplitter;
|
|
225
298
|
}
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
299
|
+
static inferRole(relPath) {
|
|
300
|
+
const p = relPath.toLowerCase();
|
|
301
|
+
if (p.includes('/services/') || p.endsWith('.service.ts'))
|
|
302
|
+
return 'Service';
|
|
303
|
+
if (p.includes('/controllers/') || p.endsWith('.controller.ts'))
|
|
304
|
+
return 'Controller';
|
|
305
|
+
if (p.includes('/entities/') || p.endsWith('.entity.ts'))
|
|
306
|
+
return 'Entity';
|
|
307
|
+
if (p.includes('/guards/') || p.endsWith('.guard.ts'))
|
|
308
|
+
return 'Guard';
|
|
309
|
+
if (p.includes('/commands/'))
|
|
310
|
+
return 'Command';
|
|
311
|
+
if (p.includes('/interfaces/'))
|
|
312
|
+
return 'Interface';
|
|
313
|
+
if (p.includes('/middleware/'))
|
|
314
|
+
return 'Middleware';
|
|
315
|
+
if (p.includes('/interceptors/'))
|
|
316
|
+
return 'Interceptor';
|
|
317
|
+
if (p.endsWith('.test.ts') || p.endsWith('.spec.ts') || p.includes('/tests/'))
|
|
318
|
+
return 'Test';
|
|
319
|
+
if (p.includes('/utils/') || p.includes('/helpers/') || p.endsWith('-utils.ts'))
|
|
320
|
+
return 'Utility';
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
getEnrichedPrompt(chunk, relPath, serviceName, _meta, edges, symbols) {
|
|
324
|
+
const chunkStart = (chunk.metadata?.loc?.lines?.from ?? 1);
|
|
325
|
+
const chunkEnd = (chunk.metadata?.loc?.lines?.to ?? chunkStart);
|
|
326
|
+
const lines = [];
|
|
327
|
+
lines.push(`Project: ${serviceName} | File: ${relPath}`);
|
|
328
|
+
const role = IndexerService.inferRole(relPath);
|
|
329
|
+
if (role)
|
|
330
|
+
lines.push(`Role: ${role}`);
|
|
331
|
+
const overlapping = symbols.filter(s => s.line != null &&
|
|
332
|
+
s.line <= chunkEnd &&
|
|
333
|
+
(s.endLine ?? s.line) >= chunkStart);
|
|
334
|
+
if (overlapping.length > 0) {
|
|
335
|
+
lines.push(`Symbols: ${overlapping.map(s => `${s.kind} ${s.name}`).join(', ')}`);
|
|
231
336
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (relevantSymbols.length === 0) {
|
|
236
|
-
return `${header}\n${pageContent}`;
|
|
337
|
+
if (edges.length > 0) {
|
|
338
|
+
const depNames = [...new Set(edges.map(e => path.basename(e.toFile, path.extname(e.toFile)).split('.')[0]))];
|
|
339
|
+
lines.push(`Dependencies: ${depNames.join(', ')}`);
|
|
237
340
|
}
|
|
238
|
-
|
|
239
|
-
return
|
|
341
|
+
lines.push(`Code: ${chunk.pageContent}`);
|
|
342
|
+
return lines.join('\n');
|
|
240
343
|
}
|
|
241
344
|
async embedText(prompt) {
|
|
242
345
|
return this.embedder.embed(prompt);
|
|
@@ -244,7 +347,7 @@ class IndexerService {
|
|
|
244
347
|
static hashContent(content) {
|
|
245
348
|
return (0, crypto_1.createHash)('sha256').update(content).digest('hex');
|
|
246
349
|
}
|
|
247
|
-
async ingestFile(filePath, serviceName, mtimeMs, projectRoot, analysis) {
|
|
350
|
+
async ingestFile(filePath, serviceName, mtimeMs, projectRoot, analysis, meta) {
|
|
248
351
|
try {
|
|
249
352
|
const relFilePath = path.relative(this.config.workspaceRoot, filePath);
|
|
250
353
|
const [existing] = await this.db.orm
|
|
@@ -264,7 +367,7 @@ class IndexerService {
|
|
|
264
367
|
return { status: 'skipped', chunks: 0, pendingMtimeRefresh: relFilePath };
|
|
265
368
|
}
|
|
266
369
|
const tAst = Date.now();
|
|
267
|
-
const { edges: rawEdges, symbols: rawSymbols } = analysis ?? { edges: [], symbols: [] };
|
|
370
|
+
const { edges: rawEdges, symbols: rawSymbols, calls: rawCalls } = analysis ?? { edges: [], symbols: [], calls: [] };
|
|
268
371
|
// astMs is near-zero — actual AST time is spent upfront in analyzeProject()
|
|
269
372
|
// Normalize edges to workspace-relative paths
|
|
270
373
|
const edges = rawEdges
|
|
@@ -274,6 +377,19 @@ class IndexerService {
|
|
|
274
377
|
fromFile: path.relative(this.config.workspaceRoot, e.fromFile),
|
|
275
378
|
toFile: path.relative(this.config.workspaceRoot, e.toFile),
|
|
276
379
|
}));
|
|
380
|
+
// Normalize call edges to workspace-relative paths; keep only resolved internal calls
|
|
381
|
+
const calls = rawCalls
|
|
382
|
+
.filter(c => c.calleeFile.startsWith(this.config.workspaceRoot))
|
|
383
|
+
.map(c => ({
|
|
384
|
+
project: serviceName,
|
|
385
|
+
fromFile: relFilePath,
|
|
386
|
+
fromSymbol: c.fromSymbol,
|
|
387
|
+
calleeName: c.calleeName,
|
|
388
|
+
calleeFile: path.relative(this.config.workspaceRoot, c.calleeFile),
|
|
389
|
+
calleeSymbol: c.calleeSymbol,
|
|
390
|
+
line: c.line,
|
|
391
|
+
col: c.col,
|
|
392
|
+
}));
|
|
277
393
|
// Normalize symbols: replace absolute filePath with workspace-relative, strip endLine (not stored)
|
|
278
394
|
const symbols = [...new Map(rawSymbols.map(s => ({ ...s, filePath: relFilePath })).map(s => [`${s.name}:${s.kind}`, s])).values()].map(({ project, filePath: fp, name, kind, signature, line, col }) => ({ project, filePath: fp, name, kind, signature, line, col }));
|
|
279
395
|
const astMs = Date.now() - tAst;
|
|
@@ -285,7 +401,7 @@ class IndexerService {
|
|
|
285
401
|
const batch = chunks.slice(i, i + this.config.indexer.batchSize);
|
|
286
402
|
const embedded = await Promise.all(batch.map(async (chunk) => {
|
|
287
403
|
const startLine = (chunk.metadata?.loc?.lines?.from ?? 1);
|
|
288
|
-
const prompt = this.getEnrichedPrompt(chunk,
|
|
404
|
+
const prompt = this.getEnrichedPrompt(chunk, relPath, serviceName, meta ?? 'missing', edges, rawSymbols);
|
|
289
405
|
const embedding = await this.embedText(prompt);
|
|
290
406
|
return {
|
|
291
407
|
serviceName, filePath: relFilePath, startLine,
|
|
@@ -309,6 +425,10 @@ class IndexerService {
|
|
|
309
425
|
if (symbols.length > 0) {
|
|
310
426
|
await tx.insert(db_1.codeSymbol).values(symbols);
|
|
311
427
|
}
|
|
428
|
+
await tx.delete(db_1.codeCall).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.codeCall.project, serviceName), (0, drizzle_orm_1.eq)(db_1.codeCall.fromFile, relFilePath)));
|
|
429
|
+
if (calls.length > 0) {
|
|
430
|
+
await tx.insert(db_1.codeCall).values(calls);
|
|
431
|
+
}
|
|
312
432
|
});
|
|
313
433
|
const dbMs = Date.now() - t3;
|
|
314
434
|
if (chunks.length > 0) {
|
|
@@ -4,7 +4,11 @@ export declare class LoggerService {
|
|
|
4
4
|
private readonly logger;
|
|
5
5
|
constructor(logger: pino.Logger);
|
|
6
6
|
static silent(): LoggerService;
|
|
7
|
-
static
|
|
7
|
+
static fromConfig(cfg: {
|
|
8
|
+
level: string;
|
|
9
|
+
dir?: string;
|
|
10
|
+
}, name?: string): LoggerService;
|
|
11
|
+
private static buildFromPath;
|
|
8
12
|
debug(msg: string, data?: Record<string, unknown>): void;
|
|
9
13
|
info(msg: string, data?: Record<string, unknown>): void;
|
|
10
14
|
warn(msg: string, data?: Record<string, unknown>): void;
|
|
@@ -50,17 +50,20 @@ class LoggerService {
|
|
|
50
50
|
static silent() {
|
|
51
51
|
return new LoggerService((0, pino_1.default)({ level: 'silent' }));
|
|
52
52
|
}
|
|
53
|
-
static
|
|
54
|
-
const level =
|
|
55
|
-
const
|
|
56
|
-
const enabled =
|
|
53
|
+
static fromConfig(cfg, name) {
|
|
54
|
+
const level = cfg.level;
|
|
55
|
+
const dirVal = cfg.dir;
|
|
56
|
+
const enabled = dirVal !== 'false' && dirVal !== '0';
|
|
57
57
|
let filePath = null;
|
|
58
58
|
if (enabled) {
|
|
59
|
-
const logDir =
|
|
59
|
+
const logDir = dirVal || DEFAULT_LOG_DIR;
|
|
60
60
|
filePath = name
|
|
61
61
|
? path.join(logDir, name, `${name}.log`)
|
|
62
62
|
: path.join(logDir, 'rag.log');
|
|
63
63
|
}
|
|
64
|
+
return LoggerService.buildFromPath(level, filePath);
|
|
65
|
+
}
|
|
66
|
+
static buildFromPath(level, filePath) {
|
|
64
67
|
if (filePath)
|
|
65
68
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
66
69
|
const transport = filePath
|
|
@@ -7,6 +7,7 @@ export declare class McpServer {
|
|
|
7
7
|
private logger;
|
|
8
8
|
constructor(searcher: SearcherService, config: Config, logger?: LoggerService);
|
|
9
9
|
start(startupError?: Error): Promise<void>;
|
|
10
|
+
private formatGraphContext;
|
|
10
11
|
private formatChunk;
|
|
11
12
|
private formatDependencies;
|
|
12
13
|
private formatDependents;
|
|
@@ -59,6 +59,7 @@ Pick one workflow based on what you need:
|
|
|
59
59
|
| Type | Signals | Workflow |
|
|
60
60
|
|---|---|---|
|
|
61
61
|
| **Lookup** | "where is X defined", "find Y" | find_symbol (if name known) → search_codebase (if not found) |
|
|
62
|
+
| **Caller trace** | "who calls X", "find usages of Y", "what invokes Z" | find_symbol → get_callers |
|
|
62
63
|
| **Conceptual** | "how does X work", "what is Y" | search_codebase → read_file_range |
|
|
63
64
|
| **Flow audit** | "how does X connect to Y", "audit this flow", "trace the path" | search_codebase → **get_dependencies + get_dependents** → Grep |
|
|
64
65
|
| **Impact** | "what would break if I change X", "what uses Z" | get_dependents → search_codebase for context |
|
|
@@ -73,6 +74,7 @@ Pick one workflow based on what you need:
|
|
|
73
74
|
- **get_dependencies** — show what files a given file imports (direct or transitive). Use to understand what a file depends on.
|
|
74
75
|
- **get_dependents** — show which files import a given file (reverse graph). Use before refactoring to find all callers.
|
|
75
76
|
- **find_symbol** — find where a symbol (function, class, type, interface, variable, method) is defined by exact name. Faster than semantic search when you know the name.
|
|
77
|
+
- **get_callers** — find all call sites of a function or method by exact name. Uses the call graph index — faster and more precise than grep. Use after find_symbol to trace where a symbol is actually called.
|
|
76
78
|
- **list_files** — browse the directory tree of a project path. Use to understand project structure before reading files.
|
|
77
79
|
- **read_file** — read a complete file by **absolute path** with line numbers. Truncates at max_lines with a hint to continue.
|
|
78
80
|
|
|
@@ -83,8 +85,9 @@ Pick one workflow based on what you need:
|
|
|
83
85
|
2. Use \`search_codebase\` to find relevant code by meaning. If your query uses an ambiguous term (e.g. "subscription" could mean pub/sub or billing), pass a lower \`threshold\` (e.g. \`0.40–0.45\`) to filter out weak matches.
|
|
84
86
|
3. If a result looks **truncated or incomplete**, call \`read_file_range\` with the returned \`file_path\` and \`start_line\`.
|
|
85
87
|
4. For exploratory searches where results span chunk boundaries, pass \`include_neighbors: true\`.
|
|
86
|
-
5.
|
|
87
|
-
6. Use \`
|
|
88
|
+
5. To immediately see a file's imports and callers without extra tool calls, pass \`include_graph_context: true\` to \`search_codebase\`. Use this when doing a quick structural survey; switch to \`get_dependencies\`/\`get_dependents\` for full transitive graphs.
|
|
89
|
+
6. Use \`find_symbol\` when you know the exact name — faster than semantic search. Use \`kind: "method"\` for class methods — grep matches lines where \`public\`, \`async\`, \`static\`, or \`override\` appears before the name. After locating a symbol, use \`get_callers\` to find all call sites — faster and more precise than grep (requires \`rag-index backfill --calls\` to have been run).
|
|
90
|
+
7. Use \`list_files\` to explore project structure before diving into files. Use \`read_file\` to read a complete file by absolute path.
|
|
88
91
|
|
|
89
92
|
### Flow audit (mandatory steps — do not skip 3 and 4)
|
|
90
93
|
1. Use \`list_projects\` to confirm project names.
|
|
@@ -131,6 +134,9 @@ class McpServer {
|
|
|
131
134
|
this.logger = logger;
|
|
132
135
|
}
|
|
133
136
|
async start(startupError) {
|
|
137
|
+
if (startupError) {
|
|
138
|
+
this.logger.error('McpServer: startup failed, server will report error on every tool call', { reason: startupError.message });
|
|
139
|
+
}
|
|
134
140
|
const server = new index_js_1.Server({ name: 'local-rag', version: PKG_VERSION }, { capabilities: { tools: {} }, instructions: buildInstructions(this.config.search.similarityThreshold) });
|
|
135
141
|
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
136
142
|
tools: [
|
|
@@ -144,6 +150,7 @@ class McpServer {
|
|
|
144
150
|
limit: { type: 'number', description: `Max results (default ${this.config.search.defaultLimit}, max ${this.config.search.maxLimit})` },
|
|
145
151
|
project: { type: 'string', description: 'Filter by project name (optional). Use list_projects to see available names.' },
|
|
146
152
|
include_neighbors: { type: 'boolean', description: 'Also include the chunk before and after each result from the same file (Small-to-Big retrieval). Useful when results look truncated.' },
|
|
153
|
+
include_graph_context: { type: 'boolean', description: 'Append direct imports and dependents list to each result. Shows "Imports: X · Y" and "Used by: Z" without extra tool calls.' },
|
|
147
154
|
threshold: { type: 'number', description: `Max cosine distance (0–1). Lower = stricter. Default: ${this.config.search.similarityThreshold}. Use 0.40–0.45 for precise technical queries, 0.55–0.60 for broad exploration.` },
|
|
148
155
|
},
|
|
149
156
|
required: ['query'],
|
|
@@ -208,6 +215,19 @@ class McpServer {
|
|
|
208
215
|
required: ['name'],
|
|
209
216
|
},
|
|
210
217
|
},
|
|
218
|
+
{
|
|
219
|
+
name: 'get_callers',
|
|
220
|
+
description: 'Find all call sites of a function or method by exact name. Uses the call graph index — faster and more precise than grep. Use after find_symbol to trace where a symbol is actually called. Returns empty if rag-index backfill --calls has not been run.',
|
|
221
|
+
inputSchema: {
|
|
222
|
+
type: 'object',
|
|
223
|
+
properties: {
|
|
224
|
+
symbol: { type: 'string', description: 'Exact function or method name (case-sensitive)' },
|
|
225
|
+
project: { type: 'string', description: 'Project name (from list_projects)' },
|
|
226
|
+
callee_file: { type: 'string', description: 'Optional: filter by definition file path (as returned by find_symbol) to disambiguate overloaded names' },
|
|
227
|
+
},
|
|
228
|
+
required: ['symbol', 'project'],
|
|
229
|
+
},
|
|
230
|
+
},
|
|
211
231
|
{
|
|
212
232
|
name: 'list_files',
|
|
213
233
|
description: 'List files and directories in a project path as an indented tree. Use to understand project structure before reading files.',
|
|
@@ -246,8 +266,10 @@ class McpServer {
|
|
|
246
266
|
};
|
|
247
267
|
}
|
|
248
268
|
try {
|
|
269
|
+
const t0 = Date.now();
|
|
249
270
|
if (req.params.name === 'list_projects') {
|
|
250
271
|
const rows = await this.searcher.listProjects();
|
|
272
|
+
this.logger.debug('McpServer: list_projects', { count: rows.length, ms: Date.now() - t0 });
|
|
251
273
|
if (rows.length === 0) {
|
|
252
274
|
return { content: [{ type: 'text', text: 'No indexed projects found.' }] };
|
|
253
275
|
}
|
|
@@ -273,6 +295,7 @@ class McpServer {
|
|
|
273
295
|
const limit = Math.min(Math.max(1, Number(req.params.arguments?.limit) || this.config.search.defaultLimit), this.config.search.maxLimit);
|
|
274
296
|
const project = req.params.arguments?.project ? String(req.params.arguments.project) : null;
|
|
275
297
|
const includeNeighbors = Boolean(req.params.arguments?.include_neighbors);
|
|
298
|
+
const includeGraphContext = Boolean(req.params.arguments?.include_graph_context);
|
|
276
299
|
const rawThreshold = req.params.arguments?.threshold;
|
|
277
300
|
const parsedThreshold = Number(rawThreshold);
|
|
278
301
|
const threshold = rawThreshold != null && !isNaN(parsedThreshold)
|
|
@@ -280,17 +303,30 @@ class McpServer {
|
|
|
280
303
|
: undefined;
|
|
281
304
|
const { query, translated } = await this.searcher.prepareQuery(raw);
|
|
282
305
|
const results = await this.searcher.searchChunks(query, limit, project, threshold);
|
|
306
|
+
this.logger.debug('McpServer: search_codebase', {
|
|
307
|
+
query: raw.slice(0, 120),
|
|
308
|
+
project,
|
|
309
|
+
threshold: threshold ?? this.config.search.similarityThreshold,
|
|
310
|
+
limit,
|
|
311
|
+
translated,
|
|
312
|
+
count: results.length,
|
|
313
|
+
ms: Date.now() - t0,
|
|
314
|
+
});
|
|
283
315
|
if (results.length === 0) {
|
|
284
316
|
return { content: [{ type: 'text', text: 'No relevant results found.' }] };
|
|
285
317
|
}
|
|
286
318
|
const header = translated ? `> Query translated: "${raw}" → "${query}"\n\n` : '';
|
|
319
|
+
let graphCtx = null;
|
|
320
|
+
if (includeGraphContext) {
|
|
321
|
+
graphCtx = await this.searcher.fetchGraphContext(results);
|
|
322
|
+
}
|
|
287
323
|
if (includeNeighbors) {
|
|
288
324
|
const withNeighbors = await this.searcher.fetchNeighbors(results);
|
|
289
325
|
const blocks = withNeighbors.map(({ result, prevChunk, nextChunk }) => {
|
|
290
326
|
const parts = [];
|
|
291
327
|
if (prevChunk)
|
|
292
328
|
parts.push(this.formatChunk(prevChunk, 'prev chunk'));
|
|
293
|
-
parts.push(`${this.formatChunk(result)}\n**Relevance:** ${result.score}`);
|
|
329
|
+
parts.push(`${this.formatChunk(result)}\n**Relevance:** ${result.score}${this.formatGraphContext(graphCtx?.get(result.filePath))}`);
|
|
294
330
|
if (nextChunk)
|
|
295
331
|
parts.push(this.formatChunk(nextChunk, 'next chunk'));
|
|
296
332
|
return parts.join('\n\n');
|
|
@@ -298,7 +334,7 @@ class McpServer {
|
|
|
298
334
|
return { content: [{ type: 'text', text: header + blocks.join('\n\n---\n\n') }] };
|
|
299
335
|
}
|
|
300
336
|
const text = results
|
|
301
|
-
.map(r => `${this.formatChunk(r)}\n**Relevance:** ${r.score}`)
|
|
337
|
+
.map(r => `${this.formatChunk(r)}\n**Relevance:** ${r.score}${this.formatGraphContext(graphCtx?.get(r.filePath))}`)
|
|
302
338
|
.join('\n\n---\n\n');
|
|
303
339
|
return { content: [{ type: 'text', text: header + text }] };
|
|
304
340
|
}
|
|
@@ -307,6 +343,7 @@ class McpServer {
|
|
|
307
343
|
const startLine = Math.max(1, Number(req.params.arguments?.start_line) || 1);
|
|
308
344
|
const endLine = Number(req.params.arguments?.end_line) || startLine + 99;
|
|
309
345
|
const range = await this.searcher.readFileRange(filePath, startLine, endLine);
|
|
346
|
+
this.logger.debug('McpServer: read_file_range', { file: filePath, startLine, endLine, ok: range.ok, ms: Date.now() - t0 });
|
|
310
347
|
if (!range.ok) {
|
|
311
348
|
const message = range.reason === 'invalid_path' ? `Invalid file path (outside workspace): ${filePath}` :
|
|
312
349
|
range.reason === 'not_indexed' ? `File is not indexed: ${filePath}. Run indexing first.` :
|
|
@@ -326,6 +363,7 @@ class McpServer {
|
|
|
326
363
|
const symbol = req.params.arguments?.symbol ? String(req.params.arguments.symbol) : null;
|
|
327
364
|
const transitive = Boolean(req.params.arguments?.transitive);
|
|
328
365
|
const rows = await this.searcher.getDependencies(filePath, project, symbol, transitive);
|
|
366
|
+
this.logger.debug('McpServer: get_dependencies', { file: filePath, project, transitive, count: rows.length, ms: Date.now() - t0 });
|
|
329
367
|
if (rows.length === 0) {
|
|
330
368
|
return { content: [{ type: 'text', text: `No dependencies found for: ${filePath}` }] };
|
|
331
369
|
}
|
|
@@ -337,6 +375,7 @@ class McpServer {
|
|
|
337
375
|
const symbol = req.params.arguments?.symbol ? String(req.params.arguments.symbol) : null;
|
|
338
376
|
const transitive = Boolean(req.params.arguments?.transitive);
|
|
339
377
|
const rows = await this.searcher.getDependents(filePath, project, symbol, transitive);
|
|
378
|
+
this.logger.debug('McpServer: get_dependents', { file: filePath, project, symbol, transitive, count: rows.length, ms: Date.now() - t0 });
|
|
340
379
|
if (rows.length > 0) {
|
|
341
380
|
return { content: [{ type: 'text', text: this.formatDependents(filePath, rows, transitive) }] };
|
|
342
381
|
}
|
|
@@ -367,6 +406,7 @@ class McpServer {
|
|
|
367
406
|
const validKinds = ['function', 'class', 'type', 'interface', 'variable', 'method', 'all'];
|
|
368
407
|
const kind = validKinds.includes(rawKind) ? rawKind : 'all';
|
|
369
408
|
const { hits, truncated } = await this.searcher.findSymbol(name, project, kind);
|
|
409
|
+
this.logger.debug('McpServer: find_symbol', { name, kind, project, count: hits.length, ms: Date.now() - t0 });
|
|
370
410
|
if (hits.length === 0) {
|
|
371
411
|
return { content: [{ type: 'text', text: `No definitions found for symbol: ${name}` }] };
|
|
372
412
|
}
|
|
@@ -386,6 +426,7 @@ class McpServer {
|
|
|
386
426
|
const depth = isNaN(rawDepth) || rawDepth < 1 ? 2 : Math.min(5, rawDepth);
|
|
387
427
|
const pattern = req.params.arguments?.pattern ? String(req.params.arguments.pattern) : null;
|
|
388
428
|
const { tree, error } = this.searcher.listFiles(relPath, project, depth, pattern);
|
|
429
|
+
this.logger.debug('McpServer: list_files', { path: relPath, project, depth, ms: Date.now() - t0 });
|
|
389
430
|
if (error) {
|
|
390
431
|
return { content: [{ type: 'text', text: `list_files error: ${error}` }] };
|
|
391
432
|
}
|
|
@@ -396,6 +437,7 @@ class McpServer {
|
|
|
396
437
|
const rawMax = Number(req.params.arguments?.max_lines);
|
|
397
438
|
const maxLines = isNaN(rawMax) || rawMax < 1 ? 300 : Math.min(1000, rawMax);
|
|
398
439
|
const result = await this.searcher.readFileAbsolute(filePath, maxLines);
|
|
440
|
+
this.logger.debug('McpServer: read_file', { file: filePath, maxLines, ok: result.ok, ms: Date.now() - t0 });
|
|
399
441
|
if (!result.ok) {
|
|
400
442
|
const message = result.reason === 'invalid_path' ? `Invalid path (outside workspace): ${filePath}` :
|
|
401
443
|
`File not found on disk: ${filePath}`;
|
|
@@ -408,6 +450,23 @@ class McpServer {
|
|
|
408
450
|
}
|
|
409
451
|
return { content: [{ type: 'text', text }] };
|
|
410
452
|
}
|
|
453
|
+
if (req.params.name === 'get_callers') {
|
|
454
|
+
const symbol = String(req.params.arguments?.symbol ?? '');
|
|
455
|
+
const project = String(req.params.arguments?.project ?? '');
|
|
456
|
+
const calleeFile = req.params.arguments?.callee_file ? String(req.params.arguments.callee_file) : null;
|
|
457
|
+
const rows = await this.searcher.getCallers(symbol, project, calleeFile);
|
|
458
|
+
this.logger.debug('McpServer: get_callers', { symbol, project, count: rows.length, ms: Date.now() - t0 });
|
|
459
|
+
if (rows.length === 0) {
|
|
460
|
+
return { content: [{ type: 'text', text: `No call sites found for \`${symbol}\` in project \`${project}\`. Run \`rag-index backfill --calls\` if the call graph has not been populated yet.` }] };
|
|
461
|
+
}
|
|
462
|
+
const lines = [`**Call sites of \`${symbol}\`** (${rows.length} found)\n`];
|
|
463
|
+
for (const r of rows) {
|
|
464
|
+
const loc = r.line != null ? ` line ${r.line}` : '';
|
|
465
|
+
const ctx = r.fromSymbol ? `, in \`${r.fromSymbol}\`` : '';
|
|
466
|
+
lines.push(` → \`${r.fromFile}\`${loc}${ctx}`);
|
|
467
|
+
}
|
|
468
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
469
|
+
}
|
|
411
470
|
throw new Error(`Unknown tool: ${req.params.name}`);
|
|
412
471
|
}
|
|
413
472
|
catch (err) {
|
|
@@ -416,6 +475,15 @@ class McpServer {
|
|
|
416
475
|
}
|
|
417
476
|
});
|
|
418
477
|
await server.connect(new stdio_js_1.StdioServerTransport());
|
|
478
|
+
this.logger.info('McpServer: started', { version: PKG_VERSION });
|
|
479
|
+
}
|
|
480
|
+
formatGraphContext(ctx) {
|
|
481
|
+
const parts = [];
|
|
482
|
+
if (ctx?.imports.length)
|
|
483
|
+
parts.push(`**Imports:** ${ctx.imports.join(' · ')}`);
|
|
484
|
+
if (ctx?.usedBy.length)
|
|
485
|
+
parts.push(`**Used by:** ${ctx.usedBy.join(' · ')}`);
|
|
486
|
+
return parts.length > 0 ? '\n' + parts.join('\n') : '';
|
|
419
487
|
}
|
|
420
488
|
formatChunk(r, label) {
|
|
421
489
|
return `${label ? `*(${label})*\n` : ''}**File:** ${r.filePath}\n**Project:** ${r.serviceName}\n**Line:** ${r.startLine}\n\`\`\`\n${r.content}\n\`\`\``;
|
|
@@ -3,7 +3,9 @@ import type { IEmbedder } from '../interfaces/IEmbedder';
|
|
|
3
3
|
export declare class OllamaEmbedder implements IEmbedder {
|
|
4
4
|
private config;
|
|
5
5
|
private client;
|
|
6
|
+
rerank?: (query: string, documents: string[], topN: number) => Promise<number[]>;
|
|
6
7
|
constructor(config: Config);
|
|
7
8
|
embed(text: string): Promise<number[]>;
|
|
8
9
|
translate(text: string, model: string): Promise<string>;
|
|
10
|
+
private _rerank;
|
|
9
11
|
}
|
|
@@ -5,9 +5,18 @@ const ollama_1 = require("ollama");
|
|
|
5
5
|
class OllamaEmbedder {
|
|
6
6
|
config;
|
|
7
7
|
client;
|
|
8
|
+
rerank;
|
|
8
9
|
constructor(config) {
|
|
9
10
|
this.config = config;
|
|
10
11
|
this.client = new ollama_1.Ollama({ host: config.ollama.host });
|
|
12
|
+
if (config.search.rerankerEnabled) {
|
|
13
|
+
if (config.ollama.rerankerModel) {
|
|
14
|
+
this.rerank = this._rerank.bind(this);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
console.warn('[OllamaEmbedder] RERANKER_ENABLED=true but RERANKER_MODEL is not set — reranking disabled');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
11
20
|
}
|
|
12
21
|
async embed(text) {
|
|
13
22
|
const { embedding } = await this.client.embeddings({
|
|
@@ -23,5 +32,25 @@ class OllamaEmbedder {
|
|
|
23
32
|
});
|
|
24
33
|
return response.message.content.trim();
|
|
25
34
|
}
|
|
35
|
+
async _rerank(query, documents, topN) {
|
|
36
|
+
const res = await fetch(this.config.ollama.rerankerUrl, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
signal: AbortSignal.timeout(30_000),
|
|
40
|
+
body: JSON.stringify({
|
|
41
|
+
model: this.config.ollama.rerankerModel,
|
|
42
|
+
query,
|
|
43
|
+
documents,
|
|
44
|
+
top_n: topN,
|
|
45
|
+
truncate: true,
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const body = (await res.text().catch(() => res.statusText)).slice(0, 200);
|
|
50
|
+
throw new Error(`Ollama rerank failed: ${res.status} ${body}`);
|
|
51
|
+
}
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
return data.results.map(r => r.index);
|
|
54
|
+
}
|
|
26
55
|
}
|
|
27
56
|
exports.OllamaEmbedder = OllamaEmbedder;
|
|
@@ -17,6 +17,10 @@ export interface NeighborResult {
|
|
|
17
17
|
prevChunk: ChunkRow | null;
|
|
18
18
|
nextChunk: ChunkRow | null;
|
|
19
19
|
}
|
|
20
|
+
export interface GraphContext {
|
|
21
|
+
imports: string[];
|
|
22
|
+
usedBy: string[];
|
|
23
|
+
}
|
|
20
24
|
export type FileRangeResult = {
|
|
21
25
|
ok: true;
|
|
22
26
|
filePath: string;
|
|
@@ -46,6 +50,13 @@ export interface GrepMatch {
|
|
|
46
50
|
line: number;
|
|
47
51
|
}
|
|
48
52
|
export type SymbolKind = 'function' | 'class' | 'type' | 'interface' | 'variable' | 'method' | 'all';
|
|
53
|
+
export interface CallerRow {
|
|
54
|
+
fromFile: string;
|
|
55
|
+
fromSymbol: string | null;
|
|
56
|
+
calleeName: string;
|
|
57
|
+
calleeFile: string;
|
|
58
|
+
line: number | null;
|
|
59
|
+
}
|
|
49
60
|
export interface SymbolHit {
|
|
50
61
|
filePath: string;
|
|
51
62
|
line: number;
|
|
@@ -66,8 +77,10 @@ export declare class SearcherService {
|
|
|
66
77
|
listProjects(): Promise<ProjectRow[]>;
|
|
67
78
|
searchChunks(query: string, limit: number, project: string | null, threshold?: number): Promise<SearchResult[]>;
|
|
68
79
|
fetchNeighbors(results: SearchResult[]): Promise<NeighborResult[]>;
|
|
80
|
+
fetchGraphContext(results: SearchResult[]): Promise<Map<string, GraphContext>>;
|
|
69
81
|
getDependencies(filePath: string, project: string, symbol?: string | null, transitive?: boolean): Promise<DependencyRow[]>;
|
|
70
82
|
getDependents(filePath: string, project: string, symbol?: string | null, transitive?: boolean): Promise<DependencyRow[]>;
|
|
83
|
+
getCallers(symbol: string, project: string, calleeFile?: string | null): Promise<CallerRow[]>;
|
|
71
84
|
grepSymbol(symbol: string, project: string, excludeFile: string): {
|
|
72
85
|
matches: GrepMatch[];
|
|
73
86
|
truncated: boolean;
|