codesearch-mcp 0.0.16-alpha → 0.0.17-alpha

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/README.md +73 -34
  3. package/dist/app.d.ts +1 -1
  4. package/dist/app.js +67 -16
  5. package/dist/src/commands/BackfillCommand.js +15 -5
  6. package/dist/src/commands/ConfigCommand.d.ts +12 -0
  7. package/dist/src/commands/ConfigCommand.js +98 -0
  8. package/dist/src/commands/IndexCommand.js +1 -1
  9. package/dist/src/config-store.d.ts +53 -0
  10. package/dist/src/config-store.js +197 -0
  11. package/dist/src/config.d.ts +22 -8
  12. package/dist/src/config.js +103 -58
  13. package/dist/src/container.d.ts +1 -1
  14. package/dist/src/container.js +8 -7
  15. package/dist/src/db/index.d.ts +1 -1
  16. package/dist/src/db/index.js +2 -1
  17. package/dist/src/db/schema.d.ts +160 -0
  18. package/dist/src/db/schema.js +12 -1
  19. package/dist/src/help.js +28 -12
  20. package/dist/src/interfaces/IAstParser.d.ts +14 -1
  21. package/dist/src/interfaces/IEmbedder.d.ts +1 -0
  22. package/dist/src/project-selector.d.ts +14 -5
  23. package/dist/src/project-selector.js +74 -60
  24. package/dist/src/services/AstParserService.d.ts +5 -1
  25. package/dist/src/services/AstParserService.js +107 -16
  26. package/dist/src/services/DatabaseService.js +18 -0
  27. package/dist/src/services/IndexerService.d.ts +7 -0
  28. package/dist/src/services/IndexerService.js +160 -40
  29. package/dist/src/services/LoggerService.d.ts +5 -1
  30. package/dist/src/services/LoggerService.js +8 -5
  31. package/dist/src/services/McpServer.d.ts +1 -0
  32. package/dist/src/services/McpServer.js +72 -4
  33. package/dist/src/services/OllamaEmbedder.d.ts +2 -0
  34. package/dist/src/services/OllamaEmbedder.js +29 -0
  35. package/dist/src/services/SearcherService.d.ts +13 -0
  36. package/dist/src/services/SearcherService.js +118 -22
  37. package/dist/src/setup.js +57 -62
  38. 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 analysisMap = await this.ast.analyzeProject(files, serviceName, dir);
177
- for (const filePath of files) {
178
- if (signal?.aborted) {
179
- bar.stop();
180
- console.log(`\n Interrupted — ${serviceName}`);
181
- stats.elapsedSec = Math.round((Date.now() - startTime) / 100) / 10;
182
- return stats;
183
- }
184
- bar.update({ filename: path.relative(dir, filePath) });
185
- const mtimeMs = fs.statSync(filePath).mtimeMs;
186
- const analysis = analysisMap.get(filePath);
187
- if (analysis === undefined && /\.(ts|tsx|js|jsx)$/.test(filePath) && !/\.d\.ts$/.test(filePath)) {
188
- this.logger.warn('IndexerService: no pre-computed analysis for TS file', { file: filePath });
189
- }
190
- const result = await this.ingestFile(filePath, serviceName, mtimeMs, dir, analysis);
191
- if (result.pendingMtimeRefresh) {
192
- mtimeRefreshBatch.push({ relFilePath: result.pendingMtimeRefresh, mtimeMs });
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
- getEnrichedPrompt(chunk, analysis, serviceName, relPath) {
227
- const { pageContent, metadata } = chunk;
228
- const header = `Project: ${serviceName} | Path: ${relPath}`;
229
- if (!analysis || analysis.symbols.length === 0) {
230
- return `${header}\n${pageContent}`;
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
- const chunkStartLine = metadata?.loc?.lines?.from ?? 1;
233
- const chunkEndLine = metadata?.loc?.lines?.to ?? chunkStartLine;
234
- const relevantSymbols = analysis.symbols.filter(s => s.line != null && s.endLine != null && s.line <= chunkEndLine && s.endLine >= chunkStartLine);
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
- const symbolsTag = `[Symbols: ${relevantSymbols.map(s => `${s.kind} ${s.name}`).join(', ')}]`;
239
- return `${header}\n${symbolsTag}\n${pageContent}`;
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, analysis, serviceName, relPath);
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 fromEnv(name?: string): LoggerService;
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 fromEnv(name) {
54
- const level = (process.env.LOG_LEVEL ?? 'error');
55
- const logDirEnv = process.env.LOG_DIR;
56
- const enabled = logDirEnv !== 'false' && logDirEnv !== '0';
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 = logDirEnv || DEFAULT_LOG_DIR;
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. 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.
87
- 6. Use \`list_files\` to explore project structure before diving into files. Use \`read_file\` to read a complete file by absolute path.
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;