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