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/LICENSE +21 -677
- package/PROMPT.md +160 -133
- package/README.md +244 -335
- package/core-engine.mjs +396 -121
- package/indexer.mjs +19 -19
- package/init.mjs +241 -0
- package/mcp-server.mjs +441 -117
- package/package.json +16 -4
- package/parser-utils.mjs +357 -17
- package/watch-daemon.mjs +25 -16
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
|
-
* @license
|
|
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);
|
|
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.2";
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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') }] };
|
|
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(
|
|
619
|
+
process.stderr.write(`✅ graph-indexer MCP server running (v${version}).\n`);
|