graph-indexer 1.1.0 → 2.0.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/CHANGELOG.md +412 -0
- package/README.md +228 -350
- package/SECURITY.md +68 -0
- package/TRADEMARK.md +31 -0
- package/cli-ui.mjs +78 -0
- package/config.mjs +269 -0
- package/daemon-ctl.mjs +175 -0
- package/daemon-lock.mjs +69 -0
- package/embedders/python/mlx_embed_server.py +61 -0
- package/embedders/python/requirements-mlx.txt +5 -0
- package/embedders/setup-mlx.mjs +110 -0
- package/embeddings.mjs +489 -0
- package/engine/binary.mjs +399 -0
- package/{core-engine.mjs → engine/memory.mjs} +359 -249
- package/engine/sqlite.mjs +863 -0
- package/enrichment.mjs +426 -0
- package/git-signals.mjs +248 -0
- package/indexer.mjs +216 -77
- package/init.mjs +1604 -170
- package/layout.mjs +123 -0
- package/mcp/format.mjs +61 -0
- package/mcp/tools.mjs +1157 -0
- package/mcp/topology.mjs +551 -0
- package/mcp-server.mjs +118 -573
- package/package.json +56 -16
- package/parse/extractor.mjs +617 -0
- package/parse/imports.mjs +365 -0
- package/parse/languages.mjs +89 -0
- package/parse/metadata.mjs +655 -0
- package/parse/routes.mjs +327 -0
- package/prompts/CORE.md +86 -0
- package/prompts/DOMAIN_TEMPLATE.md +46 -0
- package/prompts/INTEGRATION.md +123 -0
- package/prompts/frameworks/ANDROID.md +17 -0
- package/prompts/frameworks/ASPNET_CORE.md +19 -0
- package/prompts/frameworks/FASTAPI_DJANGO.md +19 -0
- package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +18 -0
- package/prompts/frameworks/RAILS.md +20 -0
- package/prompts/frameworks/REACT.md +20 -0
- package/prompts/frameworks/SPRING_BOOT.md +19 -0
- package/prompts/languages/BASH.md +22 -0
- package/prompts/languages/C.md +21 -0
- package/prompts/languages/CSHARP.md +24 -0
- package/prompts/languages/CSS_SCSS.md +22 -0
- package/prompts/languages/GO.md +23 -0
- package/prompts/languages/JAVA.md +23 -0
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
- package/prompts/languages/KOTLIN.md +24 -0
- package/prompts/languages/PHP.md +23 -0
- package/prompts/languages/PYTHON.md +23 -0
- package/prompts/languages/RUBY.md +23 -0
- package/prompts/languages/RUST.md +21 -0
- package/prompts/languages/SWIFT.md +21 -0
- package/search-core.mjs +627 -0
- package/storage.mjs +68 -0
- package/watch-daemon.mjs +204 -69
- package/PROMPT.md +0 -143
- package/parser-utils.mjs +0 -861
package/mcp/tools.mjs
ADDED
|
@@ -0,0 +1,1157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file mcp/tools.mjs
|
|
3
|
+
* @description MCP tool registration: wires all graph-indexer tools onto an McpServer.
|
|
4
|
+
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
5
|
+
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
6
|
+
* @license MIT
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path, { resolve } from 'path';
|
|
11
|
+
import { computePageRank, isNaturalLanguageQuery } from '../search-core.mjs';
|
|
12
|
+
import { getParserForFile } from '../parse/languages.mjs';
|
|
13
|
+
import { extractFileSkeleton } from '../parse/extractor.mjs';
|
|
14
|
+
import { describeEmbedder } from '../embeddings.mjs';
|
|
15
|
+
import { rerankResults, ollamaGenerate, mlxLmGenerate } from '../enrichment.mjs';
|
|
16
|
+
import { coChangesFor, gitBoostScore, computeFreshness, currentGitState } from '../git-signals.mjs';
|
|
17
|
+
import { extractSignatureLine, pruneBodyByQuery } from './format.mjs';
|
|
18
|
+
import {
|
|
19
|
+
assessConfidence, hydeQueryVector, buildHydePrompt, detectRepoLanguage,
|
|
20
|
+
classifyCallers, findReferences, findRoutes, buildSubgraph,
|
|
21
|
+
} from './topology.mjs';
|
|
22
|
+
|
|
23
|
+
// ─── Structured (JSON) output helpers ────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Wrap a structured payload as an MCP tool result. The JSON is emitted as a text
|
|
27
|
+
* block (so every client can read it) AND as `structuredContent` (so SDK-aware
|
|
28
|
+
* clients get typed fields without parsing prose). No outputSchema is declared,
|
|
29
|
+
* so the SDK passes structuredContent through without validation.
|
|
30
|
+
*/
|
|
31
|
+
function jsonResult(payload) {
|
|
32
|
+
return {
|
|
33
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
34
|
+
structuredContent: payload,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Typed projection of a chunk for JSON output (no code body unless asked). */
|
|
39
|
+
function chunkCard(chunk) {
|
|
40
|
+
return {
|
|
41
|
+
id: chunk.id,
|
|
42
|
+
name: chunk.name,
|
|
43
|
+
node_type: chunk.node_type,
|
|
44
|
+
file_path: chunk.file_path,
|
|
45
|
+
start_line: chunk.start_line,
|
|
46
|
+
end_line: chunk.end_line,
|
|
47
|
+
class_context: chunk.class_context || null,
|
|
48
|
+
params: chunk.params || [],
|
|
49
|
+
return_type: chunk.return_type || null,
|
|
50
|
+
type_refs: chunk.type_refs || [],
|
|
51
|
+
decorators: chunk.decorators || [],
|
|
52
|
+
extends: chunk.extends || [],
|
|
53
|
+
docstring: chunk.docstring || null,
|
|
54
|
+
calls: chunk.calls || [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Files that historically change together with the target's file(s) — a git
|
|
60
|
+
* blast-radius hint. Aggregates co-change across all defining files, drops the
|
|
61
|
+
* target's own files, and returns the strongest partners. Empty when no signals.
|
|
62
|
+
*/
|
|
63
|
+
function coChangeFiles(gitSignals, files, limit = 5) {
|
|
64
|
+
if (!gitSignals) return [];
|
|
65
|
+
const own = new Set(files);
|
|
66
|
+
const agg = new Map();
|
|
67
|
+
for (const f of files) {
|
|
68
|
+
for (const { file, count } of coChangesFor(gitSignals, f, limit)) {
|
|
69
|
+
if (own.has(file)) continue;
|
|
70
|
+
agg.set(file, Math.max(agg.get(file) || 0, count));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...agg.entries()]
|
|
74
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
75
|
+
.slice(0, limit)
|
|
76
|
+
.map(([file, count]) => ({ file, count }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One-line git co-change blast-radius hint for markdown output. */
|
|
80
|
+
function coChangeLine(coChanges) {
|
|
81
|
+
return `🔄 Historically changes with: ${coChanges.map(c => `\`${c.file}\` (${c.count}×)`).join(', ')}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Typed projection of a referencing chunk (caller / subclass / type user). */
|
|
85
|
+
function refCard({ chunk, recvHint, reason, confidence }) {
|
|
86
|
+
return {
|
|
87
|
+
id: chunk.id,
|
|
88
|
+
name: chunk.name,
|
|
89
|
+
node_type: chunk.node_type,
|
|
90
|
+
class_context: chunk.class_context || null,
|
|
91
|
+
file_path: chunk.file_path,
|
|
92
|
+
start_line: chunk.start_line,
|
|
93
|
+
end_line: chunk.end_line,
|
|
94
|
+
via: recvHint || null,
|
|
95
|
+
confidence: confidence || (reason ? 'high' : 'name-only'),
|
|
96
|
+
reason: reason || null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Tool registration ──────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Register every graph-indexer tool on an MCP server.
|
|
104
|
+
*
|
|
105
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
106
|
+
* @param {object} db A loaded store implementing the storage contract.
|
|
107
|
+
* @param {object} opts
|
|
108
|
+
* @param {string} opts.projectRoot
|
|
109
|
+
* @param {string} opts.artifactPath Index file whose mtime represents freshness.
|
|
110
|
+
* @param {string} opts.pidFile Watch-daemon PID file (may not exist).
|
|
111
|
+
* @param {boolean} opts.embeddingsEnabled
|
|
112
|
+
* @param {object} [opts.embedder] Query embedder (embeddings.createEmbedder); its
|
|
113
|
+
* provider/model must match the index that was built.
|
|
114
|
+
* @param {{enabled:boolean, model:string, topM:number}} [opts.rerank] LLM rerank config.
|
|
115
|
+
* @param {string} [opts.ollamaHost] Ollama endpoint for the rerank judge.
|
|
116
|
+
* @param {object|null} [opts.gitSignals] Loaded git-signals sidecar (churn/recency/co-change), or null.
|
|
117
|
+
* @param {number} [opts.gitRankBoost] 0..1 opt-in recency/churn weight in search_code (0 = ranking unchanged).
|
|
118
|
+
*/
|
|
119
|
+
export function registerTools(server, db, { projectRoot, artifactPath, pidFile, embeddingsEnabled, embedder, rerank, hyde, ollamaHost = 'http://localhost:11434', llmProvider = 'ollama', mlxLmHost = 'http://localhost:8080', gitSignals = null, gitRankBoost = 0 }) {
|
|
120
|
+
|
|
121
|
+
// Scoped here (not module-level) so multiple servers in one process don't cross-contaminate.
|
|
122
|
+
// `undefined` = not yet detected; `null` = unknown (→ generic prompt).
|
|
123
|
+
let _repoLang;
|
|
124
|
+
|
|
125
|
+
// ── Index-freshness contract ──────────────────────────────────────────────
|
|
126
|
+
// A stale call graph silently misleads the agent, so tool responses carry a
|
|
127
|
+
// freshness signal. The markdown footer is shown ONLY when the index is NOT
|
|
128
|
+
// fresh — confident, up-to-date cards stay lean (no per-call token bloat).
|
|
129
|
+
const isDaemonAlive = () => {
|
|
130
|
+
try {
|
|
131
|
+
if (!pidFile || !fs.existsSync(pidFile)) return false;
|
|
132
|
+
process.kill(parseInt(fs.readFileSync(pidFile, 'utf-8'), 10), 0);
|
|
133
|
+
return true;
|
|
134
|
+
} catch { return false; }
|
|
135
|
+
};
|
|
136
|
+
const indexFreshness = () => {
|
|
137
|
+
let ageSeconds = null;
|
|
138
|
+
try { ageSeconds = Math.floor((Date.now() - fs.statSync(artifactPath).mtimeMs) / 1000); } catch { /* no index file */ }
|
|
139
|
+
return computeFreshness({
|
|
140
|
+
ageSeconds,
|
|
141
|
+
indexedCommit: gitSignals?.head ?? null,
|
|
142
|
+
current: currentGitState(projectRoot),
|
|
143
|
+
daemonRunning: isDaemonAlive(),
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
/** Compact one-line freshness footer, or null when the index is fresh (keep cards lean). */
|
|
147
|
+
const freshnessNote = (f) => {
|
|
148
|
+
if (!f.stale && !f.syncing && !(f.pendingChanges > 0) && !f.commitMoved) return null;
|
|
149
|
+
const bits = [`🕰 index ${f.ageLabel} old`];
|
|
150
|
+
if (f.indexedCommit) bits.push(`built @ ${f.indexedCommit}`);
|
|
151
|
+
if (f.pendingChanges > 0) bits.push(`${f.pendingChanges} uncommitted source change(s)`);
|
|
152
|
+
if (f.commitMoved) bits.push(`HEAD now ${f.currentCommit}`);
|
|
153
|
+
bits.push(f.stale
|
|
154
|
+
? '⚠️ STALE — results may miss recent edits; run `npm run mcp:index`'
|
|
155
|
+
: 'daemon syncing…');
|
|
156
|
+
return bits.join(' · ');
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// ─── search_code ────────────────────────────────────────────────────────────
|
|
160
|
+
server.tool(
|
|
161
|
+
'search_code',
|
|
162
|
+
'CRITICAL: ALWAYS USE THIS TOOL FIRST to find code. High-precision AST hybrid search returning exact chunks and cross-file topology.',
|
|
163
|
+
{
|
|
164
|
+
query: z.string().describe('Natural language description of the logic to find.'),
|
|
165
|
+
exact_tokens: z.string().optional().describe('Exact symbol name for guaranteed rank-1 placement.'),
|
|
166
|
+
include_topology: z.boolean().default(true),
|
|
167
|
+
min_score: z.number().min(0).max(1).default(0.3),
|
|
168
|
+
top_k: z.number().int().min(1).max(20).default(5),
|
|
169
|
+
token_budget: z.number().int().min(100).optional().describe(
|
|
170
|
+
'Token budget for code bodies (1 token ≈ 4 chars). Omit to use smart default.'
|
|
171
|
+
),
|
|
172
|
+
detail: z.enum(['signatures', 'smart', 'full']).default('smart').describe(
|
|
173
|
+
"'signatures': compact cards only (~20 tok each, no bodies) — fastest. "
|
|
174
|
+
+ "'smart' (default): signatures + query-relevant body snippets. "
|
|
175
|
+
+ "'full': signatures + complete bodies."
|
|
176
|
+
),
|
|
177
|
+
rerank: z.boolean().optional().describe(
|
|
178
|
+
'Rerank the top results with a local LLM judge (+50% rank-1 on '
|
|
179
|
+
+ 'natural-language queries, ~1–2 s extra). Defaults to the '
|
|
180
|
+
+ '`rerank.enabled` project config; only fires on NL queries.'
|
|
181
|
+
),
|
|
182
|
+
hyde: z.boolean().optional().describe(
|
|
183
|
+
'Query-side HyDE: generate a hypothetical code snippet for the query, '
|
|
184
|
+
+ 'embed it and blend with the query vector to bridge vocabulary gaps on '
|
|
185
|
+
+ 'behavioural queries (~1 s extra). Defaults to the `hyde.enabled` project '
|
|
186
|
+
+ 'config; only fires on NL queries with a vector channel.'
|
|
187
|
+
),
|
|
188
|
+
response_format: z.enum(['markdown', 'json']).default('markdown').describe(
|
|
189
|
+
"'markdown' (default): token-efficient cards for an LLM to read. "
|
|
190
|
+
+ "'json': typed structured fields (id, file, lines, signature, topology, body) "
|
|
191
|
+
+ 'for programmatic clients — avoids parsing prose.'
|
|
192
|
+
),
|
|
193
|
+
},
|
|
194
|
+
async ({ query, exact_tokens, include_topology, min_score, top_k, token_budget, detail, rerank: rerankParam, hyde: hydeParam, response_format }) => {
|
|
195
|
+
try {
|
|
196
|
+
const fullQuery = exact_tokens ? `${query} ${exact_tokens}` : query;
|
|
197
|
+
let queryVector = null;
|
|
198
|
+
if (embedder) {
|
|
199
|
+
try { queryVector = await embedder.embedQuery(fullQuery); }
|
|
200
|
+
catch { /* lexical fallback */ }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// HyDE is suppressed when an exact symbol is pinned: the symbolic
|
|
204
|
+
// query vector is already precise, and HyDE's hypothetical snippet
|
|
205
|
+
// would only blur it. Off → queryVector is untouched (byte-identical search).
|
|
206
|
+
const wantHyde = hydeParam ?? Boolean(hyde?.enabled);
|
|
207
|
+
if (wantHyde && queryVector && !exact_tokens && isNaturalLanguageQuery(fullQuery)) {
|
|
208
|
+
if (_repoLang === undefined) {
|
|
209
|
+
try { _repoLang = detectRepoLanguage(db.stats().extCounts, db.iterateChunks()); }
|
|
210
|
+
catch { _repoLang = null; }
|
|
211
|
+
}
|
|
212
|
+
queryVector = await hydeQueryVector(fullQuery, queryVector, {
|
|
213
|
+
embedder,
|
|
214
|
+
generate: llmProvider === 'mlx'
|
|
215
|
+
? (prompt) => mlxLmGenerate(prompt, { mlxLmHost, timeoutMs: 20000 })
|
|
216
|
+
: (prompt) => ollamaGenerate(prompt, {
|
|
217
|
+
model: hyde?.model || 'qwen2.5-coder:1.5b',
|
|
218
|
+
ollamaHost, timeoutMs: 20000,
|
|
219
|
+
options: { temperature: 0.2, num_predict: 220 },
|
|
220
|
+
}),
|
|
221
|
+
lang: _repoLang,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Rerank is suppressed for exact-token queries: symbol lookups are
|
|
226
|
+
// already rank-1-dominant and re-ordering them wastes the judge call.
|
|
227
|
+
// Best-effort — order is preserved on any model failure.
|
|
228
|
+
const wantRerank = rerankParam ?? Boolean(rerank?.enabled);
|
|
229
|
+
const willRerank = wantRerank && !exact_tokens && isNaturalLanguageQuery(fullQuery);
|
|
230
|
+
|
|
231
|
+
// Over-fetch for rerank: without a deeper candidate pool the judge
|
|
232
|
+
// can reorder but never RESCUE a correct-but-deep hit into top_k.
|
|
233
|
+
// Git boost also over-fetches for the same rescue reason, and lives
|
|
234
|
+
// in the tool layer — never in db.searchHybrid — so measured
|
|
235
|
+
// retrieval ranking and backend parity are unchanged unless enabled.
|
|
236
|
+
const applyGitBoost = gitRankBoost > 0 && gitSignals;
|
|
237
|
+
const poolSize = willRerank
|
|
238
|
+
? Math.min(Math.max(top_k, rerank?.poolSize ?? 15), 25)
|
|
239
|
+
: applyGitBoost ? Math.min(Math.max(top_k * 2, 12), 25) : top_k;
|
|
240
|
+
let matches = db.searchHybrid(fullQuery, queryVector, poolSize, min_score, exact_tokens || null);
|
|
241
|
+
|
|
242
|
+
let rerankFailed = false;
|
|
243
|
+
if (willRerank && matches.length > 1) {
|
|
244
|
+
try {
|
|
245
|
+
matches = await rerankResults(fullQuery, matches, {
|
|
246
|
+
topM: Math.min(rerank?.topM ?? 12, matches.length),
|
|
247
|
+
generate: llmProvider === 'mlx'
|
|
248
|
+
? (prompt) => mlxLmGenerate(prompt, { mlxLmHost, timeoutMs: 60000 })
|
|
249
|
+
: (prompt) => ollamaGenerate(prompt, {
|
|
250
|
+
model: rerank?.model || 'qwen2.5-coder:7b',
|
|
251
|
+
ollamaHost, timeoutMs: 60000,
|
|
252
|
+
options: { temperature: 0, num_predict: 40 },
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
255
|
+
} catch {
|
|
256
|
+
// An unreachable or slow judge must degrade gracefully — never
|
|
257
|
+
// turn the whole search into an error. `matches` retains its
|
|
258
|
+
// pre-rerank order because the throwing await never reassigned it.
|
|
259
|
+
rerankFailed = true;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (applyGitBoost && matches.length > 1) {
|
|
263
|
+
if (willRerank && !rerankFailed) {
|
|
264
|
+
// The LLM judge has already set the order, and rerank leaves each
|
|
265
|
+
// result's fused `score` untouched (still in pre-rerank descending
|
|
266
|
+
// order). Re-sorting by that score would REVERT the judge, so here
|
|
267
|
+
// git only NUDGES: boost a rank surrogate and keep the displayed
|
|
268
|
+
// score intact. A strong git signal can still lift a recently-changed
|
|
269
|
+
// file a few places, but it no longer cancels the rerank.
|
|
270
|
+
matches = matches
|
|
271
|
+
.map((m, i) => ({ m, key: (matches.length - i) * (1 + gitRankBoost * gitBoostScore(gitSignals, m.chunk.file_path)) }))
|
|
272
|
+
.sort((a, b) => b.key - a.key)
|
|
273
|
+
.map(({ m }) => m);
|
|
274
|
+
} else {
|
|
275
|
+
matches = matches
|
|
276
|
+
.map(m => ({ ...m, score: m.score * (1 + gitRankBoost * gitBoostScore(gitSignals, m.chunk.file_path)) }))
|
|
277
|
+
.sort((a, b) => b.score - a.score);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
matches = matches.slice(0, top_k);
|
|
281
|
+
if (matches.length === 0) {
|
|
282
|
+
const indexEmpty = db.chunkCount() === 0;
|
|
283
|
+
const emptyHint = 'Index is empty — run `npm run mcp:index` (or `idx-index --repo <path>`) to build it.';
|
|
284
|
+
return response_format === 'json'
|
|
285
|
+
? jsonResult({ query: fullQuery, count: 0, ...(indexEmpty ? { index_status: 'empty', hint: emptyHint } : {}), results: [] })
|
|
286
|
+
: { content: [{ type: 'text', text: indexEmpty ? `⚠️ ${emptyHint}` : 'No results found.' }] };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const { lowConfidence, candidateFiles } = assessConfidence(matches, fullQuery, Boolean(exact_tokens));
|
|
290
|
+
const fresh = indexFreshness();
|
|
291
|
+
|
|
292
|
+
if (response_format === 'json') {
|
|
293
|
+
const qTokens = fullQuery.toLowerCase().split(/[\s\W_]+/).filter(t => t.length >= 3);
|
|
294
|
+
const results = matches.map(({ score, chunk }, i) => {
|
|
295
|
+
const result = { rank: i + 1, score: Number(score.toFixed(4)), ...chunkCard(chunk) };
|
|
296
|
+
if (include_topology) {
|
|
297
|
+
result.topology = {
|
|
298
|
+
dependencies: db.getDependencies(chunk.file_path),
|
|
299
|
+
used_by: db.getImportedBy(chunk.file_path),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (detail !== 'signatures' && chunk.code_snippet) {
|
|
303
|
+
result.body = detail === 'full'
|
|
304
|
+
? chunk.code_snippet
|
|
305
|
+
: pruneBodyByQuery(chunk.code_snippet, qTokens);
|
|
306
|
+
}
|
|
307
|
+
return result;
|
|
308
|
+
});
|
|
309
|
+
return jsonResult({
|
|
310
|
+
query: fullQuery, count: results.length, reranked: willRerank && !rerankFailed, detail,
|
|
311
|
+
...(rerankFailed ? { rerank_failed: true } : {}),
|
|
312
|
+
low_confidence: lowConfidence,
|
|
313
|
+
...(lowConfidence ? { candidate_files: candidateFiles } : {}),
|
|
314
|
+
index: fresh,
|
|
315
|
+
results,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const depSignature = (depPath) => {
|
|
320
|
+
const syms = [];
|
|
321
|
+
for (const c of db.getChunksByFile(depPath)) {
|
|
322
|
+
if (c.name && c.node_type !== 'expression_statement') {
|
|
323
|
+
syms.push(c.name);
|
|
324
|
+
if (syms.length >= 4) break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return syms.length ? `${depPath} [${syms.join(', ')}]` : depPath;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const lines = [`🔍 QUERY: "${fullQuery}" — ${matches.length} result(s)\n`];
|
|
331
|
+
|
|
332
|
+
for (let i = 0; i < matches.length; i++) {
|
|
333
|
+
const { score, chunk } = matches[i];
|
|
334
|
+
lines.push(`${'─'.repeat(50)}`);
|
|
335
|
+
lines.push(`#${i + 1} · **${chunk.name}** [${chunk.node_type}]`);
|
|
336
|
+
lines.push(`📄 ${chunk.file_path}:${chunk.start_line}–${chunk.end_line} · ID: \`${chunk.id}\` · RRF: ${score.toFixed(4)}`);
|
|
337
|
+
|
|
338
|
+
const sig = [];
|
|
339
|
+
if (chunk.params?.length) sig.push(`(${chunk.params.slice(0, 4).join(', ')})`);
|
|
340
|
+
if (chunk.return_type) sig.push(`→ ${chunk.return_type}`);
|
|
341
|
+
if (chunk.type_refs?.length) sig.push(`types: ${chunk.type_refs.slice(0, 4).join(', ')}`);
|
|
342
|
+
if (sig.length) lines.push(`🔤 ${sig.join(' ')}`);
|
|
343
|
+
|
|
344
|
+
if (chunk.decorators?.length) lines.push(`🏷 ${chunk.decorators.slice(0, 6).map(d => '@' + d).join(' ')}`);
|
|
345
|
+
if (chunk.extends?.length) lines.push(`🧬 extends/implements: ${chunk.extends.slice(0, 5).join(', ')}`);
|
|
346
|
+
if (chunk.docstring) lines.push(`💬 ${chunk.docstring.slice(0, 140).replace(/\n/g, ' ')}`);
|
|
347
|
+
|
|
348
|
+
if (include_topology) {
|
|
349
|
+
const deps = db.getDependencies(chunk.file_path).slice(0, 3);
|
|
350
|
+
const usedBy = db.getImportedBy(chunk.file_path).slice(0, 3);
|
|
351
|
+
if (deps.length) lines.push(`⬇️ Deps: ${deps.map(depSignature).join(' | ')}`);
|
|
352
|
+
if (usedBy.length) lines.push(`⬆️ Used by: ${usedBy.join(', ')}`);
|
|
353
|
+
if (chunk.calls?.length) lines.push(`🔗 Calls: ${chunk.calls.slice(0, 6).join(', ')}`);
|
|
354
|
+
}
|
|
355
|
+
lines.push(`↩️ Expand: get_chunk("${chunk.id}")`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (detail !== 'signatures') {
|
|
359
|
+
const CHARS_PER_TOKEN = 4;
|
|
360
|
+
const defaultBudget = detail === 'full' ? 6000 : 2000;
|
|
361
|
+
let remainingChars = token_budget != null ? token_budget * CHARS_PER_TOKEN : defaultBudget;
|
|
362
|
+
|
|
363
|
+
lines.push(`\n${'═'.repeat(50)}`);
|
|
364
|
+
lines.push(`CODE BODIES (detail: ${detail}, budget: ~${Math.round(remainingChars / CHARS_PER_TOKEN)} tok)\n`);
|
|
365
|
+
|
|
366
|
+
const queryTokens = fullQuery.toLowerCase().split(/[\s\W_]+/).filter(t => t.length >= 3);
|
|
367
|
+
|
|
368
|
+
for (const { chunk } of matches) {
|
|
369
|
+
if (remainingChars <= 0) break;
|
|
370
|
+
const raw = chunk.code_snippet || '';
|
|
371
|
+
if (!raw) continue;
|
|
372
|
+
const snippet = detail === 'full'
|
|
373
|
+
? raw.slice(0, remainingChars)
|
|
374
|
+
: pruneBodyByQuery(raw, queryTokens).slice(0, remainingChars);
|
|
375
|
+
lines.push(`### ${chunk.name} — ${chunk.file_path}`);
|
|
376
|
+
lines.push('```\n' + snippet + '\n```\n');
|
|
377
|
+
remainingChars -= snippet.length;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Low-confidence: surface candidate files so the agent can
|
|
382
|
+
// call get_file_skeleton rather than issuing another vague search.
|
|
383
|
+
if (lowConfidence) {
|
|
384
|
+
lines.push(`\n${'─'.repeat(50)}`);
|
|
385
|
+
lines.push(`⚠️ Low confidence — no dominant match. Candidate files (top distinct): ${candidateFiles.map(f => `\`${f}\``).join(', ')}`);
|
|
386
|
+
lines.push(`→ Try \`get_file_skeleton\` on these, or refine with a symbol name / \`exact_tokens\`.`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (rerankFailed) lines.push('', '⚠️ Rerank was requested but the judge model was unreachable — results are in raw fused order (not reranked).');
|
|
390
|
+
const _note = freshnessNote(fresh);
|
|
391
|
+
if (_note) lines.push('', _note);
|
|
392
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
393
|
+
} catch (err) {
|
|
394
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
// ─── get_chunk ────────────────────────────────────────────────────────────────
|
|
400
|
+
server.tool(
|
|
401
|
+
'get_chunk',
|
|
402
|
+
'CRITICAL: Use this INSTEAD of reading full files. Returns the complete body of a function/class by its chunk_id from search_code results.',
|
|
403
|
+
{
|
|
404
|
+
chunk_id: z.string().describe('The chunk ID shown in search_code results.'),
|
|
405
|
+
view: z.enum(['full', 'signature']).default('full').describe(
|
|
406
|
+
"'full': complete source body. 'signature': just the function signature line (~5 tokens)."
|
|
407
|
+
),
|
|
408
|
+
response_format: z.enum(['markdown', 'json']).default('markdown').describe(
|
|
409
|
+
"'markdown' (default) or 'json' (typed fields + topology + code)."
|
|
410
|
+
),
|
|
411
|
+
},
|
|
412
|
+
async ({ chunk_id, view, response_format }) => {
|
|
413
|
+
try {
|
|
414
|
+
const chunk = db.getChunk(chunk_id);
|
|
415
|
+
if (!chunk) {
|
|
416
|
+
return response_format === 'json'
|
|
417
|
+
? jsonResult({ chunk_id, found: false })
|
|
418
|
+
: { content: [{ type: 'text', text: `Chunk '${chunk_id}' not found. Run search_code to get valid IDs.` }] };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (response_format === 'json') {
|
|
422
|
+
return jsonResult({
|
|
423
|
+
...chunkCard(chunk),
|
|
424
|
+
topology: {
|
|
425
|
+
dependencies: db.getDependencies(chunk.file_path),
|
|
426
|
+
used_by: db.getImportedBy(chunk.file_path),
|
|
427
|
+
},
|
|
428
|
+
code: view === 'signature' ? extractSignatureLine(chunk.code_snippet) : chunk.code_snippet,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const parts = [
|
|
433
|
+
`# ${chunk.name}`,
|
|
434
|
+
`**File:** \`${chunk.file_path}\` · **Lines:** ${chunk.start_line}–${chunk.end_line} · **Type:** ${chunk.node_type}`,
|
|
435
|
+
];
|
|
436
|
+
if (chunk.params?.length) parts.push(`**Params:** ${chunk.params.join(', ')}`);
|
|
437
|
+
if (chunk.return_type) parts.push(`**Returns:** ${chunk.return_type}`);
|
|
438
|
+
if (chunk.type_refs?.length) parts.push(`**Type refs:** ${chunk.type_refs.join(', ')}`);
|
|
439
|
+
if (chunk.decorators?.length) parts.push(`**Decorators:** ${chunk.decorators.map(d => '@' + d).join(', ')}`);
|
|
440
|
+
if (chunk.extends?.length) parts.push(`**Inherits:** ${chunk.extends.join(', ')}`);
|
|
441
|
+
if (chunk.docstring) parts.push(`**Doc:** ${chunk.docstring}`);
|
|
442
|
+
|
|
443
|
+
const deps = db.getDependencies(chunk.file_path);
|
|
444
|
+
const usedBy = db.getImportedBy(chunk.file_path);
|
|
445
|
+
if (deps.length) parts.push(`⬇️ Imports: ${deps.join(', ')}`);
|
|
446
|
+
if (usedBy.length) parts.push(`⬆️ Used by: ${usedBy.join(', ')}`);
|
|
447
|
+
if (chunk.calls?.length) parts.push(`🔗 Calls: ${chunk.calls.join(', ')}`);
|
|
448
|
+
|
|
449
|
+
if (view === 'signature') parts.push('', '```', extractSignatureLine(chunk.code_snippet), '```');
|
|
450
|
+
else parts.push('', '```', chunk.code_snippet, '```');
|
|
451
|
+
return { content: [{ type: 'text', text: parts.join('\n') }] };
|
|
452
|
+
} catch (err) {
|
|
453
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
// ─── resolve_symbol ─────────────────────────────────────────────────────────
|
|
459
|
+
server.tool(
|
|
460
|
+
'resolve_symbol',
|
|
461
|
+
'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.',
|
|
462
|
+
{
|
|
463
|
+
symbol: z.string().describe("Exact symbol name (e.g. 'validateToken', 'User', 'PaymentService')."),
|
|
464
|
+
response_format: z.enum(['markdown', 'json']).default('markdown').describe(
|
|
465
|
+
"'markdown' (default) or 'json' (typed definition list + topology)."
|
|
466
|
+
),
|
|
467
|
+
},
|
|
468
|
+
async ({ symbol, response_format }) => {
|
|
469
|
+
try {
|
|
470
|
+
const defs = db.resolveSymbol(symbol);
|
|
471
|
+
if (defs.length === 0) {
|
|
472
|
+
const indexEmpty = db.chunkCount() === 0;
|
|
473
|
+
const emptyHint = 'Index is empty — run `npm run mcp:index` (or `idx-index --repo <path>`) to build it.';
|
|
474
|
+
return response_format === 'json'
|
|
475
|
+
? jsonResult({ symbol, count: 0, ...(indexEmpty ? { index_status: 'empty', hint: emptyHint } : {}), definitions: [] })
|
|
476
|
+
: { content: [{ type: 'text', text: indexEmpty ? `⚠️ ${emptyHint}` : `Symbol '${symbol}' not in index. Try search_code(query="${symbol}") for fuzzy search.` }] };
|
|
477
|
+
}
|
|
478
|
+
if (response_format === 'json') {
|
|
479
|
+
return jsonResult({
|
|
480
|
+
symbol,
|
|
481
|
+
count: defs.length,
|
|
482
|
+
definitions: defs.map(chunk => ({
|
|
483
|
+
...chunkCard(chunk),
|
|
484
|
+
topology: {
|
|
485
|
+
dependencies: db.getDependencies(chunk.file_path),
|
|
486
|
+
used_by: db.getImportedBy(chunk.file_path),
|
|
487
|
+
},
|
|
488
|
+
signature: extractSignatureLine(chunk.code_snippet),
|
|
489
|
+
})),
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
const lines = [`# Symbol: \`${symbol}\` — ${defs.length} definition(s)\n`];
|
|
493
|
+
for (const chunk of defs) {
|
|
494
|
+
lines.push(`${'─'.repeat(50)}`);
|
|
495
|
+
lines.push(`**${chunk.name}** [${chunk.node_type}]`);
|
|
496
|
+
lines.push(`📄 ${chunk.file_path}:${chunk.start_line}–${chunk.end_line} · ID: \`${chunk.id}\``);
|
|
497
|
+
if (chunk.params?.length) lines.push(`🔤 Params: ${chunk.params.join(', ')}`);
|
|
498
|
+
if (chunk.return_type) lines.push(`🔤 Returns: ${chunk.return_type}`);
|
|
499
|
+
if (chunk.type_refs?.length) lines.push(`🔗 Type refs: ${chunk.type_refs.join(', ')}`);
|
|
500
|
+
if (chunk.decorators?.length) lines.push(`🏷 ${chunk.decorators.slice(0, 6).map(d => '@' + d).join(' ')}`);
|
|
501
|
+
if (chunk.docstring) lines.push(`💬 ${chunk.docstring.slice(0, 160).replace(/\n/g, ' ')}`);
|
|
502
|
+
const deps = db.getDependencies(chunk.file_path).slice(0, 4);
|
|
503
|
+
const usedBy = db.getImportedBy(chunk.file_path).slice(0, 4);
|
|
504
|
+
if (deps.length) lines.push(`⬇️ Imports: ${deps.join(', ')}`);
|
|
505
|
+
if (usedBy.length) lines.push(`⬆️ Used by: ${usedBy.join(', ')}`);
|
|
506
|
+
if (chunk.calls?.length) lines.push(`🔗 Calls: ${chunk.calls.slice(0, 8).join(', ')}`);
|
|
507
|
+
lines.push(`\n\`\`\`\n${extractSignatureLine(chunk.code_snippet)}\n\`\`\``);
|
|
508
|
+
lines.push(`↩️ Full body: get_chunk("${chunk.id}")`);
|
|
509
|
+
}
|
|
510
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
511
|
+
} catch (err) {
|
|
512
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
// ─── get_chunk_summary ──────────────────────────────────────────────────────
|
|
518
|
+
server.tool(
|
|
519
|
+
'get_chunk_summary',
|
|
520
|
+
'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.',
|
|
521
|
+
{
|
|
522
|
+
chunk_id: z.string().describe('The chunk ID from search_code results.'),
|
|
523
|
+
expand_calls: z.boolean().default(false).describe(
|
|
524
|
+
'When true, resolves the signatures of outgoing dependencies inline (~150 tok vs ~50 tok). '
|
|
525
|
+
+ 'Use when you need to understand the interfaces of called functions in a single shot, '
|
|
526
|
+
+ 'without issuing a separate tool call per dependency.'
|
|
527
|
+
),
|
|
528
|
+
response_format: z.enum(['markdown', 'json']).default('markdown').describe(
|
|
529
|
+
"'markdown' (default) or 'json' (typed signature + docstring + resolved calls)."
|
|
530
|
+
),
|
|
531
|
+
},
|
|
532
|
+
async ({ chunk_id, expand_calls, response_format }) => {
|
|
533
|
+
try {
|
|
534
|
+
const chunk = db.getChunk(chunk_id);
|
|
535
|
+
if (!chunk) {
|
|
536
|
+
return response_format === 'json'
|
|
537
|
+
? jsonResult({ chunk_id, found: false })
|
|
538
|
+
: { content: [{ type: 'text', text: `Chunk '${chunk_id}' not found.` }] };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const resolveCalls = () => {
|
|
542
|
+
const out = [];
|
|
543
|
+
const seen = new Set();
|
|
544
|
+
for (const callName of (chunk.calls || [])) {
|
|
545
|
+
if (seen.size >= 6) break;
|
|
546
|
+
const key = callName.toLowerCase();
|
|
547
|
+
if (seen.has(key)) continue;
|
|
548
|
+
seen.add(key);
|
|
549
|
+
const target = db.resolveSymbol(callName)[0];
|
|
550
|
+
if (!target?.code_snippet) continue;
|
|
551
|
+
out.push({
|
|
552
|
+
name: callName,
|
|
553
|
+
file_path: target.file_path,
|
|
554
|
+
start_line: target.start_line,
|
|
555
|
+
signature: extractSignatureLine(target.code_snippet).split('\n')[0].trim().slice(0, 120),
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
if (response_format === 'json') {
|
|
562
|
+
return jsonResult({
|
|
563
|
+
id: chunk.id,
|
|
564
|
+
name: chunk.name,
|
|
565
|
+
node_type: chunk.node_type,
|
|
566
|
+
file_path: chunk.file_path,
|
|
567
|
+
start_line: chunk.start_line,
|
|
568
|
+
end_line: chunk.end_line,
|
|
569
|
+
params: chunk.params || [],
|
|
570
|
+
return_type: chunk.return_type || null,
|
|
571
|
+
type_refs: chunk.type_refs || [],
|
|
572
|
+
decorators: chunk.decorators || [],
|
|
573
|
+
docstring: chunk.docstring || null,
|
|
574
|
+
signature: extractSignatureLine(chunk.code_snippet),
|
|
575
|
+
calls: chunk.calls || [],
|
|
576
|
+
resolved_calls: expand_calls ? resolveCalls() : undefined,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const lines = [
|
|
581
|
+
`# ${chunk.name} · ${chunk.file_path}:${chunk.start_line}–${chunk.end_line}`,
|
|
582
|
+
`**Type:** ${chunk.node_type}`,
|
|
583
|
+
];
|
|
584
|
+
if (chunk.params?.length) lines.push(`**Params:** ${chunk.params.join(', ')}`);
|
|
585
|
+
if (chunk.return_type) lines.push(`**Returns:** ${chunk.return_type}`);
|
|
586
|
+
if (chunk.type_refs?.length) lines.push(`**Type refs:** ${chunk.type_refs.join(', ')}`);
|
|
587
|
+
if (chunk.decorators?.length) lines.push(`**Decorators:** ${chunk.decorators.map(d => '@' + d).join(', ')}`);
|
|
588
|
+
if (chunk.docstring) lines.push(`\n**Doc:** ${chunk.docstring.slice(0, 300)}`);
|
|
589
|
+
|
|
590
|
+
if (chunk.calls?.length) {
|
|
591
|
+
if (!expand_calls) {
|
|
592
|
+
lines.push(`**Calls:** ${chunk.calls.join(', ')}`);
|
|
593
|
+
} else {
|
|
594
|
+
const expanded = [];
|
|
595
|
+
const seen = new Set();
|
|
596
|
+
const SIG_BUDGET = 6;
|
|
597
|
+
for (const callName of chunk.calls) {
|
|
598
|
+
if (seen.size >= SIG_BUDGET) break;
|
|
599
|
+
const key = callName.toLowerCase();
|
|
600
|
+
if (seen.has(key)) continue;
|
|
601
|
+
seen.add(key);
|
|
602
|
+
const target = db.resolveSymbol(callName)[0];
|
|
603
|
+
if (!target?.code_snippet) continue;
|
|
604
|
+
const sig = extractSignatureLine(target.code_snippet).split('\n')[0].trim().slice(0, 120);
|
|
605
|
+
expanded.push(
|
|
606
|
+
` **${callName}** → \`${target.file_path}:${target.start_line}\``
|
|
607
|
+
+ `\n \`\`\`\n ${sig}\n \`\`\``
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
const unresolved = chunk.calls.length - expanded.length;
|
|
611
|
+
lines.push(`\n**Calls (${expanded.length} resolved${unresolved > 0 ? `, ${unresolved} unindexed` : ''}):**`);
|
|
612
|
+
if (expanded.length > 0) lines.push(...expanded);
|
|
613
|
+
else lines.push(' _(none resolved in index)_');
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
lines.push('', '```', extractSignatureLine(chunk.code_snippet), '```');
|
|
618
|
+
lines.push(`\n↩️ Full body: get_chunk("${chunk.id}")`);
|
|
619
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
620
|
+
} catch (err) {
|
|
621
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
// ─── get_file_skeleton ──────────────────────────────────────────────────────
|
|
627
|
+
server.tool(
|
|
628
|
+
'get_file_skeleton',
|
|
629
|
+
'Returns all top-level exports and definitions in a file with line numbers — no code bodies (~50 tokens vs 5000).',
|
|
630
|
+
{
|
|
631
|
+
file_path: z.string().describe("Relative path (e.g. 'src/app.ts')."),
|
|
632
|
+
response_format: z.enum(['markdown', 'json']).default('markdown').describe(
|
|
633
|
+
"'markdown' (default) or 'json' ({ file_path, skeleton } string fields)."
|
|
634
|
+
),
|
|
635
|
+
},
|
|
636
|
+
async ({ file_path, response_format }) => {
|
|
637
|
+
try {
|
|
638
|
+
const absolutePath = resolve(projectRoot, file_path);
|
|
639
|
+
const safeRoot = path.normalize(projectRoot);
|
|
640
|
+
const norm = path.normalize(absolutePath);
|
|
641
|
+
if (norm !== safeRoot && !norm.startsWith(safeRoot + path.sep)) {
|
|
642
|
+
throw new Error('Access denied: path is outside the project root.');
|
|
643
|
+
}
|
|
644
|
+
if (!fs.existsSync(absolutePath)) throw new Error('File not found.');
|
|
645
|
+
// Defence in depth: the textual check above can be defeated by a
|
|
646
|
+
// symlink inside the project that points outside it. Resolve symlinks
|
|
647
|
+
// on both sides and re-check containment to block that escape path.
|
|
648
|
+
let realRoot, realPath;
|
|
649
|
+
try { realRoot = fs.realpathSync(safeRoot); } catch { realRoot = safeRoot; }
|
|
650
|
+
try { realPath = fs.realpathSync(absolutePath); } catch { realPath = norm; }
|
|
651
|
+
if (realPath !== realRoot && !realPath.startsWith(realRoot + path.sep)) {
|
|
652
|
+
throw new Error('Access denied: path resolves outside the project root.');
|
|
653
|
+
}
|
|
654
|
+
const content = fs.readFileSync(absolutePath, 'utf-8');
|
|
655
|
+
const ext = path.extname(absolutePath);
|
|
656
|
+
const parser = getParserForFile(ext);
|
|
657
|
+
if (!parser) {
|
|
658
|
+
return response_format === 'json'
|
|
659
|
+
? jsonResult({ file_path, language_supported: false, skeleton: null })
|
|
660
|
+
: { content: [{ type: 'text', text: 'Language not supported.' }] };
|
|
661
|
+
}
|
|
662
|
+
const tree = parser.parse((offset) => offset < content.length ? content.slice(offset, offset + 4096) : null);
|
|
663
|
+
const skeleton = extractFileSkeleton(tree.rootNode, content);
|
|
664
|
+
if (response_format === 'json') {
|
|
665
|
+
return jsonResult({ file_path, language_supported: true, skeleton: skeleton || '' });
|
|
666
|
+
}
|
|
667
|
+
return { content: [{ type: 'text', text: `# Skeleton: ${file_path}\n\n${skeleton || '_No semantic signatures found_'}` }] };
|
|
668
|
+
} catch (err) {
|
|
669
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
// ─── get_call_graph ─────────────────────────────────────────────────────────
|
|
675
|
+
server.tool(
|
|
676
|
+
'get_call_graph',
|
|
677
|
+
'Finds all chunks that call a specific function, split into high-confidence callers '
|
|
678
|
+
+ '(the real blast radius) and name-only matches (an ambiguous same-named symbol). '
|
|
679
|
+
+ 'CRITICAL for safe refactoring — call before changing any exported signature.',
|
|
680
|
+
{
|
|
681
|
+
target_function: z.string().describe("Exact function name (e.g. 'validateToken')."),
|
|
682
|
+
target_class: z.string().optional().describe(
|
|
683
|
+
"Optional class/type that owns the method (e.g. 'OrderService') — scopes the "
|
|
684
|
+
+ "blast radius to one class's method when several symbols share the name."
|
|
685
|
+
),
|
|
686
|
+
response_format: z.enum(['markdown', 'json']).default('markdown').describe(
|
|
687
|
+
"'markdown' (default) or 'json' (typed { high, name_only } caller arrays)."
|
|
688
|
+
),
|
|
689
|
+
},
|
|
690
|
+
async ({ target_function, target_class, response_format }) => {
|
|
691
|
+
try {
|
|
692
|
+
const { high, nameOnly, targetDefs, ambiguous, hasSiteData, classFiltered } =
|
|
693
|
+
classifyCallers(db, target_function, { targetClass: target_class || null });
|
|
694
|
+
const total = high.length + nameOnly.length;
|
|
695
|
+
const label = classFiltered ? `${target_class}.${target_function}` : target_function;
|
|
696
|
+
const coChanges = coChangeFiles(gitSignals, [...new Set(targetDefs.map(d => d.file_path))]);
|
|
697
|
+
const fresh = indexFreshness();
|
|
698
|
+
const note = freshnessNote(fresh);
|
|
699
|
+
|
|
700
|
+
if (response_format === 'json') {
|
|
701
|
+
return jsonResult({
|
|
702
|
+
target_function,
|
|
703
|
+
target_class: target_class || null,
|
|
704
|
+
ambiguous,
|
|
705
|
+
definition_count: targetDefs.length,
|
|
706
|
+
receiver_aware: hasSiteData,
|
|
707
|
+
caller_count: total,
|
|
708
|
+
high_confidence: high.map(h => refCard({ ...h, confidence: 'high' })),
|
|
709
|
+
name_only: nameOnly.map(n => refCard({ ...n, confidence: 'name-only' })),
|
|
710
|
+
co_changes: coChanges,
|
|
711
|
+
index: fresh,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (total === 0) {
|
|
716
|
+
// "No callers" from a stale index is dangerously misleading — always surface freshness here.
|
|
717
|
+
const parts = [`✅ Safe to modify: no callers of \`${label}\` found.`];
|
|
718
|
+
if (coChanges.length) parts.push(coChangeLine(coChanges));
|
|
719
|
+
if (note) parts.push(note);
|
|
720
|
+
return { content: [{ type: 'text', text: parts.join('\n') }] };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const fmt = ({ chunk, recvHint }) =>
|
|
724
|
+
`- [${chunk.node_type}] \`${chunk.class_context ? chunk.class_context + '.' : ''}${chunk.name}\``
|
|
725
|
+
+ ` in \`${chunk.file_path}\` (lines ${chunk.start_line}–${chunk.end_line})`
|
|
726
|
+
+ (recvHint ? ` · via ${recvHint}` : '');
|
|
727
|
+
|
|
728
|
+
const lines = [`# ⚠️ Call Graph: \`${label}\``];
|
|
729
|
+
const split = ambiguous && !classFiltered;
|
|
730
|
+
|
|
731
|
+
if (split) {
|
|
732
|
+
lines.push(`${total} name-match caller(s) · ⚠️ ${targetDefs.length}+ symbols named \`${target_function}\` — grouped by confidence:`);
|
|
733
|
+
} else {
|
|
734
|
+
lines.push(`${total} caller(s) depend on this — review before changing signature:`);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (high.length) {
|
|
738
|
+
if (split) lines.push('', `## ✅ High-confidence callers (${high.length})`);
|
|
739
|
+
for (const h of high) lines.push(fmt(h));
|
|
740
|
+
}
|
|
741
|
+
if (nameOnly.length) {
|
|
742
|
+
lines.push('', `## ❔ Name-only matches (${nameOnly.length}) — verify; may call a different \`${target_function}\``);
|
|
743
|
+
for (const n of nameOnly) lines.push(fmt(n));
|
|
744
|
+
if (!classFiltered) lines.push('', `> Pass \`target_class\` to scope the blast radius to one class's \`${target_function}\`.`);
|
|
745
|
+
}
|
|
746
|
+
if (!hasSiteData && ambiguous) {
|
|
747
|
+
lines.push('', `> ℹ️ This index predates receiver-aware call graphs — re-run \`npm run mcp:index\` for precise grouping.`);
|
|
748
|
+
}
|
|
749
|
+
if (coChanges.length) lines.push('', coChangeLine(coChanges));
|
|
750
|
+
if (note) lines.push('', note);
|
|
751
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
752
|
+
} catch (err) {
|
|
753
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
// ─── find_references ──────────────────────────────────────────────────────────
|
|
759
|
+
server.tool(
|
|
760
|
+
'find_references',
|
|
761
|
+
'Every symbol that references a target symbol — symbol-level, not just file-level. '
|
|
762
|
+
+ 'Fuses three reference kinds: callers (the blast radius), subclasses/implementers '
|
|
763
|
+
+ '(extends), and type users (params, returns, fields). Each is split by confidence '
|
|
764
|
+
+ 'using receiver hints and the import graph. Broader than get_call_graph: use it before '
|
|
765
|
+
+ 'renaming or changing a class/interface/type, not just a function.',
|
|
766
|
+
{
|
|
767
|
+
symbol: z.string().describe("Exact symbol name (function, class, interface, or type)."),
|
|
768
|
+
target_class: z.string().optional().describe(
|
|
769
|
+
"Optional owning class/type to scope the call dimension when several symbols share the name."
|
|
770
|
+
),
|
|
771
|
+
response_format: z.enum(['markdown', 'json']).default('markdown').describe(
|
|
772
|
+
"'markdown' (default) or 'json' (typed { called_by, subclassed_by, used_as_type_by })."
|
|
773
|
+
),
|
|
774
|
+
},
|
|
775
|
+
async ({ symbol, target_class, response_format }) => {
|
|
776
|
+
try {
|
|
777
|
+
const { targetDefs, ambiguous, calls, inherits, types } =
|
|
778
|
+
findReferences(db, symbol, { targetClass: target_class || null });
|
|
779
|
+
const callTotal = calls.high.length + calls.nameOnly.length;
|
|
780
|
+
const total = callTotal + inherits.length + types.length;
|
|
781
|
+
const coChanges = coChangeFiles(gitSignals, [...new Set(targetDefs.map(d => d.file_path))]);
|
|
782
|
+
const fresh = indexFreshness();
|
|
783
|
+
const note = freshnessNote(fresh);
|
|
784
|
+
|
|
785
|
+
if (response_format === 'json') {
|
|
786
|
+
return jsonResult({
|
|
787
|
+
symbol,
|
|
788
|
+
target_class: target_class || null,
|
|
789
|
+
ambiguous,
|
|
790
|
+
definition_count: targetDefs.length,
|
|
791
|
+
reference_count: total,
|
|
792
|
+
called_by: {
|
|
793
|
+
high: calls.high.map(h => refCard({ ...h, confidence: 'high' })),
|
|
794
|
+
name_only: calls.nameOnly.map(n => refCard({ ...n, confidence: 'name-only' })),
|
|
795
|
+
},
|
|
796
|
+
subclassed_by: inherits.map(refCard),
|
|
797
|
+
used_as_type_by: types.map(refCard),
|
|
798
|
+
co_changes: coChanges,
|
|
799
|
+
index: fresh,
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (total === 0) {
|
|
804
|
+
const parts = [`✅ No references to \`${symbol}\` found in the index.`];
|
|
805
|
+
if (coChanges.length) parts.push(coChangeLine(coChanges));
|
|
806
|
+
if (note) parts.push(note);
|
|
807
|
+
return { content: [{ type: 'text', text: parts.join('\n') }] };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const fmt = ({ chunk, recvHint, confidence }) =>
|
|
811
|
+
`- [${chunk.node_type}] \`${chunk.class_context ? chunk.class_context + '.' : ''}${chunk.name}\``
|
|
812
|
+
+ ` in \`${chunk.file_path}\` (lines ${chunk.start_line}–${chunk.end_line})`
|
|
813
|
+
+ (recvHint ? ` · via ${recvHint}` : '')
|
|
814
|
+
+ (confidence === 'name-only' ? ' · ⚠️ unverified' : '');
|
|
815
|
+
|
|
816
|
+
const lines = [`# 🔗 References to \`${symbol}\` — ${total} total`];
|
|
817
|
+
if (ambiguous) lines.push(`⚠️ ${targetDefs.length} symbols named \`${symbol}\` — name-only matches may target a different one.`);
|
|
818
|
+
|
|
819
|
+
if (callTotal) {
|
|
820
|
+
lines.push('', `## 📞 Called by (${callTotal})`);
|
|
821
|
+
for (const h of calls.high) lines.push(fmt({ ...h, confidence: 'high' }));
|
|
822
|
+
for (const n of calls.nameOnly) lines.push(fmt({ ...n, confidence: 'name-only' }));
|
|
823
|
+
}
|
|
824
|
+
if (inherits.length) {
|
|
825
|
+
lines.push('', `## 🧬 Subclassed / implemented by (${inherits.length})`);
|
|
826
|
+
for (const it of inherits) lines.push(fmt(it));
|
|
827
|
+
}
|
|
828
|
+
if (types.length) {
|
|
829
|
+
lines.push('', `## 🏷 Used as a type by (${types.length})`);
|
|
830
|
+
for (const it of types) lines.push(fmt(it));
|
|
831
|
+
}
|
|
832
|
+
if (coChanges.length) lines.push('', coChangeLine(coChanges));
|
|
833
|
+
if (note) lines.push('', note);
|
|
834
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
835
|
+
} catch (err) {
|
|
836
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
// ─── find_routes ──────────────────────────────────────────────────────────────
|
|
842
|
+
server.tool(
|
|
843
|
+
'find_routes',
|
|
844
|
+
'Map HTTP routes to their handler functions. Returns the handler chunk ID so '
|
|
845
|
+
+ 'you can call get_chunk or get_call_graph on the handler directly. Covers '
|
|
846
|
+
+ 'NestJS decorators, FastAPI/Flask, Spring (Java) annotations, and Express/Koa '
|
|
847
|
+
+ 'registration. Filter by method and/or path.',
|
|
848
|
+
{
|
|
849
|
+
method: z.string().optional().describe(
|
|
850
|
+
"Optional HTTP method filter (GET, POST, PUT, DELETE, PATCH). Case-insensitive; omit for all methods."
|
|
851
|
+
),
|
|
852
|
+
path: z.string().optional().describe(
|
|
853
|
+
"Optional path filter. Starts with '/' → prefix match; otherwise (incl. pattern hints with '{' or ':') → substring contains-match."
|
|
854
|
+
),
|
|
855
|
+
response_format: z.enum(['markdown', 'json']).default('markdown').describe(
|
|
856
|
+
"'markdown' (default) or 'json' (typed { routes: [...] })."
|
|
857
|
+
),
|
|
858
|
+
},
|
|
859
|
+
async ({ method, path: pathArg, response_format }) => {
|
|
860
|
+
try {
|
|
861
|
+
const routes = findRoutes(db, { method: method || null, path: pathArg || null });
|
|
862
|
+
const fresh = indexFreshness();
|
|
863
|
+
const note = freshnessNote(fresh);
|
|
864
|
+
|
|
865
|
+
if (response_format === 'json') {
|
|
866
|
+
return jsonResult({
|
|
867
|
+
method: method || null,
|
|
868
|
+
path: pathArg || null,
|
|
869
|
+
route_count: routes.length,
|
|
870
|
+
routes,
|
|
871
|
+
index: fresh,
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (routes.length === 0) {
|
|
876
|
+
const filt = [method ? method.toUpperCase() : null, pathArg].filter(Boolean).join(' ');
|
|
877
|
+
const parts = [`✅ No HTTP routes${filt ? ` matching \`${filt}\`` : ''} found in the index.`];
|
|
878
|
+
if (note) parts.push(note);
|
|
879
|
+
return { content: [{ type: 'text', text: parts.join('\n') }] };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const lines = [`# 🌐 HTTP routes — ${routes.length} total`];
|
|
883
|
+
for (const r of routes) {
|
|
884
|
+
const handler = r.id
|
|
885
|
+
? `\`${r.handler_name}\` [${r.node_type}] in \`${r.file_path}\` (lines ${r.start_line}–${r.end_line}) · id \`${r.id}\``
|
|
886
|
+
: `\`${r.handler_name}\` in \`${r.file_path}\`${r.line ? ` (line ${r.line})` : ''}`;
|
|
887
|
+
lines.push(`- **${r.method}** \`${r.path}\` → ${handler}`);
|
|
888
|
+
}
|
|
889
|
+
if (note) lines.push('', note);
|
|
890
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
891
|
+
} catch (err) {
|
|
892
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
// ─── get_subgraph ─────────────────────────────────────────────────────────────
|
|
898
|
+
server.tool(
|
|
899
|
+
'get_subgraph',
|
|
900
|
+
'Trace a flow across files in ONE call: returns a bounded connected subgraph around a '
|
|
901
|
+
+ 'seed symbol — what it calls, its high-confidence callers (precise blast radius), and '
|
|
902
|
+
+ 'its type/inheritance users — within a node and token budget. Use instead of chaining '
|
|
903
|
+
+ 'search_code → get_call_graph → find_references for cross-cutting "how does X flow" questions.',
|
|
904
|
+
{
|
|
905
|
+
symbol: z.string().describe('Seed symbol name (function, method, class, or type).'),
|
|
906
|
+
depth: z.number().int().min(1).max(3).default(2).describe('Hops to traverse from the seed (1–3).'),
|
|
907
|
+
max_nodes: z.number().int().min(1).max(40).default(12).describe('Max chunks in the subgraph.'),
|
|
908
|
+
token_budget: z.number().int().min(100).optional().describe(
|
|
909
|
+
'Token budget for the subgraph (1 token ≈ 4 chars). Omit for node-count-only bounding.'
|
|
910
|
+
),
|
|
911
|
+
response_format: z.enum(['markdown', 'json']).default('markdown').describe(
|
|
912
|
+
"'markdown' (default) or 'json' (typed { nodes, edges })."
|
|
913
|
+
),
|
|
914
|
+
},
|
|
915
|
+
async ({ symbol, depth, max_nodes, token_budget, response_format }) => {
|
|
916
|
+
try {
|
|
917
|
+
const g = buildSubgraph(db, symbol, { maxNodes: max_nodes, maxDepth: depth, tokenBudget: token_budget ?? null });
|
|
918
|
+
const fresh = indexFreshness();
|
|
919
|
+
const note = freshnessNote(fresh);
|
|
920
|
+
if (!g.found) {
|
|
921
|
+
return response_format === 'json'
|
|
922
|
+
? jsonResult({ symbol, found: false, nodes: [], edges: [], index: fresh })
|
|
923
|
+
: { content: [{ type: 'text', text: `Symbol '${symbol}' not in index. Try search_code(query="${symbol}").${note ? '\n' + note : ''}` }] };
|
|
924
|
+
}
|
|
925
|
+
if (response_format === 'json') return jsonResult({ ...g, index: fresh });
|
|
926
|
+
|
|
927
|
+
const byId = new Map(g.nodes.map(n => [n.id, n]));
|
|
928
|
+
const label = (id) => { const n = byId.get(id); return n ? `${n.class_context ? n.class_context + '.' : ''}${n.name}` : id; };
|
|
929
|
+
const lines = [
|
|
930
|
+
`# 🕸 Subgraph around \`${symbol}\` — ${g.nodes.length} node(s), ${g.edges.length} edge(s)`
|
|
931
|
+
+ (g.truncated ? ` · ⚠️ truncated at the budget (raise max_nodes / token_budget for more)` : ''),
|
|
932
|
+
'',
|
|
933
|
+
`## Nodes`,
|
|
934
|
+
];
|
|
935
|
+
for (const n of g.nodes) {
|
|
936
|
+
lines.push(`- [d${n.depth}] [${n.node_type}] \`${n.class_context ? n.class_context + '.' : ''}${n.name}\` · \`${n.file_path}:${n.start_line}–${n.end_line}\``);
|
|
937
|
+
}
|
|
938
|
+
if (g.edges.length) {
|
|
939
|
+
lines.push('', `## Edges`);
|
|
940
|
+
for (const e of g.edges) {
|
|
941
|
+
lines.push(`- \`${label(e.from)}\` ${e.kind === 'calls' ? '→ calls →' : '⇢ references ⇢'} \`${label(e.to)}\``);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
if (note) lines.push('', note);
|
|
945
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
946
|
+
} catch (err) {
|
|
947
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
// ─── get_repo_map ───────────────────────────────────────────────────────────
|
|
953
|
+
server.tool(
|
|
954
|
+
'get_repo_map',
|
|
955
|
+
'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.',
|
|
956
|
+
{
|
|
957
|
+
path_filter: z.string().optional().describe(
|
|
958
|
+
"Only include files whose path contains this string (e.g. 'auth', 'api/v2', 'src/core')."
|
|
959
|
+
),
|
|
960
|
+
max_files: z.number().int().min(1).max(300).default(80).describe('Max files to include in the map.'),
|
|
961
|
+
sort_by: z.enum(['importance', 'path']).default('importance').describe(
|
|
962
|
+
"'importance' (default): most-imported files first (PageRank). 'path': alphabetical."
|
|
963
|
+
),
|
|
964
|
+
response_format: z.enum(['markdown', 'json']).default('markdown').describe(
|
|
965
|
+
"'markdown' (default) or 'json' (typed { files: [{ file_path, symbols }] })."
|
|
966
|
+
),
|
|
967
|
+
},
|
|
968
|
+
async ({ path_filter, max_files, sort_by, response_format }) => {
|
|
969
|
+
try {
|
|
970
|
+
const fileChunks = new Map();
|
|
971
|
+
const filterLower = path_filter ? path_filter.toLowerCase() : null;
|
|
972
|
+
for (const chunk of db.iterateChunks()) {
|
|
973
|
+
if (filterLower && !chunk.file_path.toLowerCase().includes(filterLower)) continue;
|
|
974
|
+
if (chunk.name === 'anonymous' || chunk.name === 'default_export') continue;
|
|
975
|
+
if (!fileChunks.has(chunk.file_path)) fileChunks.set(chunk.file_path, []);
|
|
976
|
+
fileChunks.get(chunk.file_path).push(chunk);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (fileChunks.size === 0) {
|
|
980
|
+
return response_format === 'json'
|
|
981
|
+
? jsonResult({ total_files: 0, total_symbols: 0, files: [] })
|
|
982
|
+
: {
|
|
983
|
+
content: [{
|
|
984
|
+
type: 'text', text: path_filter
|
|
985
|
+
? `No files found matching '${path_filter}'. Try a broader filter.`
|
|
986
|
+
: 'Index is empty. Run `npm run mcp:index` first.'
|
|
987
|
+
}]
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
let sortedFiles = Array.from(fileChunks.keys());
|
|
992
|
+
if (sort_by === 'importance') {
|
|
993
|
+
const pr = computePageRank(db.graph);
|
|
994
|
+
sortedFiles.sort((a, b) => (pr.get(b) ?? 0) - (pr.get(a) ?? 0));
|
|
995
|
+
} else {
|
|
996
|
+
sortedFiles.sort();
|
|
997
|
+
}
|
|
998
|
+
sortedFiles = sortedFiles.slice(0, max_files);
|
|
999
|
+
|
|
1000
|
+
const totalFiles = fileChunks.size;
|
|
1001
|
+
const totalSymbols = Array.from(fileChunks.values()).reduce((s, a) => s + a.length, 0);
|
|
1002
|
+
|
|
1003
|
+
if (response_format === 'json') {
|
|
1004
|
+
const files = sortedFiles.map(filePath => {
|
|
1005
|
+
const seen = new Set();
|
|
1006
|
+
const symbols = [];
|
|
1007
|
+
for (const c of fileChunks.get(filePath)) {
|
|
1008
|
+
const key = c.name.toLowerCase();
|
|
1009
|
+
if (seen.has(key)) continue;
|
|
1010
|
+
seen.add(key);
|
|
1011
|
+
symbols.push({
|
|
1012
|
+
name: c.name, node_type: c.node_type,
|
|
1013
|
+
params: c.params || [], return_type: c.return_type || null,
|
|
1014
|
+
start_line: c.start_line, end_line: c.end_line,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
return { file_path: filePath, symbol_count: symbols.length, symbols };
|
|
1018
|
+
});
|
|
1019
|
+
return jsonResult({
|
|
1020
|
+
total_files: totalFiles, total_symbols: totalSymbols,
|
|
1021
|
+
shown_files: sortedFiles.length, sort_by, path_filter: path_filter || null,
|
|
1022
|
+
files,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const lines = [
|
|
1027
|
+
`# Repo Map — ${totalSymbols} symbols across ${totalFiles} files`,
|
|
1028
|
+
path_filter ? `(filtered to '${path_filter}')` : '',
|
|
1029
|
+
sortedFiles.length < totalFiles ? `(showing top ${sortedFiles.length} by ${sort_by}; use path_filter to narrow)\n` : '',
|
|
1030
|
+
].filter(Boolean);
|
|
1031
|
+
|
|
1032
|
+
for (const filePath of sortedFiles) {
|
|
1033
|
+
const chunks = fileChunks.get(filePath);
|
|
1034
|
+
lines.push(`\n${filePath}`);
|
|
1035
|
+
const seen = new Set();
|
|
1036
|
+
const deduped = [];
|
|
1037
|
+
for (const c of chunks) {
|
|
1038
|
+
const key = c.name.toLowerCase();
|
|
1039
|
+
if (seen.has(key)) continue;
|
|
1040
|
+
seen.add(key);
|
|
1041
|
+
deduped.push(c);
|
|
1042
|
+
}
|
|
1043
|
+
for (const c of deduped.slice(0, 8)) {
|
|
1044
|
+
const kind = c.node_type.includes('class') ? 'class'
|
|
1045
|
+
: c.node_type.includes('interface') ? 'interface'
|
|
1046
|
+
: c.node_type.includes('enum') ? 'enum'
|
|
1047
|
+
: c.node_type.includes('struct') ? 'struct'
|
|
1048
|
+
: c.node_type.includes('trait') ? 'trait' : 'fn';
|
|
1049
|
+
const params = c.params?.length
|
|
1050
|
+
? `(${c.params.slice(0, 3).join(', ')}${c.params.length > 3 ? ', …' : ''})` : '';
|
|
1051
|
+
const ret = c.return_type ? ` → ${c.return_type.slice(0, 40)}` : '';
|
|
1052
|
+
lines.push(` ${kind} ${c.name}${params}${ret}`);
|
|
1053
|
+
}
|
|
1054
|
+
if (deduped.length > 8) lines.push(` … (${deduped.length - 8} more)`);
|
|
1055
|
+
}
|
|
1056
|
+
if (totalFiles > max_files) {
|
|
1057
|
+
lines.push(`\n… ${totalFiles - max_files} more files not shown. Use path_filter or increase max_files.`);
|
|
1058
|
+
}
|
|
1059
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
);
|
|
1065
|
+
|
|
1066
|
+
// ─── list_index_stats ───────────────────────────────────────────────────────
|
|
1067
|
+
server.tool(
|
|
1068
|
+
'list_index_stats',
|
|
1069
|
+
'Returns index health: chunk count, embedding status, daemon status, search mode, storage backend, and index freshness.',
|
|
1070
|
+
{
|
|
1071
|
+
response_format: z.enum(['markdown', 'json']).default('markdown').describe(
|
|
1072
|
+
"'markdown' (default) or 'json' (typed health fields)."
|
|
1073
|
+
),
|
|
1074
|
+
},
|
|
1075
|
+
async ({ response_format }) => {
|
|
1076
|
+
try {
|
|
1077
|
+
const s = db.stats();
|
|
1078
|
+
|
|
1079
|
+
let indexAge = 'unknown';
|
|
1080
|
+
let ageSeconds = null;
|
|
1081
|
+
try {
|
|
1082
|
+
ageSeconds = Math.floor((Date.now() - fs.statSync(artifactPath).mtimeMs) / 1000);
|
|
1083
|
+
indexAge = ageSeconds < 60 ? `${ageSeconds}s ago` : ageSeconds < 3600 ? `${Math.floor(ageSeconds / 60)}m ago` : `${Math.floor(ageSeconds / 3600)}h ago`;
|
|
1084
|
+
} catch { }
|
|
1085
|
+
|
|
1086
|
+
let daemonStatus = 'not running';
|
|
1087
|
+
let daemonRunning = false;
|
|
1088
|
+
try {
|
|
1089
|
+
if (pidFile && fs.existsSync(pidFile)) {
|
|
1090
|
+
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8'), 10);
|
|
1091
|
+
process.kill(pid, 0);
|
|
1092
|
+
daemonStatus = `running (PID: ${pid})`;
|
|
1093
|
+
daemonRunning = true;
|
|
1094
|
+
}
|
|
1095
|
+
} catch { daemonStatus = 'not running (stale PID)'; }
|
|
1096
|
+
|
|
1097
|
+
const embedLabel = embedder ? describeEmbedder(embedder) : '🔤 Lexical only';
|
|
1098
|
+
const searchModeText = !embeddingsEnabled ? 'lexical-only' : s.hasVectors ? 'hybrid' : 'lexical-only';
|
|
1099
|
+
const searchMode = !embeddingsEnabled
|
|
1100
|
+
? '🔤 Lexical only (embeddings disabled)'
|
|
1101
|
+
: s.hasVectors
|
|
1102
|
+
? `🧠 Hybrid (semantic + lexical RRF) · ${embedLabel} — vectors: ${s.vectorSource}`
|
|
1103
|
+
: '🔤 Lexical only (no vectors indexed yet)';
|
|
1104
|
+
|
|
1105
|
+
const fresh = indexFreshness();
|
|
1106
|
+
|
|
1107
|
+
if (response_format === 'json') {
|
|
1108
|
+
return jsonResult({
|
|
1109
|
+
backend: s.backend,
|
|
1110
|
+
chunks: s.chunks,
|
|
1111
|
+
files: s.files,
|
|
1112
|
+
symbols: s.symbols,
|
|
1113
|
+
vectors: s.vectors,
|
|
1114
|
+
has_vectors: s.hasVectors,
|
|
1115
|
+
search_mode: searchModeText,
|
|
1116
|
+
embeddings_enabled: Boolean(embeddingsEnabled),
|
|
1117
|
+
embedder: embedder ? { provider: embedder.provider, model: embedder.model, dim: embedder.dim ?? null } : null,
|
|
1118
|
+
lazy_mode: s.lazyMode,
|
|
1119
|
+
daemon_running: daemonRunning,
|
|
1120
|
+
index_age_seconds: ageSeconds,
|
|
1121
|
+
freshness: {
|
|
1122
|
+
indexed_commit: fresh.indexedCommit,
|
|
1123
|
+
current_commit: fresh.currentCommit,
|
|
1124
|
+
pending_changes: fresh.pendingChanges,
|
|
1125
|
+
stale: fresh.stale,
|
|
1126
|
+
syncing: fresh.syncing,
|
|
1127
|
+
},
|
|
1128
|
+
ext_counts: Object.fromEntries(s.extCounts),
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const lines = [
|
|
1133
|
+
`# 📊 graph-indexer Index Stats`, '',
|
|
1134
|
+
`| Metric | Value |`, `| :--- | :--- |`,
|
|
1135
|
+
`| **Storage backend** | ${s.backend === 'sqlite' ? '🗄 SQLite (disk-backed)' : '⚡ In-memory'} |`,
|
|
1136
|
+
`| **Chunks** | ${s.chunks} |`,
|
|
1137
|
+
`| **Files indexed** | ${s.files} |`,
|
|
1138
|
+
`| **Symbols in table** | ${s.symbols} |`,
|
|
1139
|
+
`| **Vector entries** | ${s.vectors} |`,
|
|
1140
|
+
`| **Search mode** | ${searchMode} |`,
|
|
1141
|
+
`| **Lazy vec mode** | ${s.lazyMode ? '✅ Yes (enterprise scale)' : '❌ No (small corpus)'} |`,
|
|
1142
|
+
`| **Daemon** | ${daemonStatus} |`,
|
|
1143
|
+
`| **Index age** | ${indexAge} |`,
|
|
1144
|
+
`| **Built at commit** | ${fresh.indexedCommit || '—'}${fresh.commitMoved ? ` (HEAD now ${fresh.currentCommit})` : ''} |`,
|
|
1145
|
+
`| **Pending changes** | ${fresh.pendingChanges == null ? '—' : fresh.pendingChanges} |`,
|
|
1146
|
+
`| **Freshness** | ${fresh.stale ? '⚠️ STALE — run `npm run mcp:index`' : fresh.syncing ? '🔄 daemon syncing' : '✅ fresh'} |`,
|
|
1147
|
+
'', `## Extension Breakdown`,
|
|
1148
|
+
...Array.from(s.extCounts.entries()).sort((a, b) => b[1] - a[1]).map(([e, n]) => `- .${e}: ${n} chunks`),
|
|
1149
|
+
];
|
|
1150
|
+
if (s.chunks === 0) lines.push('', `⚠️ Index empty. Run \`npm run mcp:index\`.`);
|
|
1151
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
);
|
|
1157
|
+
}
|