graph-indexer 1.0.1 → 1.0.3
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/PROMPT.md +143 -35
- package/README.md +227 -862
- package/core-engine.mjs +363 -159
- package/indexer.mjs +8 -8
- package/init.mjs +179 -68
- package/mcp-server.mjs +397 -150
- package/package.json +13 -13
- package/parser-utils.mjs +94 -23
- package/watch-daemon.mjs +2 -2
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
|
|
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
8
|
* @license MIT
|
|
8
|
-
* Copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
9
|
-
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
-
* of this software and associated documentation files (the "Software"), to deal
|
|
11
|
-
* in the Software without restriction, including without limitation the rights
|
|
12
|
-
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
-
* copies of the Software, and to permit persons to whom the Software is
|
|
14
|
-
* furnished to do so, subject to the following conditions: The above copyright
|
|
15
|
-
* notice and this permission notice shall be included in all copies or
|
|
16
|
-
* substantial portions of the Software.
|
|
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);
|
|
40
|
-
process.stderr.write(`✅ Daemon
|
|
30
|
+
process.kill(pid, 0);
|
|
31
|
+
process.stderr.write(`✅ Daemon already active (PID: ${pid}).\n`);
|
|
41
32
|
return;
|
|
42
|
-
} catch
|
|
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
|
|
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:
|
|
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
|
-
// ───
|
|
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.3";
|
|
104
|
+
const server = new McpServer({ name: "graph-indexer", version });
|
|
79
105
|
|
|
80
|
-
|
|
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
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
|
105
|
-
const
|
|
106
|
-
const
|
|
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: \`${
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
"CRITICAL: ALWAYS USE THIS TOOL FIRST to find code.
|
|
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("
|
|
133
|
-
exact_tokens: z.string().optional().describe("Exact
|
|
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)
|
|
136
|
-
top_k: z.number().int().min(1).max(20).default(5)
|
|
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
|
-
"
|
|
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
|
|
164
|
+
const fullQuery = exact_tokens ? `${query} ${exact_tokens}` : query;
|
|
144
165
|
let queryVector = null;
|
|
145
|
-
try { queryVector = await getLocalEmbedding(
|
|
166
|
+
try { queryVector = await getLocalEmbedding(fullQuery); } catch { /* lexical fallback */ }
|
|
146
167
|
|
|
147
|
-
const matches = db.searchHybrid(
|
|
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: "${
|
|
182
|
+
const lines = [`🔍 QUERY: "${fullQuery}" — ${matches.length} result(s)\n`];
|
|
163
183
|
|
|
164
|
-
//
|
|
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
|
-
|
|
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
|
|
207
|
+
lines.push(`↩️ Expand: get_chunk("${chunk.id}")`);
|
|
180
208
|
}
|
|
181
209
|
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
"CRITICAL: Use this INSTEAD of reading full files
|
|
209
|
-
{
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
235
|
-
{ file_path: z.string().describe("Relative path
|
|
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
|
-
|
|
383
|
+
if (!parser) return { content: [{ type: "text", text: "Language not supported." }] };
|
|
253
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,59 +390,180 @@ 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
|
|
266
|
-
{ target_function: z.string().describe("
|
|
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
|
|
272
|
-
callers.push(`- [${chunk.node_type}] \`${chunk.name}\` in \`${chunk.file_path}\` (lines ${chunk.start_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:
|
|
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
|
-
|
|
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
|
-
|
|
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') }] };
|
|
287
542
|
} catch (err) {
|
|
288
|
-
return { content: [{ type:
|
|
543
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
289
544
|
}
|
|
290
545
|
}
|
|
291
546
|
);
|
|
292
547
|
|
|
548
|
+
// ─── list_index_stats ─────────────────────────────────────────────────────────
|
|
549
|
+
|
|
293
550
|
server.tool(
|
|
294
551
|
"list_index_stats",
|
|
295
|
-
"Returns
|
|
552
|
+
"Returns index health: chunk count, embedding status, daemon status, search mode, and index freshness.",
|
|
296
553
|
{},
|
|
297
554
|
async () => {
|
|
298
555
|
try {
|
|
299
556
|
const chunkCount = db.chunks.size;
|
|
300
|
-
const fileSet = new Set();
|
|
301
|
-
|
|
302
|
-
const fileCount = fileSet.size;
|
|
303
|
-
|
|
304
|
-
const hasEmbeddings = db.vectors.size > 0;
|
|
557
|
+
const fileSet = new Set([...db.chunks.values()].map(c => c.file_path));
|
|
558
|
+
const hasVectors = db.vectors.size > 0 || db._vecOffsets.size > 0;
|
|
305
559
|
const embeddingsEnabled = process.env.INDEXER_EMBEDDINGS !== 'off';
|
|
306
560
|
|
|
307
561
|
let indexAge = 'unknown';
|
|
308
562
|
try {
|
|
309
|
-
const
|
|
310
|
-
const ageMs = Date.now() - stat.mtimeMs;
|
|
563
|
+
const ageMs = Date.now() - fs.statSync(INDEX_PATH).mtimeMs;
|
|
311
564
|
const ageSec = Math.floor(ageMs / 1000);
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
else indexAge = `${Math.floor(ageSec / 3600)}h ago`;
|
|
315
|
-
} catch { /* file not found */ }
|
|
565
|
+
indexAge = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : `${Math.floor(ageSec / 3600)}h ago`;
|
|
566
|
+
} catch { }
|
|
316
567
|
|
|
317
568
|
let daemonStatus = 'unknown';
|
|
318
569
|
try {
|
|
@@ -320,46 +571,40 @@ server.tool(
|
|
|
320
571
|
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8'), 10);
|
|
321
572
|
process.kill(pid, 0);
|
|
322
573
|
daemonStatus = `running (PID: ${pid})`;
|
|
323
|
-
} else {
|
|
324
|
-
daemonStatus = 'not running';
|
|
325
|
-
}
|
|
574
|
+
} else { daemonStatus = 'not running'; }
|
|
326
575
|
} catch { daemonStatus = 'not running (stale PID)'; }
|
|
327
576
|
|
|
328
|
-
// Extension distribution
|
|
329
577
|
const extCounts = new Map();
|
|
330
578
|
for (const chunk of db.chunks.values()) {
|
|
331
579
|
const ext = chunk.file_path.split('.').pop() || 'unknown';
|
|
332
580
|
extCounts.set(ext, (extCounts.get(ext) || 0) + 1);
|
|
333
581
|
}
|
|
334
582
|
|
|
583
|
+
const lazyModeActive = db._lazyMode;
|
|
584
|
+
const vectorSource = lazyModeActive
|
|
585
|
+
? (db._vecFd >= 0 ? 'disk-backed fd' : 'buffer-lazy')
|
|
586
|
+
: 'eager (in-memory matrix)';
|
|
335
587
|
const searchMode = !embeddingsEnabled
|
|
336
588
|
? '🔤 Lexical only (INDEXER_EMBEDDINGS=off)'
|
|
337
|
-
:
|
|
338
|
-
?
|
|
589
|
+
: hasVectors
|
|
590
|
+
? `🧠 Hybrid (semantic + lexical RRF) — vectors: ${vectorSource}`
|
|
339
591
|
: '🔤 Lexical only (Ollama unavailable or not yet indexed)';
|
|
340
592
|
|
|
341
593
|
const lines = [
|
|
342
|
-
`# 📊 graph-indexer Index Stats`,
|
|
343
|
-
|
|
344
|
-
`| Metric | Value |`,
|
|
345
|
-
`| :--- | :--- |`,
|
|
594
|
+
`# 📊 graph-indexer Index Stats`, '',
|
|
595
|
+
`| Metric | Value |`, `| :--- | :--- |`,
|
|
346
596
|
`| **Chunks** | ${chunkCount} |`,
|
|
347
|
-
`| **Files indexed** | ${
|
|
348
|
-
`| **
|
|
597
|
+
`| **Files indexed** | ${fileSet.size} |`,
|
|
598
|
+
`| **Symbols in table** | ${db.symbolTable.size} |`,
|
|
599
|
+
`| **Vector entries** | ${lazyModeActive ? db._vecOffsets.size : db.vectors.size} |`,
|
|
349
600
|
`| **Search mode** | ${searchMode} |`,
|
|
601
|
+
`| **Lazy vec mode** | ${lazyModeActive ? '✅ Yes (enterprise scale)' : '❌ No (small corpus)'} |`,
|
|
350
602
|
`| **Daemon** | ${daemonStatus} |`,
|
|
351
603
|
`| **Index age** | ${indexAge} |`,
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
...Array.from(extCounts.entries())
|
|
355
|
-
.sort((a, b) => b[1] - a[1])
|
|
356
|
-
.map(([ext, count]) => `- .${ext}: ${count} chunks`),
|
|
604
|
+
'', `## Extension Breakdown`,
|
|
605
|
+
...Array.from(extCounts.entries()).sort((a, b) => b[1] - a[1]).map(([e, n]) => `- .${e}: ${n} chunks`),
|
|
357
606
|
];
|
|
358
|
-
|
|
359
|
-
if (chunkCount === 0) {
|
|
360
|
-
lines.push(``, `⚠️ Index is empty. Run \`npm run mcp:index\` to build it.`);
|
|
361
|
-
}
|
|
362
|
-
|
|
607
|
+
if (chunkCount === 0) lines.push('', `⚠️ Index empty. Run \`npm run mcp:index\`.`);
|
|
363
608
|
return { content: [{ type: "text", text: lines.join('\n') }] };
|
|
364
609
|
} catch (err) {
|
|
365
610
|
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
@@ -367,6 +612,8 @@ server.tool(
|
|
|
367
612
|
}
|
|
368
613
|
);
|
|
369
614
|
|
|
615
|
+
// ─── Connect ──────────────────────────────────────────────────────────────────
|
|
616
|
+
|
|
370
617
|
const transport = new StdioServerTransport();
|
|
371
618
|
await server.connect(transport);
|
|
372
|
-
process.stderr.write(
|
|
619
|
+
process.stderr.write(`✅ graph-indexer MCP server running (v${version}).\n`);
|