graph-indexer 1.0.0 → 1.0.2

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/mcp-server.mjs CHANGED
@@ -1,19 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * @file mcp-server.mjs
4
- * @description MCP Server to expose in-memory graph index with hybrid search capabilities. Connects to local Ollama instance for embedding generation. Zero external dependencies.
4
+ * @description MCP Server — graph-indexer hybrid search with infinite scalability,
5
+ * cross-file symbol resolution, and query-driven token pruning.
5
6
  * @author MaquinaTech <https://github.com/MaquinaTech>
6
7
  * @copyright (c) 2026 MaquinaTech. All rights reserved.
7
- * @license GPL-3.0-only
8
- * * This program is free software: you can redistribute it and/or modify
9
- * it under the terms of the GNU General Public License as published by
10
- * the Free Software Foundation, either version 3 of the License, or
11
- * (at your option) any later version.
12
- *
13
- * This program is distributed in the hope that it will be useful,
14
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
15
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16
- * GNU General Public License for more details.
8
+ * @license MIT
17
9
  */
18
10
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
19
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -32,61 +24,94 @@ const PID_FILE = resolve(PROJECT_ROOT, ".idx-daemon.pid");
32
24
  // ─── Daemon Orchestration ─────────────────────────────────────────────────────
33
25
 
34
26
  function ensureDaemonRunning() {
35
- // 1. Check if a daemon is already running to avoid duplicate processes
36
27
  if (fs.existsSync(PID_FILE)) {
37
28
  const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8'), 10);
38
29
  try {
39
- process.kill(pid, 0); // Esto no mata el proceso, solo comprueba si existe
40
- process.stderr.write(`✅ Daemon is already active in background (PID: ${pid}).\n`);
30
+ process.kill(pid, 0);
31
+ process.stderr.write(`✅ Daemon already active (PID: ${pid}).\n`);
41
32
  return;
42
- } catch (e) {
43
- // The process does not exist (crashed or exited improperly), clean up the stale pid file
33
+ } catch {
44
34
  fs.unlinkSync(PID_FILE);
45
35
  }
46
36
  }
47
-
48
- // 2. Spawn the Daemon in detached mode
49
37
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
50
38
  const daemonPath = path.join(__dirname, "watch-daemon.mjs");
51
-
52
39
  const logPath = path.join(PROJECT_ROOT, ".idx-daemon.log");
53
- process.stderr.write(`🚀 Starting the Watcher Daemon in background...\n`);
54
- process.stderr.write(` Daemon log: ${logPath}\n`);
55
-
56
- // Open a log file in append mode so daemon crashes are always visible
40
+ process.stderr.write(`🚀 Starting Watcher Daemon...\n Log: ${logPath}\n`);
57
41
  let logFd;
58
42
  try { logFd = fs.openSync(logPath, 'a'); } catch { logFd = null; }
59
- const stdioOpt = logFd !== null ? ['ignore', logFd, logFd] : 'ignore';
60
-
61
43
  const child = spawn(process.execPath, [daemonPath], {
62
44
  detached: true,
63
- stdio: stdioOpt,
45
+ stdio: logFd !== null ? ['ignore', logFd, logFd] : 'ignore',
64
46
  env: { ...process.env, MCP_PROJECT_ROOT: PROJECT_ROOT }
65
47
  });
66
-
67
- // 3. Unlink the child process so parent doesn't wait on its IO
68
48
  child.unref();
69
49
  if (logFd !== null) fs.closeSync(logFd);
70
-
71
- // Save the PID for future checks
72
50
  fs.writeFileSync(PID_FILE, child.pid.toString());
73
51
  }
74
52
 
75
- // ─── Inicialización del Servidor ──────────────────────────────────────────────
53
+ // ─── Frontier 3: Query-Driven Body Pruning ───────────────────────────────────
54
+
55
+ /**
56
+ * Extracts just the function signature (first N lines up to opening brace).
57
+ */
58
+ function extractSignatureLine(codeSnippet) {
59
+ if (!codeSnippet) return '';
60
+ const lines = codeSnippet.split('\n');
61
+ const sigLines = [];
62
+ for (let i = 0; i < Math.min(lines.length, 10); i++) {
63
+ sigLines.push(lines[i]);
64
+ const l = lines[i];
65
+ // Stop at opening brace or arrow that ends the signature
66
+ if (i > 0 && (l.trimEnd().endsWith('{') || l.includes('=>') || l.trimEnd().endsWith(':'))) break;
67
+ }
68
+ return sigLines.join('\n');
69
+ }
70
+
71
+ /**
72
+ * Prune a function body: keep signature + lines containing any query token + return/throw lines.
73
+ * Falls back to first maxLines lines when no match found.
74
+ */
75
+ function pruneBodyByQuery(codeSnippet, queryTokens, maxLines = 40) {
76
+ if (!codeSnippet) return '';
77
+ const lines = codeSnippet.split('\n');
78
+ if (lines.length <= maxLines) return codeSnippet;
79
+
80
+ const querySet = new Set(queryTokens.filter(t => t.length >= 3).map(t => t.toLowerCase()));
81
+ if (querySet.size === 0) return lines.slice(0, maxLines).join('\n') + '\n// …';
82
+
83
+ // Always include: opening lines (signature + brace), closing lines, return/throw
84
+ const SIG_LINES = Math.min(5, lines.length);
85
+ const TAIL_LINES = Math.min(3, lines.length);
86
+ const sigBlock = lines.slice(0, SIG_LINES);
87
+ const tailBlock = lines.slice(Math.max(lines.length - TAIL_LINES, SIG_LINES));
88
+
89
+ const bodyLines = lines.slice(SIG_LINES, lines.length - TAIL_LINES);
90
+ const relevant = bodyLines.filter(line => {
91
+ const ll = line.toLowerCase();
92
+ if (/^\s*(return|throw|raise|yield)\b/.test(ll)) return true;
93
+ return [...querySet].some(token => ll.includes(token));
94
+ });
95
+
96
+ if (relevant.length === 0) return lines.slice(0, maxLines).join('\n') + '\n// …';
97
+ return [...sigBlock, ...relevant, ...tailBlock].join('\n');
98
+ }
99
+
100
+ // ─── Server Initialization ────────────────────────────────────────────────────
76
101
 
