graph-indexer 1.2.0 → 2.1.0

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 (70) hide show
  1. package/CHANGELOG.md +854 -0
  2. package/README.md +258 -595
  3. package/SECURITY.md +85 -0
  4. package/TRADEMARK.md +31 -0
  5. package/cli-ui.mjs +78 -0
  6. package/colbert.mjs +168 -0
  7. package/config.mjs +408 -32
  8. package/daemon-ctl.mjs +175 -0
  9. package/daemon-lock.mjs +69 -0
  10. package/embedders/python/mlx_embed_server.py +61 -0
  11. package/embedders/python/requirements-mlx.txt +5 -0
  12. package/embedders/setup-mlx.mjs +110 -0
  13. package/embeddings.mjs +571 -0
  14. package/engine/binary.mjs +437 -0
  15. package/{core-engine.mjs → engine/memory.mjs} +321 -441
  16. package/{sqlite-store.mjs → engine/sqlite.mjs} +413 -45
  17. package/enrichment.mjs +206 -32
  18. package/git-signals.mjs +248 -0
  19. package/indexer.mjs +399 -45
  20. package/init.mjs +1626 -171
  21. package/layout.mjs +123 -0
  22. package/mcp/centrality.mjs +117 -0
  23. package/mcp/format.mjs +61 -0
  24. package/mcp/resolver.mjs +116 -0
  25. package/mcp/symbolgraph.mjs +137 -0
  26. package/mcp/taint.mjs +225 -0
  27. package/mcp/tools.mjs +1582 -0
  28. package/mcp/topology.mjs +676 -0
  29. package/mcp-server.mjs +80 -25
  30. package/package.json +66 -27
  31. package/parse/extractor.mjs +628 -0
  32. package/parse/imports.mjs +365 -0
  33. package/parse/interprocedural.mjs +134 -0
  34. package/parse/languages.mjs +253 -0
  35. package/parse/metadata.mjs +701 -0
  36. package/parse/routes.mjs +327 -0
  37. package/parse/scip.mjs +391 -0
  38. package/parse/taint-patterns.mjs +119 -0
  39. package/prompts/CORE.md +91 -0
  40. package/prompts/DOMAIN_TEMPLATE.md +46 -0
  41. package/prompts/INTEGRATION.md +123 -0
  42. package/prompts/frameworks/ANDROID.md +17 -0
  43. package/prompts/frameworks/ASPNET_CORE.md +19 -0
  44. package/prompts/frameworks/FASTAPI_DJANGO.md +20 -0
  45. package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
  46. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +19 -0
  47. package/prompts/frameworks/RAILS.md +20 -0
  48. package/prompts/frameworks/REACT.md +20 -0
  49. package/prompts/frameworks/SPRING_BOOT.md +20 -0
  50. package/prompts/languages/BASH.md +22 -0
  51. package/prompts/languages/C.md +21 -0
  52. package/prompts/languages/CSHARP.md +24 -0
  53. package/prompts/languages/CSS_SCSS.md +22 -0
  54. package/prompts/languages/GO.md +24 -0
  55. package/prompts/languages/JAVA.md +23 -0
  56. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
  57. package/prompts/languages/KOTLIN.md +24 -0
  58. package/prompts/languages/PHP.md +23 -0
  59. package/prompts/languages/PYTHON.md +23 -0
  60. package/prompts/languages/RUBY.md +23 -0
  61. package/prompts/languages/RUST.md +21 -0
  62. package/prompts/languages/SWIFT.md +21 -0
  63. package/seal.mjs +400 -0
  64. package/search-core.mjs +405 -44
  65. package/search-sparse.mjs +183 -0
  66. package/storage.mjs +33 -3
  67. package/watch-daemon.mjs +75 -24
  68. package/PROMPT.md +0 -182
  69. package/mcp-tools.mjs +0 -545
  70. package/parser-utils.mjs +0 -1170