77
- // Wake the watcher before loading the index
78
102
  ensureDaemonRunning();
103
+ const version = "1.0.2";
104
+ const server = new McpServer({ name: "graph-indexer", version });
79
105
 
80
- const server = new McpServer({ name: "graph-indexer", version: "1.0.0" });
81
- const db = new MemoryGraphIndex(INDEX_PATH);
106
+ // Use lazy (disk-backed) vector loading for large corpora — cacheEmbeddings:false
107
+ const db = new MemoryGraphIndex(INDEX_PATH, { cacheEmbeddings: false });
108
+ try { db.load(); } catch { process.stderr.write("⏳ Waiting for initial indexing…\n"); }
82
109
 
83
- // If the index doesn't exist (first run), the newly started daemon will create it
84
- // Perform a safe load
85
- try { db.load(); } catch (e) { process.stderr.write("⏳ Waiting for initial indexing...\n"); }
110
+ // Graceful shutdown
111
+ process.on('SIGTERM', () => { db.close(); process.exit(0); });
112
+ process.on('SIGINT', () => { db.close(); process.exit(0); });
86
113
 
87
- // ─── Graph Topology Resource ──────────────────────────────────────────────────
88
- // Exposes the bidirectional dependency graph via the graph:// URI scheme.
89
- // Agents can inspect the topology of any indexed file without running a search.
114
+ // ─── Graph Resource ───────────────────────────────────────────────────────────
90
115
 
91
116
  server.resource(
92
117
  "graph-dependencies",
@@ -94,60 +119,55 @@ server.resource(
94
119
  list: async () => ({
95
120
  resources: Object.keys(db.graph.dependencies).map(fp => ({
96
121
  uri: `graph://dependencies/${encodeURIComponent(fp)}`,
97
- name: fp,
98
- mimeType: "text/markdown",
122
+ name: fp, mimeType: "text/markdown",
99
123
  description: `Dependency topology for ${fp}`,
100
124
  }))
101
125
  })
102
126
  }),
103
127
  async (uri, { file_path }) => {
104
- const decodedPath = decodeURIComponent(String(file_path));
105
- const dependencies = db.graph.dependencies[decodedPath] || [];
106
- const importedBy = db.graph.importedBy[decodedPath] || [];
107
-
128
+ const p = decodeURIComponent(String(file_path));
129
+ const deps = db.graph.dependencies[p] || [];
130
+ const usedBy = db.graph.importedBy[p] || [];
108
131
  const md = [
109
- `# Dependency Topology: \`${decodedPath}\``,
110
- ``,
111
- `## Imports (${dependencies.length})`,
112
- dependencies.length
113
- ? dependencies.map(d => `- \`${d}\``).join('\n')
114
- : '_No local imports_',
115
- ``,
116
- `## Imported By (${importedBy.length})`,
117
- importedBy.length
118
- ? importedBy.map(d => `- \`${d}\``).join('\n')
119
- : '_No files import this_',
132
+ `# Dependency Topology: \`${p}\``, '',
133
+ `## Imports (${deps.length})`,
134
+ deps.length ? deps.map(d => `- \`${d}\``).join('\n') : '_No local imports_', '',
135
+ `## Imported By (${usedBy.length})`,
136
+ usedBy.length ? usedBy.map(d => `- \`${d}\``).join('\n') : '_No files import this_',
120
137
  ].join('\n');
121
-
122
- return {
123
- contents: [{ uri: uri.href, mimeType: "text/markdown", text: md }]
124
- };
138
+ return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: md }] };
125
139
  }
126
140
  );
127
141
 
142
+ // ─── search_code ──────────────────────────────────────────────────────────────
143
+
128
144
  server.tool(
129
145
  "search_code",
130
- "Hybrid semantic + lexical search (RRF). Returns compact signature cards for all results and fills remaining token budget with code bodies. Use get_chunk(id) to expand any result.",
146
+ "CRITICAL: ALWAYS USE THIS TOOL FIRST to find code. High-precision AST hybrid search returning exact chunks and cross-file topology.",
131
147
  {
132
- query: z.string().describe("What logic to search for. Example: 'JWT authentication in middleware'"),
133
- exact_tokens: z.string().optional().describe("Exact variable or function name to boost in results"),
148
+ query: z.string().describe("Natural language description of the logic to find."),
149
+ exact_tokens: z.string().optional().describe("Exact symbol name for guaranteed rank-1 placement."),
134
150
  include_topology: z.boolean().default(true),
135
- min_score: z.number().min(0).max(1).default(0.3).describe("Minimum cosine similarity threshold (0–1)."),
136
- top_k: z.number().int().min(1).max(20).default(5).describe("Number of results to return (1–20)."),
151
+ min_score: z.number().min(0).max(1).default(0.3),
152
+ top_k: z.number().int().min(1).max(20).default(5),
137
153
  token_budget: z.number().int().min(100).optional().describe(
138
- "Optional estimated token budget for code snippets (1 token ≈ 4 chars). All signature cards are always shown; snippets fill the remaining budget in rank order."
154
+ "Token budget for code bodies (1 token ≈ 4 chars). Omit to use smart default."
155
+ ),
156
+ detail: z.enum(['signatures', 'smart', 'full']).default('smart').describe(
157
+ "'signatures': compact cards only (~20 tok each, no bodies) — fastest. " +
158
+ "'smart' (default): signatures + query-relevant body snippets. " +
159
+ "'full': signatures + complete bodies."
139
160
  ),
140
161
  },
141
- async ({ query, exact_tokens, include_topology, min_score, top_k, token_budget }) => {
162
+ async ({ query, exact_tokens, include_topology, min_score, top_k, token_budget, detail }) => {
142
163
  try {
143
- const fullQueryText = exact_tokens ? `${query} ${exact_tokens}` : query;
164
+ const fullQuery = exact_tokens ? `${query} ${exact_tokens}` : query;
144
165
  let queryVector = null;
145
- try { queryVector = await getLocalEmbedding(fullQueryText); } catch { /* degrade to lexical */ }
166
+ try { queryVector = await getLocalEmbedding(fullQuery); } catch { /* lexical fallback */ }
146
167
 
147
- const matches = db.searchHybrid(fullQueryText, queryVector, top_k, min_score, exact_tokens || null);
168
+ const matches = db.searchHybrid(fullQuery, queryVector, top_k, min_score, exact_tokens || null);
148
169
  if (matches.length === 0) return { content: [{ type: "text", text: "No results found." }] };
149
170
 
150
- // Helper: build a compact dep signature string for a file path
151
171
  const depSignature = (depPath) => {
152
172
  const syms = [];
153
173
  for (const c of db.chunks.values()) {
@@ -159,15 +179,23 @@ server.tool(
159
179
  return syms.length ? `${depPath} [${syms.join(', ')}]` : depPath;
160
180
  };
161
181
 
162
- const lines = [`🔍 QUERY: "${fullQueryText}" — ${matches.length} result(s)\n`];
182
+ const lines = [`🔍 QUERY: "${fullQuery}" — ${matches.length} result(s)\n`];
163
183
 
164
- // ── Phase 1: Signature cards (always emitted, ~40 tokens each) ────────────────
184
+ // Phase 1: Signature cards (always emitted)
165
185
  for (let i = 0; i < matches.length; i++) {
166
186
  const { score, chunk } = matches[i];
167
187
  lines.push(`${'─'.repeat(50)}`);
168
188
  lines.push(`#${i + 1} · **${chunk.name}** [${chunk.node_type}]`);
169
189
  lines.push(`📄 ${chunk.file_path}:${chunk.start_line}–${chunk.end_line} · ID: \`${chunk.id}\` · RRF: ${score.toFixed(4)}`);
170
- if (chunk.docstring) lines.push(`💬 ${chunk.docstring.slice(0, 120).replace(/\n/g, ' ')}`);
190
+
191
+ // Frontier 3: show return type and key params in card
192
+ const sig = [];
193
+ if (chunk.params?.length) sig.push(`(${chunk.params.slice(0, 4).join(', ')})`);
194
+ if (chunk.return_type) sig.push(`→ ${chunk.return_type}`);
195
+ if (chunk.type_refs?.length) sig.push(`types: ${chunk.type_refs.slice(0, 4).join(', ')}`);
196
+ if (sig.length) lines.push(`🔤 ${sig.join(' ')}`);
197
+
198
+ if (chunk.docstring) lines.push(`💬 ${chunk.docstring.slice(0, 140).replace(/\n/g, ' ')}`);
171
199
 
172
200
  if (include_topology) {
173
201
  const deps = (db.graph.dependencies[chunk.file_path] || []).slice(0, 3);
@@ -176,24 +204,37 @@ server.tool(
176
204
  if (usedBy.length) lines.push(`⬆️ Used by: ${usedBy.join(', ')}`);
177
205
  if (chunk.calls?.length) lines.push(`🔗 Calls: ${chunk.calls.slice(0, 6).join(', ')}`);
178
206
  }
179
- lines.push(`↩️ Expand body: get_chunk("${chunk.id}")`);
207
+ lines.push(`↩️ Expand: get_chunk("${chunk.id}")`);
180
208
  }
181
209
 
182
- // ── Phase 2: Code snippets — fill token budget in rank order ──────────────
183
- const CHARS_PER_TOKEN = 4;
184
- const MAX_SNIPPET_CHARS = token_budget != null ? 0 : 1500; // default: show rank-1 snippet
185
- let remainingChars = token_budget != null ? token_budget * CHARS_PER_TOKEN : MAX_SNIPPET_CHARS;
186
-
187
- lines.push(`\n${'═'.repeat(50)}`);
188
- lines.push(`CODE BODIES (budget: ${token_budget != null ? token_budget + ' tok' : '1500 chars rank-1'})\n`);
189
-
190
- for (const { chunk } of matches) {
191
- if (remainingChars <= 0) break;
192
- const snippet = (chunk.code_snippet || '').slice(0, remainingChars);
193
- if (!snippet) continue;
194
- lines.push(`### ${chunk.name} — ${chunk.file_path}`);
195
- lines.push('```\n' + snippet + '\n```\n');
196
- remainingChars -= snippet.length;
210
+ // Phase 2: Code bodies — controlled by `detail`
211
+ if (detail !== 'signatures') {
212
+ const CHARS_PER_TOKEN = 4;
213
+ const defaultBudget = detail === 'full' ? 6000 : 2000;
214
+ let remainingChars = token_budget != null ? token_budget * CHARS_PER_TOKEN : defaultBudget;
215
+
216
+ lines.push(`\n${'═'.repeat(50)}`);
217
+ lines.push(`CODE BODIES (detail: ${detail}, budget: ~${Math.round(remainingChars / CHARS_PER_TOKEN)} tok)\n`);
218
+
219
+ const queryTokens = fullQuery.toLowerCase().split(/[\s\W_]+/).filter(t => t.length >= 3);
220
+
221
+ for (const { chunk } of matches) {
222
+ if (remainingChars <= 0) break;
223
+ const raw = chunk.code_snippet || '';
224
+ if (!raw) continue;
225
+
226
+ let snippet;
227
+ if (detail === 'full') {
228
+ snippet = raw.slice(0, remainingChars);
229
+ } else {
230
+ // 'smart': query-driven pruning for long functions
231
+ snippet = pruneBodyByQuery(raw, queryTokens).slice(0, remainingChars);
232
+ }
233
+
234
+ lines.push(`### ${chunk.name} — ${chunk.file_path}`);
235
+ lines.push('```\n' + snippet + '\n```\n');
236
+ remainingChars -= snippet.length;
237
+ }
197
238
  }
198
239
 
199
240
  return { content: [{ type: "text", text: lines.join("\n") }] };
@@ -203,25 +244,42 @@ server.tool(
203
244
  }
204
245
  );
205
246
 
247
+ // ─── get_chunk ────────────────────────────────────────────────────────────────
248
+
206
249
  server.tool(
207
250
  "get_chunk",
208
- "Returns the full source body of a specific indexed code chunk by ID. Use AFTER search_code when you need more than the signature.",
209
- { chunk_id: z.string().describe("The chunk ID shown in search_code results (e.g. \"abc123def456\").") },
210
- async ({ chunk_id }) => {
251
+ "CRITICAL: Use this INSTEAD of reading full files. Returns the complete body of a function/class by its chunk_id from search_code results.",
252
+ {
253
+ chunk_id: z.string().describe("The chunk ID shown in search_code results."),
254
+ view: z.enum(['full', 'signature']).default('full').describe(
255
+ "'full': complete source body. 'signature': just the function signature line (~5 tokens)."
256
+ ),
257
+ },
258
+ async ({ chunk_id, view }) => {
211
259
  try {
212
260
  const chunk = db.chunks.get(chunk_id);
213
261
  if (!chunk) return { content: [{ type: "text", text: `Chunk '${chunk_id}' not found. Run search_code to get valid IDs.` }] };
262
+
214
263
  const parts = [
215
264
  `# ${chunk.name}`,
216
265
  `**File:** \`${chunk.file_path}\` · **Lines:** ${chunk.start_line}–${chunk.end_line} · **Type:** ${chunk.node_type}`,
217
266
  ];
267
+ if (chunk.params?.length) parts.push(`**Params:** ${chunk.params.join(', ')}`);
268
+ if (chunk.return_type) parts.push(`**Returns:** ${chunk.return_type}`);
269
+ if (chunk.type_refs?.length) parts.push(`**Type refs:** ${chunk.type_refs.join(', ')}`);
218
270
  if (chunk.docstring) parts.push(`**Doc:** ${chunk.docstring}`);
271
+
219
272
  const deps = db.graph.dependencies[chunk.file_path] || [];
220
273
  const usedBy = db.graph.importedBy[chunk.file_path] || [];
221
274
  if (deps.length) parts.push(`⬇️ Imports: ${deps.join(', ')}`);
222
275
  if (usedBy.length) parts.push(`⬆️ Used by: ${usedBy.join(', ')}`);
223
276
  if (chunk.calls?.length) parts.push(`🔗 Calls: ${chunk.calls.join(', ')}`);
224
- parts.push('', '```', chunk.code_snippet, '```');
277
+
278
+ if (view === 'signature') {
279
+ parts.push('', '```', extractSignatureLine(chunk.code_snippet), '```');
280
+ } else {
281
+ parts.push('', '```', chunk.code_snippet, '```');
282
+ }
225
283
  return { content: [{ type: "text", text: parts.join('\n') }] };
226
284
  } catch (err) {
227
285
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
@@ -229,30 +287,102 @@ server.tool(
229
287
  }
230
288
  );
231
289
 
290
+ // ─── resolve_symbol (Frontier 2) ─────────────────────────────────────────────
291
+
292
+ server.tool(
293
+ "resolve_symbol",
294
+ "Frontier 2: 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.",
295
+ {
296
+ symbol: z.string().describe("Exact symbol name (e.g. 'validateToken', 'User', 'PaymentService')."),
297
+ },
298
+ async ({ symbol }) => {
299
+ try {
300
+ const key = symbol.toLowerCase().trim();
301
+ const chunkIds = db.symbolTable.get(key);
302
+
303
+ if (!chunkIds || chunkIds.size === 0) {
304
+ return { content: [{ type: "text", text: `Symbol '${symbol}' not in index. Try search_code(query="${symbol}") for fuzzy search.` }] };
305
+ }
306
+
307
+ const lines = [`# Symbol: \`${symbol}\` — ${chunkIds.size} definition(s)\n`];
308
+ for (const chunkId of chunkIds) {
309
+ const chunk = db.chunks.get(chunkId);
310
+ if (!chunk) continue;
311
+ lines.push(`${'─'.repeat(50)}`);
312
+ lines.push(`**${chunk.name}** [${chunk.node_type}]`);
313
+ lines.push(`📄 ${chunk.file_path}:${chunk.start_line}–${chunk.end_line} · ID: \`${chunk.id}\``);
314
+ if (chunk.params?.length) lines.push(`🔤 Params: ${chunk.params.join(', ')}`);
315
+ if (chunk.return_type) lines.push(`🔤 Returns: ${chunk.return_type}`);
316
+ if (chunk.type_refs?.length) lines.push(`🔗 Type refs: ${chunk.type_refs.join(', ')}`);
317
+ if (chunk.docstring) lines.push(`💬 ${chunk.docstring.slice(0, 160).replace(/\n/g, ' ')}`);
318
+ const deps = (db.graph.dependencies[chunk.file_path] || []).slice(0, 4);
319
+ const usedBy = (db.graph.importedBy[chunk.file_path] || []).slice(0, 4);
320
+ if (deps.length) lines.push(`⬇️ Imports: ${deps.join(', ')}`);
321
+ if (usedBy.length) lines.push(`⬆️ Used by: ${usedBy.join(', ')}`);
322
+ if (chunk.calls?.length) lines.push(`🔗 Calls: ${chunk.calls.slice(0, 8).join(', ')}`);
323
+ lines.push(`\n\`\`\`\n${extractSignatureLine(chunk.code_snippet)}\n\`\`\``);
324
+ lines.push(`↩️ Full body: get_chunk("${chunk.id}")`);
325
+ }
326
+ return { content: [{ type: "text", text: lines.join('\n') }] };
327
+ } catch (err) {
328
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
329
+ }
330
+ }
331
+ );
332
+
333
+ // ─── get_chunk_summary (Frontier 3) ──────────────────────────────────────────
334
+
335
+ server.tool(
336
+ "get_chunk_summary",
337
+ "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.",
338
+ {
339
+ chunk_id: z.string().describe("The chunk ID from search_code results."),
340
+ },
341
+ async ({ chunk_id }) => {
342
+ try {
343
+ const chunk = db.chunks.get(chunk_id);
344
+ if (!chunk) return { content: [{ type: "text", text: `Chunk '${chunk_id}' not found.` }] };
345
+
346
+ const lines = [
347
+ `# ${chunk.name} · ${chunk.file_path}:${chunk.start_line}–${chunk.end_line}`,
348
+ `**Type:** ${chunk.node_type}`,
349
+ ];
350
+ if (chunk.params?.length) lines.push(`**Params:** ${chunk.params.join(', ')}`);
351
+ if (chunk.return_type) lines.push(`**Returns:** ${chunk.return_type}`);
352
+ if (chunk.type_refs?.length) lines.push(`**Type refs:** ${chunk.type_refs.join(', ')}`);
353
+ if (chunk.docstring) lines.push(`\n**Doc:** ${chunk.docstring.slice(0, 300)}`);
354
+ if (chunk.calls?.length) lines.push(`**Calls:** ${chunk.calls.join(', ')}`);
355
+ lines.push('', '```', extractSignatureLine(chunk.code_snippet), '```');
356
+ lines.push(`\n↩️ Full body: get_chunk("${chunk.id}")`);
357
+
358
+ return { content: [{ type: "text", text: lines.join('\n') }] };
359
+ } catch (err) {
360
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
361
+ }
362
+ }
363
+ );
364
+
365
+ // ─── get_file_skeleton ────────────────────────────────────────────────────────
366
+
232
367
  server.tool(
233
368
  "get_file_skeleton",
234
- "Returns ONLY the function and class signatures of a file. Use this to quickly understand a file's structure without consuming thousands of tokens reading the full code.",
235
- { file_path: z.string().describe("Relative path to the file (e.g., 'src/app.ts')") },
369
+ "Returns all top-level exports and definitions in a file with line numbers — no code bodies (~50 tokens vs 5000).",
370
+ { file_path: z.string().describe("Relative path (e.g. 'src/app.ts').") },
236
371
  async ({ file_path }) => {
237
372
  try {
238
373
  const absolutePath = resolve(PROJECT_ROOT, file_path);
239
- // Guard against path traversal attacks
240
374
  const safeRoot = path.normalize(PROJECT_ROOT);
241
375
  if (!path.normalize(absolutePath).startsWith(safeRoot + path.sep) &&
242
376
  path.normalize(absolutePath) !== safeRoot) {
243
377
  throw new Error("Access denied: path is outside the project root.");
244
378
  }
245
379
  if (!fs.existsSync(absolutePath)) throw new Error("File not found.");
246
-
247
380
  const content = fs.readFileSync(absolutePath, 'utf-8');
248
381
  const ext = path.extname(absolutePath);
249
382
  const parser = getParserForFile(ext);
250
-
251
- if (!parser) return { content: [{ type: "text", text: "Language not supported for AST parsing." }] };
252
-
253
- const tree = parser.parse(content);
383
+ if (!parser) return { content: [{ type: "text", text: "Language not supported." }] };
384
+ const tree = parser.parse((offset) => offset < content.length ? content.slice(offset, offset + 4096) : null);
254
385
  const skeleton = extractFileSkeleton(tree.rootNode, content);
255
-
256
386
  return { content: [{ type: "text", text: `# Skeleton: ${file_path}\n\n${skeleton || "_No semantic signatures found_"}` }] };
257
387
  } catch (err) {
258
388
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
@@ -260,36 +390,230 @@ server.tool(
260
390
  }
261
391
  );
262
392
 
393
+ // ─── get_call_graph ───────────────────────────────────────────────────────────
394
+
263
395
  server.tool(
264
396
  "get_call_graph",
265
- "Finds all code chunks across the entire repository that invoke a specific function. CRITICAL for safe refactoring.",
266
- { target_function: z.string().describe("The exact name of the function being called (e.g., 'validateToken')") },
397
+ "Finds all chunks that call a specific function. CRITICAL for safe refactoring.",
398
+ { target_function: z.string().describe("Exact function name (e.g. 'validateToken').") },
267
399
  async ({ target_function }) => {
268
400
  try {
269
401
  const callers = [];
270
402
  for (const chunk of db.chunks.values()) {
271
- if (chunk.calls && chunk.calls.includes(target_function)) {
272
- callers.push(`- [${chunk.node_type}] \`${chunk.name}\` in \`${chunk.file_path}\` (lines ${chunk.start_line}-${chunk.end_line})`);
403
+ if (chunk.calls?.includes(target_function)) {
404
+ callers.push(`- [${chunk.node_type}] \`${chunk.name}\` in \`${chunk.file_path}\` (lines ${chunk.start_line}–${chunk.end_line})`);
273
405
  }
274
406
  }
275
-
276
407
  if (callers.length === 0) {
277
- return { content: [{ type: "text", text: `✅ Safe to modify: No local functions found calling '${target_function}'.` }] };
408
+ return { content: [{ type: "text", text: `✅ Safe to modify: no callers of '${target_function}' found.` }] };
278
409
  }
410
+ return {
411
+ content: [{
412
+ type: "text", text: [
413
+ `# ⚠️ Call Graph: \`${target_function}\``,
414
+ `${callers.length} caller(s) depend on this — review before changing signature:`,
415
+ ...callers
416
+ ].join('\n')
417
+ }]
418
+ };
419
+ } catch (err) {
420
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
421
+ }
422
+ }
423
+ );
279
424
 
280
- const md = [
281
- `# ⚠️ Cross-File Call Graph: \`${target_function}\``,
282
- `The following ${callers.length} function(s) depend on this method. You must review them before modifying the target's signature:`,
283
- ...callers
284
- ].join('\n');
425
+ // ─── get_repo_map ─────────────────────────────────────────────────────────────
285
426
 
286
- return { content: [{ type: "text", text: md }] };
427
+ /**
428
+ * Compute simplified PageRank over the file dependency graph.
429
+ * Files imported by many others receive higher rank (= more important).
430
+ */
431
+ function computePageRank(graph, iters = 30, damping = 0.85) {
432
+ const files = Object.keys(graph.dependencies || {});
433
+ const N = files.length;
434
+ if (N === 0) return new Map();
435
+
436
+ const idx = new Map(files.map((f, i) => [f, i]));
437
+ const ranks = new Float64Array(N).fill(1.0 / N);
438
+ const outDeg = files.map(f => Math.max((graph.dependencies[f] || []).length, 1));
439
+
440
+ for (let iter = 0; iter < iters; iter++) {
441
+ const next = new Float64Array(N).fill((1 - damping) / N);
442
+ for (let i = 0; i < N; i++) {
443
+ const contrib = damping * ranks[i] / outDeg[i];
444
+ for (const dep of (graph.dependencies[files[i]] || [])) {
445
+ const j = idx.get(dep);
446
+ if (j !== undefined) next[j] += contrib;
447
+ }
448
+ }
449
+ ranks.set(next);
450
+ }
451
+ return new Map(files.map((f, i) => [f, ranks[i]]));
452
+ }
453
+
454
+ server.tool(
455
+ "get_repo_map",
456
+ "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.",
457
+ {
458
+ path_filter: z.string().optional().describe(
459
+ "Only include files whose path contains this string (e.g. 'auth', 'api/v2', 'src/core')."
460
+ ),
461
+ max_files: z.number().int().min(1).max(300).default(80).describe(
462
+ "Max files to include in the map."
463
+ ),
464
+ sort_by: z.enum(['importance', 'path']).default('importance').describe(
465
+ "'importance' (default): most-imported files first (PageRank). 'path': alphabetical."
466
+ ),
467
+ },
468
+ async ({ path_filter, max_files, sort_by }) => {
469
+ try {
470
+ // Group chunks by file
471
+ const fileChunks = new Map();
472
+ for (const chunk of db.chunks.values()) {
473
+ if (path_filter && !chunk.file_path.toLowerCase().includes(path_filter.toLowerCase())) continue;
474
+ if (chunk.name === 'anonymous' || chunk.name === 'default_export') continue;
475
+ if (!fileChunks.has(chunk.file_path)) fileChunks.set(chunk.file_path, []);
476
+ fileChunks.get(chunk.file_path).push(chunk);
477
+ }
478
+
479
+ if (fileChunks.size === 0) {
480
+ return {
481
+ content: [{
482
+ type: 'text', text: path_filter
483
+ ? `No files found matching '${path_filter}'. Try a broader filter.`
484
+ : 'Index is empty. Run `npm run mcp:index` first.'
485
+ }]
486
+ };
487
+ }
488
+
489
+ // Sort files
490
+ let sortedFiles = Array.from(fileChunks.keys());
491
+ if (sort_by === 'importance') {
492
+ const pr = computePageRank(db.graph);
493
+ sortedFiles.sort((a, b) => (pr.get(b) ?? 0) - (pr.get(a) ?? 0));
494
+ } else {
495
+ sortedFiles.sort();
496
+ }
497
+ sortedFiles = sortedFiles.slice(0, max_files);
498
+
499
+ // Render compact map
500
+ const totalFiles = fileChunks.size;
501
+ const totalSymbols = Array.from(fileChunks.values()).reduce((s, a) => s + a.length, 0);
502
+ const lines = [
503
+ `# Repo Map — ${totalSymbols} symbols across ${totalFiles} files`,
504
+ path_filter ? `(filtered to '${path_filter}')` : '',
505
+ sortedFiles.length < totalFiles
506
+ ? `(showing top ${sortedFiles.length} by ${sort_by}; use path_filter to narrow)\n`
507
+ : '',
508
+ ].filter(Boolean);
509
+
510
+ for (const filePath of sortedFiles) {
511
+ const chunks = fileChunks.get(filePath);
512
+ lines.push(`\n${filePath}`);
513
+ // Deduplicate by name (prefer class/function over expression_statement)
514
+ const seen = new Set();
515
+ const deduped = [];
516
+ for (const c of chunks) {
517
+ const key = c.name.toLowerCase();
518
+ if (seen.has(key)) continue;
519
+ seen.add(key);
520
+ deduped.push(c);
521
+ }
522
+ for (const c of deduped.slice(0, 8)) {
523
+ const kind = c.node_type.includes('class') ? 'class'
524
+ : c.node_type.includes('interface') ? 'interface'
525
+ : c.node_type.includes('enum') ? 'enum'
526
+ : c.node_type.includes('struct') ? 'struct'
527
+ : c.node_type.includes('trait') ? 'trait'
528
+ : 'fn';
529
+ const params = c.params?.length
530
+ ? `(${c.params.slice(0, 3).join(', ')}${c.params.length > 3 ? ', …' : ''})`
531
+ : '';
532
+ const ret = c.return_type ? ` → ${c.return_type.slice(0, 40)}` : '';
533
+ lines.push(` ${kind} ${c.name}${params}${ret}`);
534
+ }
535
+ if (deduped.length > 8) lines.push(` … (${deduped.length - 8} more)`);
536
+ }
537
+ if (totalFiles > max_files) {
538
+ lines.push(`\n… ${totalFiles - max_files} more files not shown. Use path_filter or increase max_files.`);
539
+ }
540
+
541
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
542
+ } catch (err) {
543
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
544
+ }
545
+ }
546
+ );
547
+
548
+ // ─── list_index_stats ─────────────────────────────────────────────────────────
549
+
550
+ server.tool(
551
+ "list_index_stats",
552
+ "Returns index health: chunk count, embedding status, daemon status, search mode, and index freshness.",
553
+ {},
554
+ async () => {
555
+ try {
556
+ const chunkCount = db.chunks.size;
557
+ const fileSet = new Set([...db.chunks.values()].map(c => c.file_path));
558
+ const hasVectors = db.vectors.size > 0 || db._vecOffsets.size > 0;
559
+ const embeddingsEnabled = process.env.INDEXER_EMBEDDINGS !== 'off';
560
+
561
+ let indexAge = 'unknown';
562
+ try {
563
+ const ageMs = Date.now() - fs.statSync(INDEX_PATH).mtimeMs;
564
+ const ageSec = Math.floor(ageMs / 1000);
565
+ indexAge = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : `${Math.floor(ageSec / 3600)}h ago`;
566
+ } catch { }
567
+
568
+ let daemonStatus = 'unknown';
569
+ try {
570
+ if (fs.existsSync(PID_FILE)) {
571
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8'), 10);
572
+ process.kill(pid, 0);
573
+ daemonStatus = `running (PID: ${pid})`;
574
+ } else { daemonStatus = 'not running'; }
575
+ } catch { daemonStatus = 'not running (stale PID)'; }
576
+
577
+ const extCounts = new Map();
578
+ for (const chunk of db.chunks.values()) {
579
+ const ext = chunk.file_path.split('.').pop() || 'unknown';
580
+ extCounts.set(ext, (extCounts.get(ext) || 0) + 1);
581
+ }
582
+
583
+ const lazyModeActive = db._lazyMode;
584
+ const vectorSource = lazyModeActive
585
+ ? (db._vecFd >= 0 ? 'disk-backed fd' : 'buffer-lazy')
586
+ : 'eager (in-memory matrix)';
587
+ const searchMode = !embeddingsEnabled
588
+ ? '🔤 Lexical only (INDEXER_EMBEDDINGS=off)'
589
+ : hasVectors
590
+ ? `🧠 Hybrid (semantic + lexical RRF) — vectors: ${vectorSource}`
591
+ : '🔤 Lexical only (Ollama unavailable or not yet indexed)';
592
+
593
+ const lines = [
594
+ `# 📊 graph-indexer Index Stats`, '',
595
+ `| Metric | Value |`, `| :--- | :--- |`,
596
+ `| **Chunks** | ${chunkCount} |`,
597
+ `| **Files indexed** | ${fileSet.size} |`,
598
+ `| **Symbols in table** | ${db.symbolTable.size} |`,
599
+ `| **Vector entries** | ${lazyModeActive ? db._vecOffsets.size : db.vectors.size} |`,
600
+ `| **Search mode** | ${searchMode} |`,
601
+ `| **Lazy vec mode** | ${lazyModeActive ? '✅ Yes (enterprise scale)' : '❌ No (small corpus)'} |`,
602
+ `| **Daemon** | ${daemonStatus} |`,
603
+ `| **Index age** | ${indexAge} |`,
604
+ '', `## Extension Breakdown`,
605
+ ...Array.from(extCounts.entries()).sort((a, b) => b[1] - a[1]).map(([e, n]) => `- .${e}: ${n} chunks`),
606
+ ];
607
+ if (chunkCount === 0) lines.push('', `⚠️ Index empty. Run \`npm run mcp:index\`.`);
608
+ return { content: [{ type: "text", text: lines.join('\n') }] };
287
609
  } catch (err) {
288
610
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
289
611
  }
290
612
  }
291
613
  );
292
614
 
615
+ // ─── Connect ──────────────────────────────────────────────────────────────────
616
+
293
617
  const transport = new StdioServerTransport();
294
618
  await server.connect(transport);
295
- process.stderr.write("✅ graph-indexer MCP server running (v1.0.0).\n");
619
+ process.stderr.write(`✅ graph-indexer MCP server running (v${version}).\n`);