package/mcp-tools.mjs DELETED
@@ -1,545 +0,0 @@
1
- /**
2
- * @file mcp-tools.mjs
3
- * @description MCP tool definitions for graph-indexer. Every tool is written
4
- * against the storage contract (see storage.mjs) — never a concrete
5
- * backend — so the identical surface serves both the in-memory engine
6
- * and the disk-backed SQLite store. `registerTools(server, db, opts)`
7
- * wires them onto an McpServer instance.
8
- * @author MaquinaTech <https://github.com/MaquinaTech>
9
- * @copyright (c) 2026 MaquinaTech. All rights reserved.
10
- * @license MIT
11
- */
12
- import { z } from 'zod';
13
- import fs from 'fs';
14
- import path, { resolve } from 'path';
15
- import { computePageRank, isNaturalLanguageQuery } from './search-core.mjs';
16
- import { getLocalEmbedding, getParserForFile, extractFileSkeleton } from './parser-utils.mjs';
17
- import { rerankResults, ollamaGenerate } from './enrichment.mjs';
18
-
19
- // ─── Rendering helpers ──────────────────────────────────────────────────────────
20
-
21
- /** Extract just the function signature (first lines up to the opening brace). */
22
- export function extractSignatureLine(codeSnippet) {
23
- if (!codeSnippet) return '';
24
- const lines = codeSnippet.split('\n');
25
- const sigLines = [];
26
- for (let i = 0; i < Math.min(lines.length, 10); i++) {
27
- sigLines.push(lines[i]);
28
- const l = lines[i];
29
- if (i > 0 && (l.trimEnd().endsWith('{') || l.includes('=>') || l.trimEnd().endsWith(':'))) break;
30
- }
31
- return sigLines.join('\n');
32
- }
33
-
34
- /**
35
- * Prune a function body: keep signature + query-relevant lines + tail.
36
- *
37
- * Semantic fallback: when no lexical token matches (the agent used a high-level
38
- * description like "authentication bottleneck" that isn't in the code verbatim),
39
- * preserve the structural skeleton — control-flow lines and calls — rather than
40
- * blindly truncating, so 'smart' detail always returns meaningful context.
41
- */
42
- export function pruneBodyByQuery(codeSnippet, queryTokens, maxLines = 40) {
43
- if (!codeSnippet) return '';
44
- const lines = codeSnippet.split('\n');
45
- if (lines.length <= maxLines) return codeSnippet;
46
-
47
- const querySet = new Set(queryTokens.filter(t => t.length >= 3).map(t => t.toLowerCase()));
48
- if (querySet.size === 0) return lines.slice(0, maxLines).join('\n') + '\n// …';
49
-
50
- const SIG_LINES = Math.min(5, lines.length);
51
- const TAIL_LINES = Math.min(3, lines.length);
52
- const sigBlock = lines.slice(0, SIG_LINES);
53
- const tailBlock = lines.slice(Math.max(lines.length - TAIL_LINES, SIG_LINES));
54
-
55
- const bodyLines = lines.slice(SIG_LINES, lines.length - TAIL_LINES);
56
- const relevant = bodyLines.filter(line => {
57
- const ll = line.toLowerCase();
58
- if (/^\s*(return|throw|raise|yield)\b/.test(ll)) return true;
59
- return [...querySet].some(token => ll.includes(token));
60
- });
61
-
62
- if (relevant.length === 0) {
63
- const budget = Math.max(4, maxLines - SIG_LINES - TAIL_LINES);
64
- const structural = bodyLines.filter(line => {
65
- const ll = line.trimStart().toLowerCase();
66
- if (/^(if |else |for |while |switch |try |catch |finally |return |throw |raise |yield |await )/.test(ll)) return true;
67
- if (/[a-zA-Z_]\w*\s*\(/.test(line) && line.trim().length > 4) return true;
68
- return false;
69
- }).slice(0, budget);
70
- if (structural.length > 0) return [...sigBlock, ...structural, ...tailBlock].join('\n');
71
- return lines.slice(0, maxLines).join('\n') + '\n// …';
72
- }
73
- return [...sigBlock, ...relevant, ...tailBlock].join('\n');
74
- }
75
-
76
- // ─── Tool registration ──────────────────────────────────────────────────────────
77
-
78
- /**
79
- * Register every graph-indexer tool on an MCP server.
80
- *
81
- * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
82
- * @param {object} db A loaded store implementing the storage contract.
83
- * @param {object} opts
84
- * @param {string} opts.projectRoot
85
- * @param {string} opts.artifactPath Index file whose mtime represents freshness.
86
- * @param {string} opts.pidFile Watch-daemon PID file (may not exist).
87
- * @param {boolean} opts.embeddingsEnabled
88
- * @param {string} [opts.ollamaHost] Resolved Ollama endpoint for query embedding.
89
- * @param {string} [opts.embedModel] Embedding model (must match the index).
90
- * @param {{enabled:boolean, model:string, topM:number}} [opts.rerank] LLM rerank config.
91
- */
92
- export function registerTools(server, db, { projectRoot, artifactPath, pidFile, embeddingsEnabled, ollamaHost, embedModel, rerank }) {
93
-
94
- // ─── search_code ────────────────────────────────────────────────────────────
95
- server.tool(
96
- 'search_code',
97
- 'CRITICAL: ALWAYS USE THIS TOOL FIRST to find code. High-precision AST hybrid search returning exact chunks and cross-file topology.',
98
- {
99
- query: z.string().describe('Natural language description of the logic to find.'),
100
- exact_tokens: z.string().optional().describe('Exact symbol name for guaranteed rank-1 placement.'),
101
- include_topology: z.boolean().default(true),
102
- min_score: z.number().min(0).max(1).default(0.3),
103
- top_k: z.number().int().min(1).max(20).default(5),
104
- token_budget: z.number().int().min(100).optional().describe(
105
- 'Token budget for code bodies (1 token ≈ 4 chars). Omit to use smart default.'
106
- ),
107
- detail: z.enum(['signatures', 'smart', 'full']).default('smart').describe(
108
- "'signatures': compact cards only (~20 tok each, no bodies) — fastest. "
109
- + "'smart' (default): signatures + query-relevant body snippets. "
110
- + "'full': signatures + complete bodies."
111
- ),
112
- rerank: z.boolean().optional().describe(
113
- 'Rerank the top results with a local LLM judge (+50% rank-1 on '
114
- + 'natural-language queries, ~1–2 s extra). Defaults to the '
115
- + '`rerank.enabled` project config; only fires on NL queries.'
116
- ),
117
- },
118
- async ({ query, exact_tokens, include_topology, min_score, top_k, token_budget, detail, rerank: rerankParam }) => {
119
- try {
120
- const fullQuery = exact_tokens ? `${query} ${exact_tokens}` : query;
121
- let queryVector = null;
122
- try { queryVector = await getLocalEmbedding(fullQuery, true, { ollamaHost, model: embedModel }); }
123
- catch { /* lexical fallback */ }
124
-
125
- let matches = db.searchHybrid(fullQuery, queryVector, top_k, min_score, exact_tokens || null);
126
-
127
- // Opt-in LLM rerank: only for natural-language queries (symbol
128
- // lookups are already rank-1-dominant), never when the caller
129
- // pinned an exact symbol. Best-effort — order is preserved on
130
- // any model failure.
131
- const wantRerank = rerankParam ?? Boolean(rerank?.enabled);
132
- if (wantRerank && !exact_tokens && matches.length > 1 && isNaturalLanguageQuery(fullQuery)) {
133
- matches = await rerankResults(fullQuery, matches, {
134
- topM: rerank?.topM ?? 8,
135
- generate: (prompt) => ollamaGenerate(prompt, {
136
- model: rerank?.model || 'qwen2.5-coder:7b',
137
- ollamaHost, timeoutMs: 20000,
138
- options: { temperature: 0, num_predict: 40 },
139
- }),
140
- });
141
- }
142
- if (matches.length === 0) return { content: [{ type: 'text', text: 'No results found.' }] };
143
-
144
- const depSignature = (depPath) => {
145
- const syms = [];
146
- for (const c of db.getChunksByFile(depPath)) {
147
- if (c.name && c.node_type !== 'expression_statement') {
148
- syms.push(c.name);
149
- if (syms.length >= 4) break;
150
- }
151
- }
152
- return syms.length ? `${depPath} [${syms.join(', ')}]` : depPath;
153
- };
154
-
155
- const lines = [`🔍 QUERY: "${fullQuery}" — ${matches.length} result(s)\n`];
156
-
157
- for (let i = 0; i < matches.length; i++) {
158
- const { score, chunk } = matches[i];
159
- lines.push(`${'─'.repeat(50)}`);
160
- lines.push(`#${i + 1} · **${chunk.name}** [${chunk.node_type}]`);
161
- lines.push(`📄 ${chunk.file_path}:${chunk.start_line}–${chunk.end_line} · ID: \`${chunk.id}\` · RRF: ${score.toFixed(4)}`);
162
-
163
- const sig = [];
164
- if (chunk.params?.length) sig.push(`(${chunk.params.slice(0, 4).join(', ')})`);
165
- if (chunk.return_type) sig.push(`→ ${chunk.return_type}`);
166
- if (chunk.type_refs?.length) sig.push(`types: ${chunk.type_refs.slice(0, 4).join(', ')}`);
167
- if (sig.length) lines.push(`🔤 ${sig.join(' ')}`);
168
-
169
- if (chunk.decorators?.length) lines.push(`🏷 ${chunk.decorators.slice(0, 6).map(d => '@' + d).join(' ')}`);
170
- if (chunk.extends?.length) lines.push(`🧬 extends/implements: ${chunk.extends.slice(0, 5).join(', ')}`);
171
- if (chunk.docstring) lines.push(`💬 ${chunk.docstring.slice(0, 140).replace(/\n/g, ' ')}`);
172
-
173
- if (include_topology) {
174
- const deps = db.getDependencies(chunk.file_path).slice(0, 3);
175
- const usedBy = db.getImportedBy(chunk.file_path).slice(0, 3);
176
- if (deps.length) lines.push(`⬇️ Deps: ${deps.map(depSignature).join(' | ')}`);
177
- if (usedBy.length) lines.push(`⬆️ Used by: ${usedBy.join(', ')}`);
178
- if (chunk.calls?.length) lines.push(`🔗 Calls: ${chunk.calls.slice(0, 6).join(', ')}`);
179
- }
180
- lines.push(`↩️ Expand: get_chunk("${chunk.id}")`);
181
- }
182
-
183
- if (detail !== 'signatures') {
184
- const CHARS_PER_TOKEN = 4;
185
- const defaultBudget = detail === 'full' ? 6000 : 2000;
186
- let remainingChars = token_budget != null ? token_budget * CHARS_PER_TOKEN : defaultBudget;
187
-
188
- lines.push(`\n${'═'.repeat(50)}`);
189
- lines.push(`CODE BODIES (detail: ${detail}, budget: ~${Math.round(remainingChars / CHARS_PER_TOKEN)} tok)\n`);
190
-
191
- const queryTokens = fullQuery.toLowerCase().split(/[\s\W_]+/).filter(t => t.length >= 3);
192
-
193
- for (const { chunk } of matches) {
194
- if (remainingChars <= 0) break;
195
- const raw = chunk.code_snippet || '';
196
- if (!raw) continue;
197
- const snippet = detail === 'full'
198
- ? raw.slice(0, remainingChars)
199
- : pruneBodyByQuery(raw, queryTokens).slice(0, remainingChars);
200
- lines.push(`### ${chunk.name} — ${chunk.file_path}`);
201
- lines.push('```\n' + snippet + '\n```\n');
202
- remainingChars -= snippet.length;
203
- }
204
- }
205
-
206
- return { content: [{ type: 'text', text: lines.join('\n') }] };
207
- } catch (err) {
208
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
209
- }
210
- }
211
- );
212
-
213
- // ─── get_chunk ────────────────────────────────────────────────────────────────
214
- server.tool(
215
- 'get_chunk',
216
- 'CRITICAL: Use this INSTEAD of reading full files. Returns the complete body of a function/class by its chunk_id from search_code results.',
217
- {
218
- chunk_id: z.string().describe('The chunk ID shown in search_code results.'),
219
- view: z.enum(['full', 'signature']).default('full').describe(
220
- "'full': complete source body. 'signature': just the function signature line (~5 tokens)."
221
- ),
222
- },
223
- async ({ chunk_id, view }) => {
224
- try {
225
- const chunk = db.getChunk(chunk_id);
226
- if (!chunk) return { content: [{ type: 'text', text: `Chunk '${chunk_id}' not found. Run search_code to get valid IDs.` }] };
227
-
228
- const parts = [
229
- `# ${chunk.name}`,
230
- `**File:** \`${chunk.file_path}\` · **Lines:** ${chunk.start_line}–${chunk.end_line} · **Type:** ${chunk.node_type}`,
231
- ];
232
- if (chunk.params?.length) parts.push(`**Params:** ${chunk.params.join(', ')}`);
233
- if (chunk.return_type) parts.push(`**Returns:** ${chunk.return_type}`);
234
- if (chunk.type_refs?.length) parts.push(`**Type refs:** ${chunk.type_refs.join(', ')}`);
235
- if (chunk.decorators?.length) parts.push(`**Decorators:** ${chunk.decorators.map(d => '@' + d).join(', ')}`);
236
- if (chunk.extends?.length) parts.push(`**Inherits:** ${chunk.extends.join(', ')}`);
237
- if (chunk.docstring) parts.push(`**Doc:** ${chunk.docstring}`);
238
-
239
- const deps = db.getDependencies(chunk.file_path);
240
- const usedBy = db.getImportedBy(chunk.file_path);
241
- if (deps.length) parts.push(`⬇️ Imports: ${deps.join(', ')}`);
242
- if (usedBy.length) parts.push(`⬆️ Used by: ${usedBy.join(', ')}`);
243
- if (chunk.calls?.length) parts.push(`🔗 Calls: ${chunk.calls.join(', ')}`);
244
-
245
- if (view === 'signature') parts.push('', '```', extractSignatureLine(chunk.code_snippet), '```');
246
- else parts.push('', '```', chunk.code_snippet, '```');
247
- return { content: [{ type: 'text', text: parts.join('\n') }] };
248
- } catch (err) {
249
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
250
- }
251
- }
252
- );
253
-
254
- // ─── resolve_symbol ─────────────────────────────────────────────────────────
255
- server.tool(
256
- 'resolve_symbol',
257
- 'Instantly finds the definition of any symbol (function, class, type, variable) by exact name — O(1) lookup, no search needed. Returns the defining chunk and cross-file topology.',
258
- { symbol: z.string().describe("Exact symbol name (e.g. 'validateToken', 'User', 'PaymentService').") },
259
- async ({ symbol }) => {
260
- try {
261
- const defs = db.resolveSymbol(symbol);
262
- if (defs.length === 0) {
263
- return { content: [{ type: 'text', text: `Symbol '${symbol}' not in index. Try search_code(query="${symbol}") for fuzzy search.` }] };
264
- }
265
- const lines = [`# Symbol: \`${symbol}\` — ${defs.length} definition(s)\n`];
266
- for (const chunk of defs) {
267
- lines.push(`${'─'.repeat(50)}`);
268
- lines.push(`**${chunk.name}** [${chunk.node_type}]`);
269
- lines.push(`📄 ${chunk.file_path}:${chunk.start_line}–${chunk.end_line} · ID: \`${chunk.id}\``);
270
- if (chunk.params?.length) lines.push(`🔤 Params: ${chunk.params.join(', ')}`);
271
- if (chunk.return_type) lines.push(`🔤 Returns: ${chunk.return_type}`);
272
- if (chunk.type_refs?.length) lines.push(`🔗 Type refs: ${chunk.type_refs.join(', ')}`);
273
- if (chunk.decorators?.length) lines.push(`🏷 ${chunk.decorators.slice(0, 6).map(d => '@' + d).join(' ')}`);
274
- if (chunk.docstring) lines.push(`💬 ${chunk.docstring.slice(0, 160).replace(/\n/g, ' ')}`);
275
- const deps = db.getDependencies(chunk.file_path).slice(0, 4);
276
- const usedBy = db.getImportedBy(chunk.file_path).slice(0, 4);
277
- if (deps.length) lines.push(`⬇️ Imports: ${deps.join(', ')}`);
278
- if (usedBy.length) lines.push(`⬆️ Used by: ${usedBy.join(', ')}`);
279
- if (chunk.calls?.length) lines.push(`🔗 Calls: ${chunk.calls.slice(0, 8).join(', ')}`);
280
- lines.push(`\n\`\`\`\n${extractSignatureLine(chunk.code_snippet)}\n\`\`\``);
281
- lines.push(`↩️ Full body: get_chunk("${chunk.id}")`);
282
- }
283
- return { content: [{ type: 'text', text: lines.join('\n') }] };
284
- } catch (err) {
285
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
286
- }
287
- }
288
- );
289
-
290
- // ─── get_chunk_summary ──────────────────────────────────────────────────────
291
- server.tool(
292
- 'get_chunk_summary',
293
- 'Returns the function/class signature + docstring + called functions — no full body. ~50 tokens vs ~300 for full body. Use when you only need to understand the interface, not the implementation.',
294
- {
295
- chunk_id: z.string().describe('The chunk ID from search_code results.'),
296
- expand_calls: z.boolean().default(false).describe(
297
- 'When true, resolves the signatures of outgoing dependencies inline (~150 tok vs ~50 tok). '
298
- + 'Use when you need to understand the interfaces of called functions in a single shot, '
299
- + 'without issuing a separate tool call per dependency.'
300
- ),
301
- },
302
- async ({ chunk_id, expand_calls }) => {
303
- try {
304
- const chunk = db.getChunk(chunk_id);
305
- if (!chunk) return { content: [{ type: 'text', text: `Chunk '${chunk_id}' not found.` }] };
306
-
307
- const lines = [
308
- `# ${chunk.name} · ${chunk.file_path}:${chunk.start_line}–${chunk.end_line}`,
309
- `**Type:** ${chunk.node_type}`,
310
- ];
311
- if (chunk.params?.length) lines.push(`**Params:** ${chunk.params.join(', ')}`);
312
- if (chunk.return_type) lines.push(`**Returns:** ${chunk.return_type}`);
313
- if (chunk.type_refs?.length) lines.push(`**Type refs:** ${chunk.type_refs.join(', ')}`);
314
- if (chunk.decorators?.length) lines.push(`**Decorators:** ${chunk.decorators.map(d => '@' + d).join(', ')}`);
315
- if (chunk.docstring) lines.push(`\n**Doc:** ${chunk.docstring.slice(0, 300)}`);
316
-
317
- if (chunk.calls?.length) {
318
- if (!expand_calls) {
319
- lines.push(`**Calls:** ${chunk.calls.join(', ')}`);
320
- } else {
321
- const expanded = [];
322
- const seen = new Set();
323
- const SIG_BUDGET = 6;
324
- for (const callName of chunk.calls) {
325
- if (seen.size >= SIG_BUDGET) break;
326
- const key = callName.toLowerCase();
327
- if (seen.has(key)) continue;
328
- seen.add(key);
329
- const target = db.resolveSymbol(callName)[0];
330
- if (!target?.code_snippet) continue;
331
- const sig = extractSignatureLine(target.code_snippet).split('\n')[0].trim().slice(0, 120);
332
- expanded.push(
333
- ` **${callName}** → \`${target.file_path}:${target.start_line}\``
334
- + `\n \`\`\`\n ${sig}\n \`\`\``
335
- );
336
- }
337
- const unresolved = chunk.calls.length - expanded.length;
338
- lines.push(`\n**Calls (${expanded.length} resolved${unresolved > 0 ? `, ${unresolved} unindexed` : ''}):**`);
339
- if (expanded.length > 0) lines.push(...expanded);
340
- else lines.push(' _(none resolved in index)_');
341
- }
342
- }
343
-
344
- lines.push('', '```', extractSignatureLine(chunk.code_snippet), '```');
345
- lines.push(`\n↩️ Full body: get_chunk("${chunk.id}")`);
346
- return { content: [{ type: 'text', text: lines.join('\n') }] };
347
- } catch (err) {
348
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
349
- }
350
- }
351
- );
352
-
353
- // ─── get_file_skeleton ──────────────────────────────────────────────────────
354
- server.tool(
355
- 'get_file_skeleton',
356
- 'Returns all top-level exports and definitions in a file with line numbers — no code bodies (~50 tokens vs 5000).',
357
- { file_path: z.string().describe("Relative path (e.g. 'src/app.ts').") },
358
- async ({ file_path }) => {
359
- try {
360
- const absolutePath = resolve(projectRoot, file_path);
361
- const safeRoot = path.normalize(projectRoot);
362
- if (!path.normalize(absolutePath).startsWith(safeRoot + path.sep) &&
363
- path.normalize(absolutePath) !== safeRoot) {
364
- throw new Error('Access denied: path is outside the project root.');
365
- }
366
- if (!fs.existsSync(absolutePath)) throw new Error('File not found.');
367
- const content = fs.readFileSync(absolutePath, 'utf-8');
368
- const ext = path.extname(absolutePath);
369
- const parser = getParserForFile(ext);
370
- if (!parser) return { content: [{ type: 'text', text: 'Language not supported.' }] };
371
- const tree = parser.parse((offset) => offset < content.length ? content.slice(offset, offset + 4096) : null);
372
- const skeleton = extractFileSkeleton(tree.rootNode, content);
373
- return { content: [{ type: 'text', text: `# Skeleton: ${file_path}\n\n${skeleton || '_No semantic signatures found_'}` }] };
374
- } catch (err) {
375
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
376
- }
377
- }
378
- );
379
-
380
- // ─── get_call_graph ─────────────────────────────────────────────────────────
381
- server.tool(
382
- 'get_call_graph',
383
- 'Finds all chunks that call a specific function. CRITICAL for safe refactoring.',
384
- { target_function: z.string().describe("Exact function name (e.g. 'validateToken').") },
385
- async ({ target_function }) => {
386
- try {
387
- const callers = db.findCallers(target_function).map(chunk =>
388
- `- [${chunk.node_type}] \`${chunk.name}\` in \`${chunk.file_path}\` (lines ${chunk.start_line}–${chunk.end_line})`
389
- );
390
- if (callers.length === 0) {
391
- return { content: [{ type: 'text', text: `✅ Safe to modify: no callers of '${target_function}' found.` }] };
392
- }
393
- return {
394
- content: [{
395
- type: 'text', text: [
396
- `# ⚠️ Call Graph: \`${target_function}\``,
397
- `${callers.length} caller(s) depend on this — review before changing signature:`,
398
- ...callers,
399
- ].join('\n')
400
- }]
401
- };
402
- } catch (err) {
403
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
404
- }
405
- }
406
- );
407
-
408
- // ─── get_repo_map ───────────────────────────────────────────────────────────
409
- server.tool(
410
- 'get_repo_map',
411
- 'Returns a compact symbol map of the entire codebase grouped by file, ordered by importance (most-imported files first via PageRank). Use this FIRST to orient yourself in an unfamiliar codebase — ~1-2k tokens vs reading every file. Combine with path_filter to focus on a subsystem.',
412
- {
413
- path_filter: z.string().optional().describe(
414
- "Only include files whose path contains this string (e.g. 'auth', 'api/v2', 'src/core')."
415
- ),
416
- max_files: z.number().int().min(1).max(300).default(80).describe('Max files to include in the map.'),
417
- sort_by: z.enum(['importance', 'path']).default('importance').describe(
418
- "'importance' (default): most-imported files first (PageRank). 'path': alphabetical."
419
- ),
420
- },
421
- async ({ path_filter, max_files, sort_by }) => {
422
- try {
423
- const fileChunks = new Map();
424
- const filterLower = path_filter ? path_filter.toLowerCase() : null;
425
- for (const chunk of db.iterateChunks()) {
426
- if (filterLower && !chunk.file_path.toLowerCase().includes(filterLower)) continue;
427
- if (chunk.name === 'anonymous' || chunk.name === 'default_export') continue;
428
- if (!fileChunks.has(chunk.file_path)) fileChunks.set(chunk.file_path, []);
429
- fileChunks.get(chunk.file_path).push(chunk);
430
- }
431
-
432
- if (fileChunks.size === 0) {
433
- return {
434
- content: [{
435
- type: 'text', text: path_filter
436
- ? `No files found matching '${path_filter}'. Try a broader filter.`
437
- : 'Index is empty. Run `npm run mcp:index` first.'
438
- }]
439
- };
440
- }
441
-
442
- let sortedFiles = Array.from(fileChunks.keys());
443
- if (sort_by === 'importance') {
444
- const pr = computePageRank(db.graph);
445
- sortedFiles.sort((a, b) => (pr.get(b) ?? 0) - (pr.get(a) ?? 0));
446
- } else {
447
- sortedFiles.sort();
448
- }
449
- sortedFiles = sortedFiles.slice(0, max_files);
450
-
451
- const totalFiles = fileChunks.size;
452
- const totalSymbols = Array.from(fileChunks.values()).reduce((s, a) => s + a.length, 0);
453
- const lines = [
454
- `# Repo Map — ${totalSymbols} symbols across ${totalFiles} files`,
455
- path_filter ? `(filtered to '${path_filter}')` : '',
456
- sortedFiles.length < totalFiles ? `(showing top ${sortedFiles.length} by ${sort_by}; use path_filter to narrow)\n` : '',
457
- ].filter(Boolean);
458
-
459
- for (const filePath of sortedFiles) {
460
- const chunks = fileChunks.get(filePath);
461
- lines.push(`\n${filePath}`);
462
- const seen = new Set();
463
- const deduped = [];
464
- for (const c of chunks) {
465
- const key = c.name.toLowerCase();
466
- if (seen.has(key)) continue;
467
- seen.add(key);
468
- deduped.push(c);
469
- }
470
- for (const c of deduped.slice(0, 8)) {
471
- const kind = c.node_type.includes('class') ? 'class'
472
- : c.node_type.includes('interface') ? 'interface'
473
- : c.node_type.includes('enum') ? 'enum'
474
- : c.node_type.includes('struct') ? 'struct'
475
- : c.node_type.includes('trait') ? 'trait' : 'fn';
476
- const params = c.params?.length
477
- ? `(${c.params.slice(0, 3).join(', ')}${c.params.length > 3 ? ', …' : ''})` : '';
478
- const ret = c.return_type ? ` → ${c.return_type.slice(0, 40)}` : '';
479
- lines.push(` ${kind} ${c.name}${params}${ret}`);
480
- }
481
- if (deduped.length > 8) lines.push(` … (${deduped.length - 8} more)`);
482
- }
483
- if (totalFiles > max_files) {
484
- lines.push(`\n… ${totalFiles - max_files} more files not shown. Use path_filter or increase max_files.`);
485
- }
486
- return { content: [{ type: 'text', text: lines.join('\n') }] };
487
- } catch (err) {
488
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
489
- }
490
- }
491
- );
492
-
493
- // ─── list_index_stats ───────────────────────────────────────────────────────
494
- server.tool(
495
- 'list_index_stats',
496
- 'Returns index health: chunk count, embedding status, daemon status, search mode, storage backend, and index freshness.',
497
- {},
498
- async () => {
499
- try {
500
- const s = db.stats();
501
-
502
- let indexAge = 'unknown';
503
- try {
504
- const ageSec = Math.floor((Date.now() - fs.statSync(artifactPath).mtimeMs) / 1000);
505
- indexAge = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : `${Math.floor(ageSec / 3600)}h ago`;
506
- } catch { }
507
-
508
- let daemonStatus = 'not running';
509
- try {
510
- if (pidFile && fs.existsSync(pidFile)) {
511
- const pid = parseInt(fs.readFileSync(pidFile, 'utf-8'), 10);
512
- process.kill(pid, 0);
513
- daemonStatus = `running (PID: ${pid})`;
514
- }
515
- } catch { daemonStatus = 'not running (stale PID)'; }
516
-
517
- const searchMode = !embeddingsEnabled
518
- ? '🔤 Lexical only (INDEXER_EMBEDDINGS=off)'
519
- : s.hasVectors
520
- ? `🧠 Hybrid (semantic + lexical RRF) — vectors: ${s.vectorSource}`
521
- : '🔤 Lexical only (Ollama unavailable or not yet indexed)';
522
-
523
- const lines = [
524
- `# 📊 graph-indexer Index Stats`, '',
525
- `| Metric | Value |`, `| :--- | :--- |`,
526
- `| **Storage backend** | ${s.backend === 'sqlite' ? '🗄 SQLite (disk-backed)' : '⚡ In-memory'} |`,
527
- `| **Chunks** | ${s.chunks} |`,
528
- `| **Files indexed** | ${s.files} |`,
529
- `| **Symbols in table** | ${s.symbols} |`,
530
- `| **Vector entries** | ${s.vectors} |`,
531
- `| **Search mode** | ${searchMode} |`,
532
- `| **Lazy vec mode** | ${s.lazyMode ? '✅ Yes (enterprise scale)' : '❌ No (small corpus)'} |`,
533
- `| **Daemon** | ${daemonStatus} |`,
534
- `| **Index age** | ${indexAge} |`,
535
- '', `## Extension Breakdown`,
536
- ...Array.from(s.extCounts.entries()).sort((a, b) => b[1] - a[1]).map(([e, n]) => `- .${e}: ${n} chunks`),
537
- ];
538
- if (s.chunks === 0) lines.push('', `⚠️ Index empty. Run \`npm run mcp:index\`.`);
539
- return { content: [{ type: 'text', text: lines.join('\n') }] };
540
- } catch (err) {
541
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
542
- }
543
- }
544
- );
545
- }