graph-indexer 2.1.1 β†’ 3.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.
Files changed (129) hide show
  1. package/CHANGELOG.md +260 -1
  2. package/README.md +232 -312
  3. package/SECURITY.md +37 -59
  4. package/bin/graph-indexer.mjs +251 -0
  5. package/integrations/claude-code/.claude-plugin/plugin.json +8 -0
  6. package/integrations/claude-code/.mcp.json +8 -0
  7. package/integrations/claude-code/agents/code-structure.md +22 -0
  8. package/integrations/claude-code/hooks/gi-hook.mjs +99 -0
  9. package/integrations/claude-code/hooks/hooks.json +16 -0
  10. package/integrations/opencode/graph-indexer.js +124 -0
  11. package/package.json +32 -109
  12. package/src/cli/helper.mjs +67 -0
  13. package/src/cli/hook.mjs +356 -0
  14. package/src/cli/init.mjs +255 -0
  15. package/src/cli/resident.mjs +175 -0
  16. package/src/index/discover.mjs +130 -0
  17. package/src/index/fingerprint.mjs +30 -0
  18. package/src/index/graph.mjs +61 -0
  19. package/src/index/indexer.mjs +476 -0
  20. package/src/index/modules.mjs +331 -0
  21. package/src/index/resolver.mjs +954 -0
  22. package/src/mcp/render.mjs +60 -0
  23. package/src/mcp/server.mjs +147 -0
  24. package/src/mcp/tools.mjs +973 -0
  25. package/src/parse/arity.mjs +152 -0
  26. package/src/parse/extract.mjs +1031 -0
  27. package/src/parse/lang/csharp.mjs +106 -0
  28. package/src/parse/lang/ecma.mjs +438 -0
  29. package/src/parse/lang/go.mjs +194 -0
  30. package/src/parse/lang/jvm.mjs +118 -0
  31. package/src/parse/lang/kotlin.mjs +97 -0
  32. package/src/parse/lang/misc.mjs +115 -0
  33. package/src/parse/lang/native.mjs +126 -0
  34. package/src/parse/lang/python.mjs +153 -0
  35. package/src/parse/lang/rust.mjs +157 -0
  36. package/src/parse/lang/scripting.mjs +219 -0
  37. package/src/parse/languages.mjs +48 -0
  38. package/src/parse/runtime.mjs +56 -0
  39. package/src/query/check.mjs +343 -0
  40. package/src/query/facts.mjs +323 -0
  41. package/src/query/intel.mjs +680 -0
  42. package/src/query/read.mjs +81 -0
  43. package/src/query/testcmd.mjs +119 -0
  44. package/src/query/textsearch.mjs +160 -0
  45. package/src/search/search.mjs +210 -0
  46. package/src/search/tokenize.mjs +188 -0
  47. package/src/store/db.mjs +169 -0
  48. package/src/util/paths.mjs +24 -0
  49. package/vendor/grammars/bash.wasm.gz +0 -0
  50. package/vendor/grammars/c.wasm.gz +0 -0
  51. package/vendor/grammars/c_sharp.wasm.gz +0 -0
  52. package/vendor/grammars/cpp.wasm.gz +0 -0
  53. package/vendor/grammars/css.wasm.gz +0 -0
  54. package/vendor/grammars/go.wasm.gz +0 -0
  55. package/vendor/grammars/java.wasm.gz +0 -0
  56. package/vendor/grammars/javascript.wasm.gz +0 -0
  57. package/vendor/grammars/kotlin.wasm.gz +0 -0
  58. package/vendor/grammars/manifest.json +148 -0
  59. package/vendor/grammars/php.wasm.gz +0 -0
  60. package/vendor/grammars/python.wasm.gz +0 -0
  61. package/vendor/grammars/ruby.wasm.gz +0 -0
  62. package/vendor/grammars/rust.wasm.gz +0 -0
  63. package/vendor/grammars/scala.wasm.gz +0 -0
  64. package/vendor/grammars/tsx.wasm.gz +0 -0
  65. package/vendor/grammars/typescript.wasm.gz +0 -0
  66. package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
  67. package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  68. package/cli-ui.mjs +0 -78
  69. package/colbert.mjs +0 -168
  70. package/config.mjs +0 -494
  71. package/daemon-ctl.mjs +0 -175
  72. package/daemon-lock.mjs +0 -69
  73. package/embedders/python/mlx_embed_server.py +0 -61
  74. package/embedders/python/requirements-mlx.txt +0 -5
  75. package/embedders/setup-mlx.mjs +0 -110
  76. package/embeddings.mjs +0 -571
  77. package/engine/binary.mjs +0 -437
  78. package/engine/memory.mjs +0 -1003
  79. package/engine/sqlite.mjs +0 -1136
  80. package/enrichment.mjs +0 -521
  81. package/git-signals.mjs +0 -248
  82. package/indexer.mjs +0 -546
  83. package/init.mjs +0 -1809
  84. package/layout.mjs +0 -123
  85. package/mcp/centrality.mjs +0 -117
  86. package/mcp/format.mjs +0 -61
  87. package/mcp/resolver.mjs +0 -116
  88. package/mcp/symbolgraph.mjs +0 -137
  89. package/mcp/taint.mjs +0 -225
  90. package/mcp/tools.mjs +0 -1582
  91. package/mcp/topology.mjs +0 -676
  92. package/mcp-server.mjs +0 -197
  93. package/parse/extractor.mjs +0 -628
  94. package/parse/imports.mjs +0 -365
  95. package/parse/interprocedural.mjs +0 -134
  96. package/parse/languages.mjs +0 -253
  97. package/parse/metadata.mjs +0 -701
  98. package/parse/routes.mjs +0 -327
  99. package/parse/scip.mjs +0 -391
  100. package/parse/taint-patterns.mjs +0 -119
  101. package/prompts/CORE.md +0 -91
  102. package/prompts/DOMAIN_TEMPLATE.md +0 -46
  103. package/prompts/INTEGRATION.md +0 -123
  104. package/prompts/frameworks/ANDROID.md +0 -17
  105. package/prompts/frameworks/ASPNET_CORE.md +0 -19
  106. package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
  107. package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
  108. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
  109. package/prompts/frameworks/RAILS.md +0 -20
  110. package/prompts/frameworks/REACT.md +0 -20
  111. package/prompts/frameworks/SPRING_BOOT.md +0 -20
  112. package/prompts/languages/BASH.md +0 -22
  113. package/prompts/languages/C.md +0 -21
  114. package/prompts/languages/CSHARP.md +0 -24
  115. package/prompts/languages/CSS_SCSS.md +0 -22
  116. package/prompts/languages/GO.md +0 -24
  117. package/prompts/languages/JAVA.md +0 -23
  118. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
  119. package/prompts/languages/KOTLIN.md +0 -24
  120. package/prompts/languages/PHP.md +0 -23
  121. package/prompts/languages/PYTHON.md +0 -23
  122. package/prompts/languages/RUBY.md +0 -23
  123. package/prompts/languages/RUST.md +0 -21
  124. package/prompts/languages/SWIFT.md +0 -21
  125. package/seal.mjs +0 -400
  126. package/search-core.mjs +0 -777
  127. package/search-sparse.mjs +0 -183
  128. package/storage.mjs +0 -73
  129. package/watch-daemon.mjs +0 -289
package/mcp/tools.mjs DELETED
@@ -1,1582 +0,0 @@
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, TEST_FILE_RE, learnedRerank, DEFAULT_RANKER_MODEL } 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, rerankCrossEncoder, crossEncoderScore, ollamaGenerate, mlxLmGenerate } from '../enrichment.mjs';
16
- import { encodeMultiVector, rerankLateInteraction } from '../colbert.mjs';
17
- import { coChangesFor, gitBoostScore, computeFreshness, currentGitState } from '../git-signals.mjs';
18
- import { egressGuardActive } from '../seal.mjs';
19
- import { extractSignatureLine, pruneBodyByQuery } from './format.mjs';
20
- import {
21
- assessConfidence, hydeQueryVector, buildHydePrompt, detectRepoLanguage,
22
- classifyCallers, findReferences, findRoutes, buildSubgraph, buildImpact,
23
- } from './topology.mjs';
24
- import { traceTaint, findTaintedSinks } from './taint.mjs';
25
- import { CATEGORY_SEVERITY } from '../parse/taint-patterns.mjs';
26
-
27
- // ─── Structured (JSON) output helpers ────────────────────────────────────────────
28
-
29
- /**
30
- * Wrap a structured payload as an MCP tool result. The JSON is emitted as a text
31
- * block (so every client can read it) AND as `structuredContent` (so SDK-aware
32
- * clients get typed fields without parsing prose). No outputSchema is declared,
33
- * so the SDK passes structuredContent through without validation.
34
- */
35
- function jsonResult(payload) {
36
- return {
37
- content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
38
- structuredContent: payload,
39
- };
40
- }
41
-
42
- /** Typed projection of a chunk for JSON output (no code body unless asked). */
43
- function chunkCard(chunk) {
44
- return {
45
- id: chunk.id,
46
- name: chunk.name,
47
- node_type: chunk.node_type,
48
- file_path: chunk.file_path,
49
- start_line: chunk.start_line,
50
- end_line: chunk.end_line,
51
- class_context: chunk.class_context || null,
52
- params: chunk.params || [],
53
- return_type: chunk.return_type || null,
54
- type_refs: chunk.type_refs || [],
55
- decorators: chunk.decorators || [],
56
- extends: chunk.extends || [],
57
- docstring: chunk.docstring || null,
58
- calls: chunk.calls || [],
59
- };
60
- }
61
-
62
- /**
63
- * Files that historically change together with the target's file(s) β€” a git
64
- * blast-radius hint. Aggregates co-change across all defining files, drops the
65
- * target's own files, and returns the strongest partners. Empty when no signals.
66
- */
67
- function coChangeFiles(gitSignals, files, limit = 5) {
68
- if (!gitSignals) return [];
69
- const own = new Set(files);
70
- const agg = new Map();
71
- for (const f of files) {
72
- for (const { file, count } of coChangesFor(gitSignals, f, limit)) {
73
- if (own.has(file)) continue;
74
- agg.set(file, Math.max(agg.get(file) || 0, count));
75
- }
76
- }
77
- return [...agg.entries()]
78
- .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
79
- .slice(0, limit)
80
- .map(([file, count]) => ({ file, count }));
81
- }
82
-
83
- /** One-line git co-change blast-radius hint for markdown output. */
84
- function coChangeLine(coChanges) {
85
- return `πŸ”„ Historically changes with: ${coChanges.map(c => `\`${c.file}\` (${c.count}Γ—)`).join(', ')}`;
86
- }
87
-
88
- /** Typed projection of a referencing chunk (caller / subclass / type user). */
89
- function refCard({ chunk, recvHint, reason, confidence }) {
90
- return {
91
- id: chunk.id,
92
- name: chunk.name,
93
- node_type: chunk.node_type,
94
- class_context: chunk.class_context || null,
95
- file_path: chunk.file_path,
96
- start_line: chunk.start_line,
97
- end_line: chunk.end_line,
98
- via: recvHint || null,
99
- confidence: confidence || (reason ? 'high' : 'name-only'),
100
- reason: reason || null,
101
- };
102
- }
103
-
104
- // ─── Tool registration ──────────────────────────────────────────────────────────
105
-
106
- /**
107
- * Register every graph-indexer tool on an MCP server.
108
- *
109
- * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
110
- * @param {object} db A loaded store implementing the storage contract.
111
- * @param {object} opts
112
- * @param {string} opts.projectRoot
113
- * @param {string} opts.artifactPath Index file whose mtime represents freshness.
114
- * @param {string} opts.pidFile Watch-daemon PID file (may not exist).
115
- * @param {boolean} opts.embeddingsEnabled
116
- * @param {object} [opts.embedder] Query embedder (embeddings.createEmbedder); its
117
- * provider/model must match the index that was built.
118
- * @param {{enabled:boolean, model:string, topM:number}} [opts.rerank] LLM rerank config.
119
- * @param {string} [opts.ollamaHost] Ollama endpoint for the rerank judge.
120
- * @param {object|null} [opts.gitSignals] Loaded git-signals sidecar (churn/recency/co-change), or null.
121
- * @param {number} [opts.gitRankBoost] 0..1 opt-in recency/churn weight in search_code (0 = ranking unchanged).
122
- */
123
- 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, sealed = 'off', ranker = 'rrf' }) {
124
-
125
- // Scoped here (not module-level) so multiple servers in one process don't cross-contaminate.
126
- // `undefined` = not yet detected; `null` = unknown (β†’ generic prompt).
127
- let _repoLang;
128
-
129
- // ── Index-freshness contract ──────────────────────────────────────────────
130
- // A stale call graph silently misleads the agent, so tool responses carry a
131
- // freshness signal. The markdown footer is shown ONLY when the index is NOT
132
- // fresh β€” confident, up-to-date cards stay lean (no per-call token bloat).
133
- const isDaemonAlive = () => {
134
- try {
135
- if (!pidFile || !fs.existsSync(pidFile)) return false;
136
- process.kill(parseInt(fs.readFileSync(pidFile, 'utf-8'), 10), 0);
137
- return true;
138
- } catch { return false; }
139
- };
140
- const indexFreshness = () => {
141
- let ageSeconds = null;
142
- try { ageSeconds = Math.floor((Date.now() - fs.statSync(artifactPath).mtimeMs) / 1000); } catch { /* no index file */ }
143
- return computeFreshness({
144
- ageSeconds,
145
- indexedCommit: gitSignals?.head ?? null,
146
- current: currentGitState(projectRoot),
147
- daemonRunning: isDaemonAlive(),
148
- });
149
- };
150
- /** Compact one-line freshness footer, or null when the index is fresh (keep cards lean). */
151
- const freshnessNote = (f) => {
152
- if (!f.stale && !f.syncing && !(f.pendingChanges > 0) && !f.commitMoved) return null;
153
- const bits = [`πŸ•° index ${f.ageLabel} old`];
154
- if (f.indexedCommit) bits.push(`built @ ${f.indexedCommit}`);
155
- if (f.pendingChanges > 0) bits.push(`${f.pendingChanges} uncommitted source change(s)`);
156
- if (f.commitMoved) bits.push(`HEAD now ${f.currentCommit}`);
157
- bits.push(f.stale
158
- ? '⚠️ STALE β€” results may miss recent edits; run `npm run mcp:index`'
159
- : 'daemon syncing…');
160
- return bits.join(' Β· ');
161
- };
162
-
163
- // ── test β†’ code mapping (C5) ──────────────────────────────────────────────
164
- // The test chunks that exercise a symbol: a test/spec chunk (TEST_FILE_RE) that
165
- // either CALLS the symbol or REFERENCES it (type/inheritance). Deduped by id,
166
- // deterministically ordered (file, line, id). Shared by tests_for + explain_symbol.
167
- const testsForSymbol = (symbol) => {
168
- const seen = new Set();
169
- const out = [];
170
- for (const c of [...db.findCallers(symbol), ...db.findReferers(symbol)]) {
171
- if (!c || !TEST_FILE_RE.test(c.file_path) || seen.has(c.id)) continue;
172
- seen.add(c.id);
173
- out.push(c);
174
- }
175
- out.sort((a, b) =>
176
- (a.file_path < b.file_path ? -1 : a.file_path > b.file_path ? 1 : 0)
177
- || (a.start_line - b.start_line)
178
- || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
179
- return out;
180
- };
181
-
182
- // ─── search_code ────────────────────────────────────────────────────────────
183
- server.tool(
184
- 'search_code',
185
- 'CRITICAL: ALWAYS USE THIS TOOL FIRST to find code. High-precision AST hybrid search returning exact chunks and cross-file topology.',
186
- {
187
- query: z.string().describe('Natural language description of the logic to find.'),
188
- exact_tokens: z.string().optional().describe('Exact symbol name for guaranteed rank-1 placement.'),
189
- include_topology: z.boolean().default(true),
190
- min_score: z.number().min(0).max(1).default(0.3),
191
- top_k: z.number().int().min(1).max(20).default(5),
192
- token_budget: z.number().int().min(100).optional().describe(
193
- 'Token budget for code bodies (1 token β‰ˆ 4 chars). Omit to use smart default.'
194
- ),
195
- detail: z.enum(['signatures', 'smart', 'full']).default('smart').describe(
196
- "'signatures': compact cards only (~20 tok each, no bodies) β€” fastest. "
197
- + "'smart' (default): signatures + query-relevant body snippets. "
198
- + "'full': signatures + complete bodies."
199
- ),
200
- rerank: z.boolean().optional().describe(
201
- 'Rerank the top results with a local LLM judge (+50% rank-1 on '
202
- + 'natural-language queries, ~1–2 s extra). Defaults to the '
203
- + '`rerank.enabled` project config; only fires on NL queries.'
204
- ),
205
- hyde: z.boolean().optional().describe(
206
- 'Query-side HyDE: generate a hypothetical code snippet for the query, '
207
- + 'embed it and blend with the query vector to bridge vocabulary gaps on '
208
- + 'behavioural queries (~1 s extra). Defaults to the `hyde.enabled` project '
209
- + 'config; only fires on NL queries with a vector channel.'
210
- ),
211
- response_format: z.enum(['markdown', 'json']).default('markdown').describe(
212
- "'markdown' (default): token-efficient cards for an LLM to read. "
213
- + "'json': typed structured fields (id, file, lines, signature, topology, body) "
214
- + 'for programmatic clients β€” avoids parsing prose.'
215
- ),
216
- },
217
- async ({ query, exact_tokens, include_topology, min_score, top_k, token_budget, detail, rerank: rerankParam, hyde: hydeParam, response_format }) => {
218
- try {
219
- const fullQuery = exact_tokens ? `${query} ${exact_tokens}` : query;
220
- let queryVector = null;
221
- if (embedder) {
222
- try { queryVector = await embedder.embedQuery(fullQuery); }
223
- catch { /* lexical fallback */ }
224
- }
225
-
226
- // HyDE is suppressed when an exact symbol is pinned: the symbolic
227
- // query vector is already precise, and HyDE's hypothetical snippet
228
- // would only blur it. Off β†’ queryVector is untouched (byte-identical search).
229
- const wantHyde = hydeParam ?? Boolean(hyde?.enabled);
230
- if (wantHyde && queryVector && !exact_tokens && isNaturalLanguageQuery(fullQuery)) {
231
- if (_repoLang === undefined) {
232
- try { _repoLang = detectRepoLanguage(db.stats().extCounts, db.iterateChunks()); }
233
- catch { _repoLang = null; }
234
- }
235
- queryVector = await hydeQueryVector(fullQuery, queryVector, {
236
- embedder,
237
- generate: llmProvider === 'mlx'
238
- ? (prompt) => mlxLmGenerate(prompt, { mlxLmHost, timeoutMs: 20000 })
239
- : (prompt) => ollamaGenerate(prompt, {
240
- model: hyde?.model || 'qwen2.5-coder:1.5b',
241
- ollamaHost, timeoutMs: 20000,
242
- options: { temperature: 0.2, num_predict: 220 },
243
- }),
244
- lang: _repoLang,
245
- });
246
- }
247
-
248
- // Rerank is suppressed for exact-token queries: symbol lookups are
249
- // already rank-1-dominant and re-ordering them wastes the judge call.
250
- // Best-effort β€” order is preserved on any model failure.
251
- const wantRerank = rerankParam ?? Boolean(rerank?.enabled);
252
- const willRerank = wantRerank && !exact_tokens && isNaturalLanguageQuery(fullQuery);
253
-
254
- // Over-fetch for rerank: without a deeper candidate pool the judge
255
- // can reorder but never RESCUE a correct-but-deep hit into top_k.
256
- // Git boost also over-fetches for the same rescue reason, and lives
257
- // in the tool layer β€” never in db.searchHybrid β€” so measured
258
- // retrieval ranking and backend parity are unchanged unless enabled.
259
- const applyGitBoost = gitRankBoost > 0 && gitSignals;
260
- // D3: the learned re-rank also needs a deeper pool to RESCUE a structurally-strong
261
- // but lexically-deep hit into top_k (same rationale as rerank / git boost).
262
- const wantLearned = ranker === 'learned' && !exact_tokens;
263
- const poolSize = willRerank
264
- ? Math.min(Math.max(top_k, rerank?.poolSize ?? 15), 25)
265
- : (applyGitBoost || wantLearned) ? Math.min(Math.max(top_k * 2, 12), 25) : top_k;
266
- let matches = db.searchHybrid(fullQuery, queryVector, poolSize, min_score, exact_tokens || null);
267
-
268
- // D3: post-fusion learned re-rank (opt-in). Reorders the fused pool by a zero-dep
269
- // linear model over RRF score + centrality + resolved/SCIP in-degree + git, BEFORE the
270
- // optional LLM judge / git nudge. Accessors are null when the layer is absent, so the
271
- // model degrades to RRF + git + node-type (β‰ˆneutral) on a plain index.
272
- if (wantLearned && matches.length > 1) {
273
- matches = learnedRerank(matches, {
274
- getCentrality: db.hasCentrality?.() ? (id) => db.getCentrality(id) : null,
275
- getInEdges: db.hasSymbolGraph?.() ? (id) => db.getEdges(id, { direction: 'in' }) : null,
276
- gitScoreFor: gitSignals ? (file) => gitBoostScore(gitSignals, file) : null,
277
- }, DEFAULT_RANKER_MODEL);
278
- }
279
-
280
- let rerankFailed = false;
281
- if (willRerank && matches.length > 1) {
282
- try {
283
- if (rerank?.provider === 'cross-encoder') {
284
- // Local air-gapped cross-encoder: scores (query, candidate) pairs and
285
- // sorts. Reuses the same over-fetched pool; deterministic; no LLM call.
286
- matches = await rerankCrossEncoder(fullQuery, matches, {
287
- topM: Math.min(rerank?.topM ?? 12, matches.length),
288
- scorer: (q, texts) => crossEncoderScore(q, texts, { model: rerank?.crossEncoderModel }),
289
- });
290
- } else if (rerank?.provider === 'late-interaction') {
291
- // B4 ColBERT-style late interaction: encode the query into multi-vectors
292
- // with the SAME local embedder, then MaxSim-rerank against each candidate's
293
- // stored doc sub-vectors. Air-gapped, deterministic, parity-safe; a no-op
294
- // (original order) without --embeddings (no embedder / no doc vectors).
295
- const qVecs = embedder ? await encodeMultiVector(embedder, fullQuery) : [];
296
- matches = rerankLateInteraction(matches, {
297
- qVecs,
298
- binPath: db.embeddingBinPath?.(),
299
- topM: Math.min(rerank?.topM ?? 12, matches.length),
300
- });
301
- } else {
302
- matches = await rerankResults(fullQuery, matches, {
303
- topM: Math.min(rerank?.topM ?? 12, matches.length),
304
- generate: llmProvider === 'mlx'
305
- ? (prompt) => mlxLmGenerate(prompt, { mlxLmHost, timeoutMs: 60000 })
306
- : (prompt) => ollamaGenerate(prompt, {
307
- model: rerank?.model || 'qwen2.5-coder:7b',
308
- ollamaHost, timeoutMs: 60000,
309
- options: { temperature: 0, num_predict: 40 },
310
- }),
311
- });
312
- }
313
- } catch {
314
- // An unreachable or slow reranker must degrade gracefully β€” never
315
- // turn the whole search into an error. `matches` retains its
316
- // pre-rerank order because the throwing await never reassigned it.
317
- rerankFailed = true;
318
- }
319
- }
320
- if (applyGitBoost && matches.length > 1) {
321
- if (willRerank && !rerankFailed) {
322
- // The LLM judge has already set the order, and rerank leaves each
323
- // result's fused `score` untouched (still in pre-rerank descending
324
- // order). Re-sorting by that score would REVERT the judge, so here
325
- // git only NUDGES: boost a rank surrogate and keep the displayed
326
- // score intact. A strong git signal can still lift a recently-changed
327
- // file a few places, but it no longer cancels the rerank.
328
- matches = matches
329
- .map((m, i) => ({ m, key: (matches.length - i) * (1 + gitRankBoost * gitBoostScore(gitSignals, m.chunk.file_path)) }))
330
- .sort((a, b) => b.key - a.key)
331
- .map(({ m }) => m);
332
- } else {
333
- matches = matches
334
- .map(m => ({ ...m, score: m.score * (1 + gitRankBoost * gitBoostScore(gitSignals, m.chunk.file_path)) }))
335
- .sort((a, b) => b.score - a.score);
336
- }
337
- }
338
- matches = matches.slice(0, top_k);
339
- if (matches.length === 0) {
340
- const indexEmpty = db.chunkCount() === 0;
341
- const emptyHint = 'Index is empty β€” run `npm run mcp:index` (or `idx-index --repo <path>`) to build it.';
342
- return response_format === 'json'
343
- ? jsonResult({ query: fullQuery, count: 0, ...(indexEmpty ? { index_status: 'empty', hint: emptyHint } : {}), results: [] })
344
- : { content: [{ type: 'text', text: indexEmpty ? `⚠️ ${emptyHint}` : 'No results found.' }] };
345
- }
346
-
347
- const { lowConfidence, candidateFiles } = assessConfidence(matches, fullQuery, Boolean(exact_tokens));
348
- const fresh = indexFreshness();
349
-
350
- if (response_format === 'json') {
351
- const qTokens = fullQuery.toLowerCase().split(/[\s\W_]+/).filter(t => t.length >= 3);
352
- const results = matches.map(({ score, chunk }, i) => {
353
- const result = { rank: i + 1, score: Number(score.toFixed(4)), ...chunkCard(chunk) };
354
- if (include_topology) {
355
- result.topology = {
356
- dependencies: db.getDependencies(chunk.file_path),
357
- used_by: db.getImportedBy(chunk.file_path),
358
- };
359
- }
360
- if (detail !== 'signatures' && chunk.code_snippet) {
361
- result.body = detail === 'full'
362
- ? chunk.code_snippet
363
- : pruneBodyByQuery(chunk.code_snippet, qTokens);
364
- }
365
- return result;
366
- });
367
- return jsonResult({
368
- query: fullQuery, count: results.length, reranked: willRerank && !rerankFailed, detail,
369
- ...(rerankFailed ? { rerank_failed: true } : {}),
370
- low_confidence: lowConfidence,
371
- ...(lowConfidence ? { candidate_files: candidateFiles } : {}),
372
- index: fresh,
373
- results,
374
- });
375
- }
376
-
377
- const depSignature = (depPath) => {
378
- const syms = [];
379
- for (const c of db.getChunksByFile(depPath)) {
380
- if (c.name && c.node_type !== 'expression_statement') {
381
- syms.push(c.name);
382
- if (syms.length >= 4) break;
383
- }
384
- }
385
- return syms.length ? `${depPath} [${syms.join(', ')}]` : depPath;
386
- };
387
-
388
- const lines = [`πŸ” QUERY: "${fullQuery}" β€” ${matches.length} result(s)\n`];
389
-
390
- for (let i = 0; i < matches.length; i++) {
391
- const { score, chunk } = matches[i];
392
- lines.push(`${'─'.repeat(50)}`);
393
- lines.push(`#${i + 1} Β· **${chunk.name}** [${chunk.node_type}]`);
394
- lines.push(`πŸ“„ ${chunk.file_path}:${chunk.start_line}–${chunk.end_line} Β· ID: \`${chunk.id}\` Β· RRF: ${score.toFixed(4)}`);
395
-
396
- const sig = [];
397
- if (chunk.params?.length) sig.push(`(${chunk.params.slice(0, 4).join(', ')})`);
398
- if (chunk.return_type) sig.push(`β†’ ${chunk.return_type}`);
399
- if (chunk.type_refs?.length) sig.push(`types: ${chunk.type_refs.slice(0, 4).join(', ')}`);
400
- if (sig.length) lines.push(`πŸ”€ ${sig.join(' ')}`);
401
-
402
- if (chunk.decorators?.length) lines.push(`🏷 ${chunk.decorators.slice(0, 6).map(d => '@' + d).join(' ')}`);
403
- if (chunk.extends?.length) lines.push(`🧬 extends/implements: ${chunk.extends.slice(0, 5).join(', ')}`);
404
- if (chunk.docstring) lines.push(`πŸ’¬ ${chunk.docstring.slice(0, 140).replace(/\n/g, ' ')}`);
405
-
406
- if (include_topology) {
407
- const deps = db.getDependencies(chunk.file_path).slice(0, 3);
408
- const usedBy = db.getImportedBy(chunk.file_path).slice(0, 3);
409
- if (deps.length) lines.push(`⬇️ Deps: ${deps.map(depSignature).join(' | ')}`);
410
- if (usedBy.length) lines.push(`⬆️ Used by: ${usedBy.join(', ')}`);
411
- if (chunk.calls?.length) lines.push(`πŸ”— Calls: ${chunk.calls.slice(0, 6).join(', ')}`);
412
- }
413
- lines.push(`↩️ Expand: get_chunk("${chunk.id}")`);
414
- }
415
-
416
- if (detail !== 'signatures') {
417
- const CHARS_PER_TOKEN = 4;
418
- const defaultBudget = detail === 'full' ? 6000 : 2000;
419
- let remainingChars = token_budget != null ? token_budget * CHARS_PER_TOKEN : defaultBudget;
420
-
421
- lines.push(`\n${'═'.repeat(50)}`);
422
- lines.push(`CODE BODIES (detail: ${detail}, budget: ~${Math.round(remainingChars / CHARS_PER_TOKEN)} tok)\n`);
423
-
424
- const queryTokens = fullQuery.toLowerCase().split(/[\s\W_]+/).filter(t => t.length >= 3);
425
-
426
- for (const { chunk } of matches) {
427
- if (remainingChars <= 0) break;
428
- const raw = chunk.code_snippet || '';
429
- if (!raw) continue;
430
- const snippet = detail === 'full'
431
- ? raw.slice(0, remainingChars)
432
- : pruneBodyByQuery(raw, queryTokens).slice(0, remainingChars);
433
- lines.push(`### ${chunk.name} β€” ${chunk.file_path}`);
434
- lines.push('```\n' + snippet + '\n```\n');
435
- remainingChars -= snippet.length;
436
- }
437
- }
438
-
439
- // Low-confidence: surface candidate files so the agent can
440
- // call get_file_skeleton rather than issuing another vague search.
441
- if (lowConfidence) {
442
- lines.push(`\n${'─'.repeat(50)}`);
443
- lines.push(`⚠️ Low confidence β€” no dominant match. Candidate files (top distinct): ${candidateFiles.map(f => `\`${f}\``).join(', ')}`);
444
- lines.push(`β†’ Try \`get_file_skeleton\` on these, or refine with a symbol name / \`exact_tokens\`.`);
445
- }
446
-
447
- if (rerankFailed) lines.push('', '⚠️ Rerank was requested but the judge model was unreachable β€” results are in raw fused order (not reranked).');
448
- const _note = freshnessNote(fresh);
449
- if (_note) lines.push('', _note);
450
- return { content: [{ type: 'text', text: lines.join('\n') }] };
451
- } catch (err) {
452
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
453
- }
454
- }
455
- );
456
-
457
- // ─── get_chunk ────────────────────────────────────────────────────────────────
458
- server.tool(
459
- 'get_chunk',
460
- 'CRITICAL: Use this INSTEAD of reading full files. Returns the complete body of a function/class by its chunk_id from search_code results.',
461
- {
462
- chunk_id: z.string().describe('The chunk ID shown in search_code results.'),
463
- view: z.enum(['full', 'signature']).default('full').describe(
464
- "'full': complete source body. 'signature': just the function signature line (~5 tokens)."
465
- ),
466
- response_format: z.enum(['markdown', 'json']).default('markdown').describe(
467
- "'markdown' (default) or 'json' (typed fields + topology + code)."
468
- ),
469
- },
470
- async ({ chunk_id, view, response_format }) => {
471
- try {
472
- const chunk = db.getChunk(chunk_id);
473
- if (!chunk) {
474
- return response_format === 'json'
475
- ? jsonResult({ chunk_id, found: false })
476
- : { content: [{ type: 'text', text: `Chunk '${chunk_id}' not found. Run search_code to get valid IDs.` }] };
477
- }
478
-
479
- if (response_format === 'json') {
480
- return jsonResult({
481
- ...chunkCard(chunk),
482
- topology: {
483
- dependencies: db.getDependencies(chunk.file_path),
484
- used_by: db.getImportedBy(chunk.file_path),
485
- },
486
- code: view === 'signature' ? extractSignatureLine(chunk.code_snippet) : chunk.code_snippet,
487
- });
488
- }
489
-
490
- const parts = [
491
- `# ${chunk.name}`,
492
- `**File:** \`${chunk.file_path}\` Β· **Lines:** ${chunk.start_line}–${chunk.end_line} Β· **Type:** ${chunk.node_type}`,
493
- ];
494
- if (chunk.params?.length) parts.push(`**Params:** ${chunk.params.join(', ')}`);
495
- if (chunk.return_type) parts.push(`**Returns:** ${chunk.return_type}`);
496
- if (chunk.type_refs?.length) parts.push(`**Type refs:** ${chunk.type_refs.join(', ')}`);
497
- if (chunk.decorators?.length) parts.push(`**Decorators:** ${chunk.decorators.map(d => '@' + d).join(', ')}`);
498
- if (chunk.extends?.length) parts.push(`**Inherits:** ${chunk.extends.join(', ')}`);
499
- if (chunk.docstring) parts.push(`**Doc:** ${chunk.docstring}`);
500
-
501
- const deps = db.getDependencies(chunk.file_path);
502
- const usedBy = db.getImportedBy(chunk.file_path);
503
- if (deps.length) parts.push(`⬇️ Imports: ${deps.join(', ')}`);
504
- if (usedBy.length) parts.push(`⬆️ Used by: ${usedBy.join(', ')}`);
505
- if (chunk.calls?.length) parts.push(`πŸ”— Calls: ${chunk.calls.join(', ')}`);
506
-
507
- if (view === 'signature') parts.push('', '```', extractSignatureLine(chunk.code_snippet), '```');
508
- else parts.push('', '```', chunk.code_snippet, '```');
509
- return { content: [{ type: 'text', text: parts.join('\n') }] };
510
- } catch (err) {
511
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
512
- }
513
- }
514
- );
515
-
516
- // ─── resolve_symbol ─────────────────────────────────────────────────────────
517
- server.tool(
518
- 'resolve_symbol',
519
- '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.',
520
- {
521
- symbol: z.string().describe("Exact symbol name (e.g. 'validateToken', 'User', 'PaymentService')."),
522
- response_format: z.enum(['markdown', 'json']).default('markdown').describe(
523
- "'markdown' (default) or 'json' (typed definition list + topology)."
524
- ),
525
- },
526
- async ({ symbol, response_format }) => {
527
- try {
528
- const defs = db.resolveSymbol(symbol);
529
- if (defs.length === 0) {
530
- const indexEmpty = db.chunkCount() === 0;
531
- const emptyHint = 'Index is empty β€” run `npm run mcp:index` (or `idx-index --repo <path>`) to build it.';
532
- return response_format === 'json'
533
- ? jsonResult({ symbol, count: 0, ...(indexEmpty ? { index_status: 'empty', hint: emptyHint } : {}), definitions: [] })
534
- : { content: [{ type: 'text', text: indexEmpty ? `⚠️ ${emptyHint}` : `Symbol '${symbol}' not in index. Try search_code(query="${symbol}") for fuzzy search.` }] };
535
- }
536
- if (response_format === 'json') {
537
- return jsonResult({
538
- symbol,
539
- count: defs.length,
540
- definitions: defs.map(chunk => ({
541
- ...chunkCard(chunk),
542
- topology: {
543
- dependencies: db.getDependencies(chunk.file_path),
544
- used_by: db.getImportedBy(chunk.file_path),
545
- },
546
- signature: extractSignatureLine(chunk.code_snippet),
547
- })),
548
- });
549
- }
550
- const lines = [`# Symbol: \`${symbol}\` β€” ${defs.length} definition(s)\n`];
551
- for (const chunk of defs) {
552
- lines.push(`${'─'.repeat(50)}`);
553
- lines.push(`**${chunk.name}** [${chunk.node_type}]`);
554
- lines.push(`πŸ“„ ${chunk.file_path}:${chunk.start_line}–${chunk.end_line} Β· ID: \`${chunk.id}\``);
555
- if (chunk.params?.length) lines.push(`πŸ”€ Params: ${chunk.params.join(', ')}`);
556
- if (chunk.return_type) lines.push(`πŸ”€ Returns: ${chunk.return_type}`);
557
- if (chunk.type_refs?.length) lines.push(`πŸ”— Type refs: ${chunk.type_refs.join(', ')}`);
558
- if (chunk.decorators?.length) lines.push(`🏷 ${chunk.decorators.slice(0, 6).map(d => '@' + d).join(' ')}`);
559
- if (chunk.docstring) lines.push(`πŸ’¬ ${chunk.docstring.slice(0, 160).replace(/\n/g, ' ')}`);
560
- const deps = db.getDependencies(chunk.file_path).slice(0, 4);
561
- const usedBy = db.getImportedBy(chunk.file_path).slice(0, 4);
562
- if (deps.length) lines.push(`⬇️ Imports: ${deps.join(', ')}`);
563
- if (usedBy.length) lines.push(`⬆️ Used by: ${usedBy.join(', ')}`);
564
- if (chunk.calls?.length) lines.push(`πŸ”— Calls: ${chunk.calls.slice(0, 8).join(', ')}`);
565
- lines.push(`\n\`\`\`\n${extractSignatureLine(chunk.code_snippet)}\n\`\`\``);
566
- lines.push(`↩️ Full body: get_chunk("${chunk.id}")`);
567
- }
568
- return { content: [{ type: 'text', text: lines.join('\n') }] };
569
- } catch (err) {
570
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
571
- }
572
- }
573
- );
574
-
575
- // ─── get_chunk_summary ──────────────────────────────────────────────────────
576
- server.tool(
577
- 'get_chunk_summary',
578
- '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.',
579
- {
580
- chunk_id: z.string().describe('The chunk ID from search_code results.'),
581
- expand_calls: z.boolean().default(false).describe(
582
- 'When true, resolves the signatures of outgoing dependencies inline (~150 tok vs ~50 tok). '
583
- + 'Use when you need to understand the interfaces of called functions in a single shot, '
584
- + 'without issuing a separate tool call per dependency.'
585
- ),
586
- response_format: z.enum(['markdown', 'json']).default('markdown').describe(
587
- "'markdown' (default) or 'json' (typed signature + docstring + resolved calls)."
588
- ),
589
- },
590
- async ({ chunk_id, expand_calls, response_format }) => {
591
- try {
592
- const chunk = db.getChunk(chunk_id);
593
- if (!chunk) {
594
- return response_format === 'json'
595
- ? jsonResult({ chunk_id, found: false })
596
- : { content: [{ type: 'text', text: `Chunk '${chunk_id}' not found.` }] };
597
- }
598
-
599
- const resolveCalls = () => {
600
- const out = [];
601
- const seen = new Set();
602
- for (const callName of (chunk.calls || [])) {
603
- if (seen.size >= 6) break;
604
- const key = callName.toLowerCase();
605
- if (seen.has(key)) continue;
606
- seen.add(key);
607
- const target = db.resolveSymbol(callName)[0];
608
- if (!target?.code_snippet) continue;
609
- out.push({
610
- name: callName,
611
- file_path: target.file_path,
612
- start_line: target.start_line,
613
- signature: extractSignatureLine(target.code_snippet).split('\n')[0].trim().slice(0, 120),
614
- });
615
- }
616
- return out;
617
- };
618
-
619
- if (response_format === 'json') {
620
- return jsonResult({
621
- id: chunk.id,
622
- name: chunk.name,
623
- node_type: chunk.node_type,
624
- file_path: chunk.file_path,
625
- start_line: chunk.start_line,
626
- end_line: chunk.end_line,
627
- params: chunk.params || [],
628
- return_type: chunk.return_type || null,
629
- type_refs: chunk.type_refs || [],
630
- decorators: chunk.decorators || [],
631
- docstring: chunk.docstring || null,
632
- signature: extractSignatureLine(chunk.code_snippet),
633
- calls: chunk.calls || [],
634
- resolved_calls: expand_calls ? resolveCalls() : undefined,
635
- });
636
- }
637
-
638
- const lines = [
639
- `# ${chunk.name} Β· ${chunk.file_path}:${chunk.start_line}–${chunk.end_line}`,
640
- `**Type:** ${chunk.node_type}`,
641
- ];
642
- if (chunk.params?.length) lines.push(`**Params:** ${chunk.params.join(', ')}`);
643
- if (chunk.return_type) lines.push(`**Returns:** ${chunk.return_type}`);
644
- if (chunk.type_refs?.length) lines.push(`**Type refs:** ${chunk.type_refs.join(', ')}`);
645
- if (chunk.decorators?.length) lines.push(`**Decorators:** ${chunk.decorators.map(d => '@' + d).join(', ')}`);
646
- if (chunk.docstring) lines.push(`\n**Doc:** ${chunk.docstring.slice(0, 300)}`);
647
-
648
- if (chunk.calls?.length) {
649
- if (!expand_calls) {
650
- lines.push(`**Calls:** ${chunk.calls.join(', ')}`);
651
- } else {
652
- const expanded = [];
653
- const seen = new Set();
654
- const SIG_BUDGET = 6;
655
- for (const callName of chunk.calls) {
656
- if (seen.size >= SIG_BUDGET) break;
657
- const key = callName.toLowerCase();
658
- if (seen.has(key)) continue;
659
- seen.add(key);
660
- const target = db.resolveSymbol(callName)[0];
661
- if (!target?.code_snippet) continue;
662
- const sig = extractSignatureLine(target.code_snippet).split('\n')[0].trim().slice(0, 120);
663
- expanded.push(
664
- ` **${callName}** β†’ \`${target.file_path}:${target.start_line}\``
665
- + `\n \`\`\`\n ${sig}\n \`\`\``
666
- );
667
- }
668
- const unresolved = chunk.calls.length - expanded.length;
669
- lines.push(`\n**Calls (${expanded.length} resolved${unresolved > 0 ? `, ${unresolved} unindexed` : ''}):**`);
670
- if (expanded.length > 0) lines.push(...expanded);
671
- else lines.push(' _(none resolved in index)_');
672
- }
673
- }
674
-
675
- lines.push('', '```', extractSignatureLine(chunk.code_snippet), '```');
676
- lines.push(`\n↩️ Full body: get_chunk("${chunk.id}")`);
677
- return { content: [{ type: 'text', text: lines.join('\n') }] };
678
- } catch (err) {
679
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
680
- }
681
- }
682
- );
683
-
684
- // ─── get_file_skeleton ──────────────────────────────────────────────────────
685
- server.tool(
686
- 'get_file_skeleton',
687
- 'Returns all top-level exports and definitions in a file with line numbers β€” no code bodies (~50 tokens vs 5000).',
688
- {
689
- file_path: z.string().describe("Relative path (e.g. 'src/app.ts')."),
690
- response_format: z.enum(['markdown', 'json']).default('markdown').describe(
691
- "'markdown' (default) or 'json' ({ file_path, skeleton } string fields)."
692
- ),
693
- },
694
- async ({ file_path, response_format }) => {
695
- try {
696
- const absolutePath = resolve(projectRoot, file_path);
697
- const safeRoot = path.normalize(projectRoot);
698
- const norm = path.normalize(absolutePath);
699
- if (norm !== safeRoot && !norm.startsWith(safeRoot + path.sep)) {
700
- throw new Error('Access denied: path is outside the project root.');
701
- }
702
- if (!fs.existsSync(absolutePath)) throw new Error('File not found.');
703
- // Defence in depth: the textual check above can be defeated by a
704
- // symlink inside the project that points outside it. Resolve symlinks
705
- // on both sides and re-check containment to block that escape path.
706
- let realRoot, realPath;
707
- try { realRoot = fs.realpathSync(safeRoot); } catch { realRoot = safeRoot; }
708
- try { realPath = fs.realpathSync(absolutePath); } catch { realPath = norm; }
709
- if (realPath !== realRoot && !realPath.startsWith(realRoot + path.sep)) {
710
- throw new Error('Access denied: path resolves outside the project root.');
711
- }
712
- const content = fs.readFileSync(absolutePath, 'utf-8');
713
- const ext = path.extname(absolutePath);
714
- const parser = getParserForFile(ext);
715
- if (!parser) {
716
- return response_format === 'json'
717
- ? jsonResult({ file_path, language_supported: false, skeleton: null })
718
- : { content: [{ type: 'text', text: 'Language not supported.' }] };
719
- }
720
- const tree = parser.parse((offset) => offset < content.length ? content.slice(offset, offset + 4096) : null);
721
- const skeleton = extractFileSkeleton(tree.rootNode, content);
722
- if (response_format === 'json') {
723
- return jsonResult({ file_path, language_supported: true, skeleton: skeleton || '' });
724
- }
725
- return { content: [{ type: 'text', text: `# Skeleton: ${file_path}\n\n${skeleton || '_No semantic signatures found_'}` }] };
726
- } catch (err) {
727
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
728
- }
729
- }
730
- );
731
-
732
- // ─── get_call_graph ─────────────────────────────────────────────────────────
733
- server.tool(
734
- 'get_call_graph',
735
- 'Finds all chunks that call a specific function, split into high-confidence callers '
736
- + '(the real blast radius) and name-only matches (an ambiguous same-named symbol). '
737
- + 'CRITICAL for safe refactoring β€” call before changing any exported signature.',
738
- {
739
- target_function: z.string().describe("Exact function name (e.g. 'validateToken')."),
740
- target_class: z.string().optional().describe(
741
- "Optional class/type that owns the method (e.g. 'OrderService') β€” scopes the "
742
- + "blast radius to one class's method when several symbols share the name."
743
- ),
744
- response_format: z.enum(['markdown', 'json']).default('markdown').describe(
745
- "'markdown' (default) or 'json' (typed { high, name_only } caller arrays)."
746
- ),
747
- },
748
- async ({ target_function, target_class, response_format }) => {
749
- try {
750
- const { high, nameOnly, targetDefs, ambiguous, hasSiteData, classFiltered } =
751
- classifyCallers(db, target_function, { targetClass: target_class || null });
752
- const total = high.length + nameOnly.length;
753
- const label = classFiltered ? `${target_class}.${target_function}` : target_function;
754
- const coChanges = coChangeFiles(gitSignals, [...new Set(targetDefs.map(d => d.file_path))]);
755
- const fresh = indexFreshness();
756
- const note = freshnessNote(fresh);
757
-
758
- if (response_format === 'json') {
759
- return jsonResult({
760
- target_function,
761
- target_class: target_class || null,
762
- ambiguous,
763
- definition_count: targetDefs.length,
764
- receiver_aware: hasSiteData,
765
- caller_count: total,
766
- high_confidence: high.map(h => refCard({ ...h, confidence: 'high' })),
767
- name_only: nameOnly.map(n => refCard({ ...n, confidence: 'name-only' })),
768
- co_changes: coChanges,
769
- index: fresh,
770
- });
771
- }
772
-
773
- if (total === 0) {
774
- // "No callers" from a stale index is dangerously misleading β€” always surface freshness here.
775
- const parts = [`βœ… Safe to modify: no callers of \`${label}\` found.`];
776
- if (coChanges.length) parts.push(coChangeLine(coChanges));
777
- if (note) parts.push(note);
778
- return { content: [{ type: 'text', text: parts.join('\n') }] };
779
- }
780
-
781
- const fmt = ({ chunk, recvHint }) =>
782
- `- [${chunk.node_type}] \`${chunk.class_context ? chunk.class_context + '.' : ''}${chunk.name}\``
783
- + ` in \`${chunk.file_path}\` (lines ${chunk.start_line}–${chunk.end_line})`
784
- + (recvHint ? ` Β· via ${recvHint}` : '');
785
-
786
- const lines = [`# ⚠️ Call Graph: \`${label}\``];
787
- const split = ambiguous && !classFiltered;
788
-
789
- if (split) {
790
- lines.push(`${total} name-match caller(s) Β· ⚠️ ${targetDefs.length}+ symbols named \`${target_function}\` β€” grouped by confidence:`);
791
- } else {
792
- lines.push(`${total} caller(s) depend on this β€” review before changing signature:`);
793
- }
794
-
795
- if (high.length) {
796
- if (split) lines.push('', `## βœ… High-confidence callers (${high.length})`);
797
- for (const h of high) lines.push(fmt(h));
798
- }
799
- if (nameOnly.length) {
800
- lines.push('', `## ❔ Name-only matches (${nameOnly.length}) β€” verify; may call a different \`${target_function}\``);
801
- for (const n of nameOnly) lines.push(fmt(n));
802
- if (!classFiltered) lines.push('', `> Pass \`target_class\` to scope the blast radius to one class's \`${target_function}\`.`);
803
- }
804
- if (!hasSiteData && ambiguous) {
805
- lines.push('', `> ℹ️ This index predates receiver-aware call graphs β€” re-run \`npm run mcp:index\` for precise grouping.`);
806
- }
807
- if (coChanges.length) lines.push('', coChangeLine(coChanges));
808
- if (note) lines.push('', note);
809
- return { content: [{ type: 'text', text: lines.join('\n') }] };
810
- } catch (err) {
811
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
812
- }
813
- }
814
- );
815
-
816
- // ─── find_references ──────────────────────────────────────────────────────────
817
- server.tool(
818
- 'find_references',
819
- 'Every symbol that references a target symbol β€” symbol-level, not just file-level. '
820
- + 'Fuses three reference kinds: callers (the blast radius), subclasses/implementers '
821
- + '(extends), and type users (params, returns, fields). Each is split by confidence '
822
- + 'using receiver hints and the import graph. Broader than get_call_graph: use it before '
823
- + 'renaming or changing a class/interface/type, not just a function.',
824
- {
825
- symbol: z.string().describe("Exact symbol name (function, class, interface, or type)."),
826
- target_class: z.string().optional().describe(
827
- "Optional owning class/type to scope the call dimension when several symbols share the name."
828
- ),
829
- response_format: z.enum(['markdown', 'json']).default('markdown').describe(
830
- "'markdown' (default) or 'json' (typed { called_by, subclassed_by, used_as_type_by })."
831
- ),
832
- },
833
- async ({ symbol, target_class, response_format }) => {
834
- try {
835
- const { targetDefs, ambiguous, calls, inherits, types, references } =
836
- findReferences(db, symbol, { targetClass: target_class || null });
837
- const callTotal = calls.high.length + calls.nameOnly.length;
838
- const total = callTotal + inherits.length + types.length;
839
- const scipRefs = references || [];
840
- const coChanges = coChangeFiles(gitSignals, [...new Set(targetDefs.map(d => d.file_path))]);
841
- const fresh = indexFreshness();
842
- const note = freshnessNote(fresh);
843
-
844
- if (response_format === 'json') {
845
- return jsonResult({
846
- symbol,
847
- target_class: target_class || null,
848
- ambiguous,
849
- definition_count: targetDefs.length,
850
- reference_count: total,
851
- called_by: {
852
- high: calls.high.map(h => refCard({ ...h, confidence: 'high' })),
853
- name_only: calls.nameOnly.map(n => refCard({ ...n, confidence: 'name-only' })),
854
- },
855
- subclassed_by: inherits.map(refCard),
856
- used_as_type_by: types.map(refCard),
857
- // A2 v2: binding-precise cross-file references from a SCIP index (resolved tier).
858
- referenced_by_resolved: scipRefs.map(refCard),
859
- resolved_reference_count: scipRefs.length,
860
- co_changes: coChanges,
861
- index: fresh,
862
- });
863
- }
864
-
865
- if (total === 0 && scipRefs.length === 0) {
866
- const parts = [`βœ… No references to \`${symbol}\` found in the index.`];
867
- if (coChanges.length) parts.push(coChangeLine(coChanges));
868
- if (note) parts.push(note);
869
- return { content: [{ type: 'text', text: parts.join('\n') }] };
870
- }
871
-
872
- const fmt = ({ chunk, recvHint, confidence }) =>
873
- `- [${chunk.node_type}] \`${chunk.class_context ? chunk.class_context + '.' : ''}${chunk.name}\``
874
- + ` in \`${chunk.file_path}\` (lines ${chunk.start_line}–${chunk.end_line})`
875
- + (recvHint ? ` Β· via ${recvHint}` : '')
876
- + (confidence === 'name-only' ? ' · ⚠️ unverified' : '');
877
-
878
- const lines = [`# πŸ”— References to \`${symbol}\` β€” ${total} total`
879
- + (scipRefs.length ? ` Β· ${scipRefs.length} SCIP-resolved` : '')];
880
- if (ambiguous) lines.push(`⚠️ ${targetDefs.length} symbols named \`${symbol}\` β€” name-only matches may target a different one.`);
881
-
882
- if (callTotal) {
883
- lines.push('', `## πŸ“ž Called by (${callTotal})`);
884
- for (const h of calls.high) lines.push(fmt({ ...h, confidence: 'high' }));
885
- for (const n of calls.nameOnly) lines.push(fmt({ ...n, confidence: 'name-only' }));
886
- }
887
- if (inherits.length) {
888
- lines.push('', `## 🧬 Subclassed / implemented by (${inherits.length})`);
889
- for (const it of inherits) lines.push(fmt(it));
890
- }
891
- if (types.length) {
892
- lines.push('', `## 🏷 Used as a type by (${types.length})`);
893
- for (const it of types) lines.push(fmt(it));
894
- }
895
- if (scipRefs.length) {
896
- lines.push('', `## 🎯 Referenced by β€” SCIP-resolved (${scipRefs.length})`,
897
- '_Binding-precise cross-file references from your SCIP index (kind-agnostic): disambiguates'
898
- + ' same-named symbols and catches uses the name heuristics above can miss._');
899
- for (const it of scipRefs) lines.push(fmt(it));
900
- }
901
- if (coChanges.length) lines.push('', coChangeLine(coChanges));
902
- if (note) lines.push('', note);
903
- return { content: [{ type: 'text', text: lines.join('\n') }] };
904
- } catch (err) {
905
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
906
- }
907
- }
908
- );
909
-
910
- // ─── find_routes ──────────────────────────────────────────────────────────────
911
- server.tool(
912
- 'find_routes',
913
- 'Map HTTP routes to their handler functions. Returns the handler chunk ID so '
914
- + 'you can call get_chunk or get_call_graph on the handler directly. Covers '
915
- + 'NestJS decorators, FastAPI/Flask, Spring (Java) annotations, and Express/Koa '
916
- + 'registration. Filter by method and/or path.',
917
- {
918
- method: z.string().optional().describe(
919
- "Optional HTTP method filter (GET, POST, PUT, DELETE, PATCH). Case-insensitive; omit for all methods."
920
- ),
921
- path: z.string().optional().describe(
922
- "Optional path filter. Starts with '/' β†’ prefix match; otherwise (incl. pattern hints with '{' or ':') β†’ substring contains-match."
923
- ),
924
- response_format: z.enum(['markdown', 'json']).default('markdown').describe(
925
- "'markdown' (default) or 'json' (typed { routes: [...] })."
926
- ),
927
- },
928
- async ({ method, path: pathArg, response_format }) => {
929
- try {
930
- const routes = findRoutes(db, { method: method || null, path: pathArg || null });
931
- const fresh = indexFreshness();
932
- const note = freshnessNote(fresh);
933
-
934
- if (response_format === 'json') {
935
- return jsonResult({
936
- method: method || null,
937
- path: pathArg || null,
938
- route_count: routes.length,
939
- routes,
940
- index: fresh,
941
- });
942
- }
943
-
944
- if (routes.length === 0) {
945
- const filt = [method ? method.toUpperCase() : null, pathArg].filter(Boolean).join(' ');
946
- const parts = [`βœ… No HTTP routes${filt ? ` matching \`${filt}\`` : ''} found in the index.`];
947
- if (note) parts.push(note);
948
- return { content: [{ type: 'text', text: parts.join('\n') }] };
949
- }
950
-
951
- const lines = [`# 🌐 HTTP routes β€” ${routes.length} total`];
952
- for (const r of routes) {
953
- const handler = r.id
954
- ? `\`${r.handler_name}\` [${r.node_type}] in \`${r.file_path}\` (lines ${r.start_line}–${r.end_line}) Β· id \`${r.id}\``
955
- : `\`${r.handler_name}\` in \`${r.file_path}\`${r.line ? ` (line ${r.line})` : ''}`;
956
- lines.push(`- **${r.method}** \`${r.path}\` β†’ ${handler}`);
957
- }
958
- if (note) lines.push('', note);
959
- return { content: [{ type: 'text', text: lines.join('\n') }] };
960
- } catch (err) {
961
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
962
- }
963
- }
964
- );
965
-
966
- // ─── get_subgraph ─────────────────────────────────────────────────────────────
967
- server.tool(
968
- 'get_subgraph',
969
- 'Trace a flow across files in ONE call: returns a bounded connected subgraph around a '
970
- + 'seed symbol β€” what it calls, its high-confidence callers (precise blast radius), and '
971
- + 'its type/inheritance users β€” within a node and token budget. Use instead of chaining '
972
- + 'search_code β†’ get_call_graph β†’ find_references for cross-cutting "how does X flow" questions.',
973
- {
974
- symbol: z.string().describe('Seed symbol name (function, method, class, or type).'),
975
- depth: z.number().int().min(1).max(3).default(2).describe('Hops to traverse from the seed (1–3).'),
976
- max_nodes: z.number().int().min(1).max(40).default(12).describe('Max chunks in the subgraph.'),
977
- token_budget: z.number().int().min(100).optional().describe(
978
- 'Token budget for the subgraph (1 token β‰ˆ 4 chars). Omit for node-count-only bounding.'
979
- ),
980
- response_format: z.enum(['markdown', 'json']).default('markdown').describe(
981
- "'markdown' (default) or 'json' (typed { nodes, edges })."
982
- ),
983
- },
984
- async ({ symbol, depth, max_nodes, token_budget, response_format }) => {
985
- try {
986
- const g = buildSubgraph(db, symbol, { maxNodes: max_nodes, maxDepth: depth, tokenBudget: token_budget ?? null });
987
- const fresh = indexFreshness();
988
- const note = freshnessNote(fresh);
989
- if (!g.found) {
990
- return response_format === 'json'
991
- ? jsonResult({ symbol, found: false, nodes: [], edges: [], index: fresh })
992
- : { content: [{ type: 'text', text: `Symbol '${symbol}' not in index. Try search_code(query="${symbol}").${note ? '\n' + note : ''}` }] };
993
- }
994
- if (response_format === 'json') return jsonResult({ ...g, index: fresh });
995
-
996
- const byId = new Map(g.nodes.map(n => [n.id, n]));
997
- const label = (id) => { const n = byId.get(id); return n ? `${n.class_context ? n.class_context + '.' : ''}${n.name}` : id; };
998
- const lines = [
999
- `# πŸ•Έ Subgraph around \`${symbol}\` β€” ${g.nodes.length} node(s), ${g.edges.length} edge(s)`
1000
- + (g.truncated ? ` · ⚠️ truncated at the budget (raise max_nodes / token_budget for more)` : ''),
1001
- '',
1002
- `## Nodes`,
1003
- ];
1004
- for (const n of g.nodes) {
1005
- 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}\``);
1006
- }
1007
- if (g.edges.length) {
1008
- lines.push('', `## Edges`);
1009
- for (const e of g.edges) {
1010
- lines.push(`- \`${label(e.from)}\` ${e.kind === 'calls' ? 'β†’ calls β†’' : 'β‡’ references β‡’'} \`${label(e.to)}\``);
1011
- }
1012
- }
1013
- if (note) lines.push('', note);
1014
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1015
- } catch (err) {
1016
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
1017
- }
1018
- }
1019
- );
1020
-
1021
- // ─── get_repo_map ───────────────────────────────────────────────────────────
1022
- server.tool(
1023
- 'get_repo_map',
1024
- '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.',
1025
- {
1026
- path_filter: z.string().optional().describe(
1027
- "Only include files whose path contains this string (e.g. 'auth', 'api/v2', 'src/core')."
1028
- ),
1029
- max_files: z.number().int().min(1).max(300).default(80).describe('Max files to include in the map.'),
1030
- sort_by: z.enum(['importance', 'path']).default('importance').describe(
1031
- "'importance' (default): most-imported files first (PageRank). 'path': alphabetical."
1032
- ),
1033
- response_format: z.enum(['markdown', 'json']).default('markdown').describe(
1034
- "'markdown' (default) or 'json' (typed { files: [{ file_path, symbols }] })."
1035
- ),
1036
- },
1037
- async ({ path_filter, max_files, sort_by, response_format }) => {
1038
- try {
1039
- const fileChunks = new Map();
1040
- const filterLower = path_filter ? path_filter.toLowerCase() : null;
1041
- for (const chunk of db.iterateChunks()) {
1042
- if (filterLower && !chunk.file_path.toLowerCase().includes(filterLower)) continue;
1043
- if (chunk.name === 'anonymous' || chunk.name === 'default_export') continue;
1044
- if (!fileChunks.has(chunk.file_path)) fileChunks.set(chunk.file_path, []);
1045
- fileChunks.get(chunk.file_path).push(chunk);
1046
- }
1047
-
1048
- if (fileChunks.size === 0) {
1049
- return response_format === 'json'
1050
- ? jsonResult({ total_files: 0, total_symbols: 0, files: [] })
1051
- : {
1052
- content: [{
1053
- type: 'text', text: path_filter
1054
- ? `No files found matching '${path_filter}'. Try a broader filter.`
1055
- : 'Index is empty. Run `npm run mcp:index` first.'
1056
- }]
1057
- };
1058
- }
1059
-
1060
- let sortedFiles = Array.from(fileChunks.keys());
1061
- if (sort_by === 'importance') {
1062
- const pr = computePageRank(db.graph);
1063
- sortedFiles.sort((a, b) => (pr.get(b) ?? 0) - (pr.get(a) ?? 0));
1064
- } else {
1065
- sortedFiles.sort();
1066
- }
1067
- sortedFiles = sortedFiles.slice(0, max_files);
1068
-
1069
- const totalFiles = fileChunks.size;
1070
- const totalSymbols = Array.from(fileChunks.values()).reduce((s, a) => s + a.length, 0);
1071
-
1072
- // A5: most-central symbols (PageRank over the resolved symbol graph), shown only
1073
- // on the unfiltered orientation view and only when --symbol-graph built it.
1074
- // Absent on the default path β†’ output byte-identical.
1075
- const central = (db.hasCentrality?.() && !path_filter) ? db.topCentral(12) : [];
1076
-
1077
- if (response_format === 'json') {
1078
- const files = sortedFiles.map(filePath => {
1079
- const seen = new Set();
1080
- const symbols = [];
1081
- for (const c of fileChunks.get(filePath)) {
1082
- const key = c.name.toLowerCase();
1083
- if (seen.has(key)) continue;
1084
- seen.add(key);
1085
- symbols.push({
1086
- name: c.name, node_type: c.node_type,
1087
- params: c.params || [], return_type: c.return_type || null,
1088
- start_line: c.start_line, end_line: c.end_line,
1089
- });
1090
- }
1091
- return { file_path: filePath, symbol_count: symbols.length, symbols };
1092
- });
1093
- return jsonResult({
1094
- total_files: totalFiles, total_symbols: totalSymbols,
1095
- shown_files: sortedFiles.length, sort_by, path_filter: path_filter || null,
1096
- ...(central.length ? {
1097
- central_total: db.getCentrality(central[0].chunk.id)?.total ?? central.length,
1098
- central_symbols: central.map(({ chunk, score, rank }) => ({
1099
- name: chunk.name, class_context: chunk.class_context || null,
1100
- file_path: chunk.file_path, node_type: chunk.node_type,
1101
- rank, score,
1102
- })),
1103
- } : {}),
1104
- files,
1105
- });
1106
- }
1107
-
1108
- const lines = [
1109
- `# Repo Map β€” ${totalSymbols} symbols across ${totalFiles} files`,
1110
- path_filter ? `(filtered to '${path_filter}')` : '',
1111
- sortedFiles.length < totalFiles ? `(showing top ${sortedFiles.length} by ${sort_by}; use path_filter to narrow)\n` : '',
1112
- ].filter(Boolean);
1113
-
1114
- if (central.length) {
1115
- const cenTotal = db.getCentrality(central[0].chunk.id)?.total ?? central.length;
1116
- lines.push(`**Most central symbols** (PageRank over the resolved symbol graph, top ${central.length} of ${cenTotal}):`);
1117
- for (const { chunk, rank } of central) {
1118
- const nm = `${chunk.class_context ? chunk.class_context + '.' : ''}${chunk.name}`;
1119
- lines.push(` ${rank}. \`${nm}\` β€” \`${chunk.file_path}\``);
1120
- }
1121
- lines.push('');
1122
- }
1123
-
1124
- for (const filePath of sortedFiles) {
1125
- const chunks = fileChunks.get(filePath);
1126
- lines.push(`\n${filePath}`);
1127
- const seen = new Set();
1128
- const deduped = [];
1129
- for (const c of chunks) {
1130
- const key = c.name.toLowerCase();
1131
- if (seen.has(key)) continue;
1132
- seen.add(key);
1133
- deduped.push(c);
1134
- }
1135
- for (const c of deduped.slice(0, 8)) {
1136
- const kind = c.node_type.includes('class') ? 'class'
1137
- : c.node_type.includes('interface') ? 'interface'
1138
- : c.node_type.includes('enum') ? 'enum'
1139
- : c.node_type.includes('struct') ? 'struct'
1140
- : c.node_type.includes('trait') ? 'trait' : 'fn';
1141
- const params = c.params?.length
1142
- ? `(${c.params.slice(0, 3).join(', ')}${c.params.length > 3 ? ', …' : ''})` : '';
1143
- const ret = c.return_type ? ` β†’ ${c.return_type.slice(0, 40)}` : '';
1144
- lines.push(` ${kind} ${c.name}${params}${ret}`);
1145
- }
1146
- if (deduped.length > 8) lines.push(` … (${deduped.length - 8} more)`);
1147
- }
1148
- if (totalFiles > max_files) {
1149
- lines.push(`\n… ${totalFiles - max_files} more files not shown. Use path_filter or increase max_files.`);
1150
- }
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
-
1158
- // ─── list_index_stats ───────────────────────────────────────────────────────
1159
- server.tool(
1160
- 'list_index_stats',
1161
- 'Returns index health: chunk count, embedding status, daemon status, search mode, storage backend, and index freshness.',
1162
- {
1163
- response_format: z.enum(['markdown', 'json']).default('markdown').describe(
1164
- "'markdown' (default) or 'json' (typed health fields)."
1165
- ),
1166
- },
1167
- async ({ response_format }) => {
1168
- try {
1169
- const s = db.stats();
1170
-
1171
- let indexAge = 'unknown';
1172
- let ageSeconds = null;
1173
- try {
1174
- ageSeconds = Math.floor((Date.now() - fs.statSync(artifactPath).mtimeMs) / 1000);
1175
- indexAge = ageSeconds < 60 ? `${ageSeconds}s ago` : ageSeconds < 3600 ? `${Math.floor(ageSeconds / 60)}m ago` : `${Math.floor(ageSeconds / 3600)}h ago`;
1176
- } catch { }
1177
-
1178
- let daemonStatus = 'not running';
1179
- let daemonRunning = false;
1180
- try {
1181
- if (pidFile && fs.existsSync(pidFile)) {
1182
- const pid = parseInt(fs.readFileSync(pidFile, 'utf-8'), 10);
1183
- process.kill(pid, 0);
1184
- daemonStatus = `running (PID: ${pid})`;
1185
- daemonRunning = true;
1186
- }
1187
- } catch { daemonStatus = 'not running (stale PID)'; }
1188
-
1189
- const embedLabel = embedder ? describeEmbedder(embedder) : 'πŸ”€ Lexical only';
1190
- const searchModeText = !embeddingsEnabled ? 'lexical-only' : s.hasVectors ? 'hybrid' : 'lexical-only';
1191
- const searchMode = !embeddingsEnabled
1192
- ? 'πŸ”€ Lexical only (embeddings disabled)'
1193
- : s.hasVectors
1194
- ? `🧠 Hybrid (semantic + lexical RRF) Β· ${embedLabel} β€” vectors: ${s.vectorSource}`
1195
- : 'πŸ”€ Lexical only (no vectors indexed yet)';
1196
-
1197
- const fresh = indexFreshness();
1198
-
1199
- if (response_format === 'json') {
1200
- return jsonResult({
1201
- backend: s.backend,
1202
- chunks: s.chunks,
1203
- files: s.files,
1204
- symbols: s.symbols,
1205
- vectors: s.vectors,
1206
- has_vectors: s.hasVectors,
1207
- search_mode: searchModeText,
1208
- embeddings_enabled: Boolean(embeddingsEnabled),
1209
- embedder: embedder ? { provider: embedder.provider, model: embedder.model, dim: embedder.dim ?? null } : null,
1210
- lazy_mode: s.lazyMode,
1211
- sealed: sealed,
1212
- egress_guard: sealed === 'off' ? 'off' : (egressGuardActive() ? 'active' : 'requested'),
1213
- taint_flows: db.hasTaint?.() ? (db.getTaintFlows()?.flows?.length ?? 0) : null,
1214
- scip_references: db.hasScipRefs?.() ? (db.scipRefCount?.() ?? null) : null,
1215
- daemon_running: daemonRunning,
1216
- index_age_seconds: ageSeconds,
1217
- freshness: {
1218
- indexed_commit: fresh.indexedCommit,
1219
- current_commit: fresh.currentCommit,
1220
- pending_changes: fresh.pendingChanges,
1221
- stale: fresh.stale,
1222
- syncing: fresh.syncing,
1223
- },
1224
- ext_counts: Object.fromEntries(s.extCounts),
1225
- });
1226
- }
1227
-
1228
- const lines = [
1229
- `# πŸ“Š graph-indexer Index Stats`, '',
1230
- `| Metric | Value |`, `| :--- | :--- |`,
1231
- `| **Storage backend** | ${s.backend === 'sqlite' ? 'πŸ—„ SQLite (disk-backed)' : '⚑ In-memory'} |`,
1232
- `| **Chunks** | ${s.chunks} |`,
1233
- `| **Files indexed** | ${s.files} |`,
1234
- `| **Symbols in table** | ${s.symbols} |`,
1235
- `| **Vector entries** | ${s.vectors} |`,
1236
- `| **Search mode** | ${searchMode} |`,
1237
- `| **Lazy vec mode** | ${s.lazyMode ? 'βœ… Yes (enterprise scale)' : '❌ No (small corpus)'} |`,
1238
- ...(sealed !== 'off' ? [`| **Sealed mode** | πŸ”’ ${sealed} Β· egress guard ${egressGuardActive() ? 'active' : 'requested'} |`] : []),
1239
- ...(db.hasTaint?.() ? [`| **Taint flows** | πŸ§ͺ ${db.getTaintFlows()?.flows?.length ?? 0} precomputed |`] : []),
1240
- ...(db.hasScipRefs?.() ? [`| **SCIP references** | 🎯 ${db.scipRefCount?.() ?? 0} definitions (precise cross-file) |`] : []),
1241
- `| **Daemon** | ${daemonStatus} |`,
1242
- `| **Index age** | ${indexAge} |`,
1243
- `| **Built at commit** | ${fresh.indexedCommit || 'β€”'}${fresh.commitMoved ? ` (HEAD now ${fresh.currentCommit})` : ''} |`,
1244
- `| **Pending changes** | ${fresh.pendingChanges == null ? 'β€”' : fresh.pendingChanges} |`,
1245
- `| **Freshness** | ${fresh.stale ? '⚠️ STALE β€” run `npm run mcp:index`' : fresh.syncing ? 'πŸ”„ daemon syncing' : 'βœ… fresh'} |`,
1246
- '', `## Extension Breakdown`,
1247
- ...Array.from(s.extCounts.entries()).sort((a, b) => b[1] - a[1]).map(([e, n]) => `- .${e}: ${n} chunks`),
1248
- ];
1249
- if (s.chunks === 0) lines.push('', `⚠️ Index empty. Run \`npm run mcp:index\`.`);
1250
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1251
- } catch (err) {
1252
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
1253
- }
1254
- }
1255
- );
1256
-
1257
- // ─── tests_for ────────────────────────────────────────────────────────────────
1258
- server.tool(
1259
- 'tests_for',
1260
- 'Find the tests that exercise a symbol β€” the test/spec chunks that call or reference it. '
1261
- + 'Use it to see how a function/class is meant to behave, or which tests to run (or update) '
1262
- + 'before changing it. Returns each test chunk + id (call get_chunk for the body).',
1263
- {
1264
- symbol: z.string().describe("Exact symbol name (function, class, method) to find tests for."),
1265
- response_format: z.enum(['markdown', 'json']).default('markdown').describe(
1266
- "'markdown' (default) or 'json' (typed { tests: [...] })."
1267
- ),
1268
- },
1269
- async ({ symbol, response_format }) => {
1270
- try {
1271
- const tests = testsForSymbol(symbol);
1272
- const fresh = indexFreshness();
1273
- const note = freshnessNote(fresh);
1274
-
1275
- if (response_format === 'json') {
1276
- return jsonResult({ symbol, test_count: tests.length, tests: tests.map(chunkCard), index: fresh });
1277
- }
1278
- if (tests.length === 0) {
1279
- const known = db.resolveSymbol(symbol).length > 0;
1280
- const parts = [known
1281
- ? `πŸ§ͺ No tests found that reference \`${symbol}\`. It may be untested, or its tests are dynamic/indirect (the test calls a wrapper, not the symbol directly).`
1282
- : `Symbol \`${symbol}\` is not in the index. Try search_code(query="${symbol}").`];
1283
- if (note) parts.push(note);
1284
- return { content: [{ type: 'text', text: parts.join('\n') }] };
1285
- }
1286
- const lines = [`# πŸ§ͺ Tests for \`${symbol}\` β€” ${tests.length}`];
1287
- for (const t of tests) {
1288
- lines.push(`- \`${t.class_context ? t.class_context + '.' : ''}${t.name}\` [${t.node_type}]`
1289
- + ` in \`${t.file_path}\` (lines ${t.start_line}–${t.end_line}) Β· id \`${t.id}\``);
1290
- }
1291
- if (note) lines.push('', note);
1292
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1293
- } catch (err) {
1294
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
1295
- }
1296
- }
1297
- );
1298
-
1299
- // ─── explain_symbol ─────────────────────────────────────────────────────────────
1300
- server.tool(
1301
- 'explain_symbol',
1302
- 'One-call overview of a symbol before you edit it: its signature(s), what it calls (callees), '
1303
- + 'who calls it (callers / blast radius), subclasses and type users, the HTTP routes it handles, '
1304
- + 'the tests that exercise it, and git recency/co-change. Composes resolve_symbol + '
1305
- + 'find_references + find_routes + tests_for + git signals so an agent gets the full context in '
1306
- + 'a single round-trip instead of four.',
1307
- {
1308
- symbol: z.string().describe("Exact symbol name (function, class, interface, method)."),
1309
- target_class: z.string().optional().describe(
1310
- "Optional owning class/type to scope the caller/reference dimensions when several symbols share the name."
1311
- ),
1312
- response_format: z.enum(['markdown', 'json']).default('markdown').describe(
1313
- "'markdown' (default) or 'json' (typed { definitions, callees, called_by, subclassed_by, used_as_type_by, routes, tests, recent_changes, co_changes })."
1314
- ),
1315
- },
1316
- async ({ symbol, target_class, response_format }) => {
1317
- try {
1318
- const defs = db.resolveSymbol(symbol);
1319
- if (defs.length === 0) {
1320
- const indexEmpty = db.chunkCount() === 0;
1321
- const emptyHint = 'Index is empty β€” run `npm run mcp:index` (or `idx-index --repo <path>`) to build it.';
1322
- return response_format === 'json'
1323
- ? jsonResult({ symbol, found: false, ...(indexEmpty ? { index_status: 'empty', hint: emptyHint } : {}), definitions: [] })
1324
- : { content: [{ type: 'text', text: indexEmpty ? `⚠️ ${emptyHint}` : `Symbol \`${symbol}\` not in index. Try search_code(query="${symbol}").` }] };
1325
- }
1326
-
1327
- const { targetDefs, ambiguous, calls, inherits, types, references } =
1328
- findReferences(db, symbol, { targetClass: target_class || null });
1329
- const scipRefs = references || [];
1330
- const callees = [...new Set(defs.flatMap(d => d.calls || []))].sort();
1331
- const tests = testsForSymbol(symbol);
1332
- const defIds = new Set(defs.map(d => d.id));
1333
- const routes = findRoutes(db, {}).filter(r => r.handler_name === symbol || (r.id && defIds.has(r.id)));
1334
- const files = [...new Set(targetDefs.map(d => d.file_path))];
1335
- const coChanges = coChangeFiles(gitSignals, files);
1336
- const recentChanges = files
1337
- .map(f => ({ file: f, hotness: Number(gitBoostScore(gitSignals, f).toFixed(3)) }))
1338
- .filter(r => r.hotness > 0)
1339
- .sort((a, b) => b.hotness - a.hotness || a.file.localeCompare(b.file));
1340
- const fresh = indexFreshness();
1341
- const note = freshnessNote(fresh);
1342
-
1343
- if (response_format === 'json') {
1344
- return jsonResult({
1345
- symbol,
1346
- target_class: target_class || null,
1347
- ambiguous,
1348
- definition_count: defs.length,
1349
- definitions: defs.map(d => {
1350
- const cen = db.getCentrality?.(d.id);
1351
- return { ...chunkCard(d), signature: extractSignatureLine(d.code_snippet), ...(cen ? { centrality: cen } : {}) };
1352
- }),
1353
- callees,
1354
- called_by: {
1355
- high: calls.high.map(h => refCard({ ...h, confidence: 'high' })),
1356
- name_only: calls.nameOnly.map(n => refCard({ ...n, confidence: 'name-only' })),
1357
- },
1358
- subclassed_by: inherits.map(refCard),
1359
- used_as_type_by: types.map(refCard),
1360
- referenced_by_resolved: scipRefs.map(refCard),
1361
- routes,
1362
- tests: tests.map(chunkCard),
1363
- recent_changes: recentChanges,
1364
- co_changes: coChanges,
1365
- index: fresh,
1366
- });
1367
- }
1368
-
1369
- const callerTotal = calls.high.length + calls.nameOnly.length;
1370
- const lines = [`# πŸ”Ž \`${target_class ? target_class + '.' : ''}${symbol}\``];
1371
- if (ambiguous) lines.push(`> ⚠️ ${defs.length} symbols named \`${symbol}\`${target_class ? '' : ' β€” pass target_class to scope callers/references'}.`);
1372
- for (const d of defs) {
1373
- const cen = db.getCentrality?.(d.id);
1374
- const cenTag = cen ? ` · 🎯 centrality #${cen.rank}/${cen.total}${cen.rank <= Math.ceil(cen.total * 0.1) ? ' (hub)' : ''}` : '';
1375
- lines.push('', `**${d.class_context ? d.class_context + '.' : ''}${d.name}** [${d.node_type}] Β· \`${d.file_path}\`:${d.start_line}–${d.end_line} Β· id \`${d.id}\`${cenTag}`);
1376
- lines.push('```', extractSignatureLine(d.code_snippet), '```');
1377
- }
1378
- if (callees.length) lines.push('', `**Calls (${callees.length}):** ${callees.slice(0, 20).join(', ')}${callees.length > 20 ? ' …' : ''}`);
1379
- lines.push('', `**Called by (${callerTotal}):** ${calls.high.length} high-confidence` + (calls.nameOnly.length ? `, ${calls.nameOnly.length} name-only` : ''));
1380
- for (const h of calls.high.slice(0, 10)) lines.push(` - βœ… \`${h.chunk.class_context ? h.chunk.class_context + '.' : ''}${h.chunk.name}\` in \`${h.chunk.file_path}\`${h.recvHint ? ` Β· via ${h.recvHint}` : ''}`);
1381
- if (inherits.length) lines.push('', `**Subclassed/implemented by (${inherits.length}):** ` + inherits.slice(0, 10).map(r => `\`${r.chunk.name}\``).join(', '));
1382
- if (types.length) lines.push('', `**Used as a type by (${types.length}):** ` + types.slice(0, 10).map(r => `\`${r.chunk.name}\``).join(', '));
1383
- if (scipRefs.length) lines.push('', `**🎯 SCIP-resolved references (${scipRefs.length}):** ` + scipRefs.slice(0, 10).map(r => `\`${r.chunk.name}\``).join(', '));
1384
- if (routes.length) {
1385
- lines.push('', `**Routes (${routes.length}):**`);
1386
- for (const r of routes) lines.push(` - **${r.method}** \`${r.path}\``);
1387
- }
1388
- lines.push('', tests.length ? `**Tests (${tests.length}):**` : `**Tests:** none found`);
1389
- for (const t of tests.slice(0, 10)) lines.push(` - πŸ§ͺ \`${t.name}\` in \`${t.file_path}\` Β· id \`${t.id}\``);
1390
- if (recentChanges.length) lines.push('', `**Recent activity:** ` + recentChanges.map(r => `\`${r.file}\` (hotness ${r.hotness})`).join(', '));
1391
- if (coChanges.length) lines.push('', coChangeLine(coChanges));
1392
- if (note) lines.push('', note);
1393
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1394
- } catch (err) {
1395
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
1396
- }
1397
- }
1398
- );
1399
-
1400
- // ─── impact_of_edit ─────────────────────────────────────────────────────────────
1401
- server.tool(
1402
- 'impact_of_edit',
1403
- 'The precise BLAST RADIUS of a change before you make it: pass the symbols and/or files '
1404
- + 'you are about to edit and get the transitively-affected code (callers, subclasses, type '
1405
- + 'users), the HTTP routes that reach them, the tests to run, and the files that '
1406
- + 'historically change together. Follows high-confidence edges transitively (precise, no '
1407
- + 'name collisions); direct ambiguous referrers are listed separately to verify. Most '
1408
- + 'precise on an index built with --symbol-graph; otherwise resolves at query time.',
1409
- {
1410
- symbols: z.array(z.string()).optional().describe('Symbol names about to change (functions, classes, methods).'),
1411
- files: z.array(z.string()).optional().describe('Repo-relative file paths about to change (every defined symbol in them seeds the impact).'),
1412
- max_depth: z.number().int().min(1).max(6).default(3).describe('Transitive caller hops to follow (default 3).'),
1413
- max_nodes: z.number().int().min(1).max(1000).default(200).describe('Cap on affected chunks returned.'),
1414
- precision: z.enum(['standard', 'strict']).default('standard').describe(
1415
- "'standard' (default): follow the high-confidence closure (resolved + import/proximity). "
1416
- + "'strict': follow ONLY provably-unambiguous bindings (sole definition or type-pinned receiver) "
1417
- + 'for a false-positive-free blast radius β€” most useful on a --symbol-graph --resolver precise index.'
1418
- ),
1419
- response_format: z.enum(['markdown', 'json']).default('markdown').describe(
1420
- "'markdown' (default) or 'json' (typed { changed, impacted, ambiguous, routes, tests, co_changes })."
1421
- ),
1422
- },
1423
- async ({ symbols = [], files = [], max_depth, max_nodes, precision = 'standard', response_format }) => {
1424
- try {
1425
- // Seed: every definition of each symbol + every chunk in each named file.
1426
- const seedMap = new Map();
1427
- for (const s of symbols) for (const c of db.resolveSymbol(s)) seedMap.set(c.id, c);
1428
- for (const f of files) for (const c of db.getChunksByFile(f)) seedMap.set(c.id, c);
1429
- const seed = [...seedMap.values()];
1430
-
1431
- if (seed.length === 0) {
1432
- const msg = (symbols.length || files.length)
1433
- ? `No indexed symbols matched ${JSON.stringify([...symbols, ...files])}. Check the names/paths, or run search_code.`
1434
- : 'Pass `symbols` and/or `files` you are about to change.';
1435
- return response_format === 'json'
1436
- ? jsonResult({ changed: [], impacted: [], ambiguous: [], routes: [], tests: [], co_changes: [], note: msg })
1437
- : { content: [{ type: 'text', text: msg }] };
1438
- }
1439
-
1440
- const { impacted, ambiguous, truncated, usedGraph } = buildImpact(db, seed, { maxDepth: max_depth, maxNodes: max_nodes, precision });
1441
-
1442
- // Affected = seed βˆͺ impacted. Split tests out from production code.
1443
- const affected = [...seed.map(c => ({ chunk: c, depth: 0, kind: 'seed' })), ...impacted];
1444
- const affectedIds = new Set(affected.map(a => a.chunk.id));
1445
- const isTest = (c) => TEST_FILE_RE.test(c.file_path);
1446
- const impactedCode = impacted.filter(a => !isTest(a.chunk));
1447
- const tests = affected.filter(a => isTest(a.chunk)).map(a => a.chunk);
1448
-
1449
- // Routes whose handler chunk is in the affected set.
1450
- const routes = findRoutes(db, {}).filter(r => r.id && affectedIds.has(r.id));
1451
- const seedFiles = [...new Set(seed.map(c => c.file_path))];
1452
- const coChanges = coChangeFiles(gitSignals, seedFiles);
1453
- const fresh = indexFreshness();
1454
- const note = freshnessNote(fresh);
1455
-
1456
- if (response_format === 'json') {
1457
- return jsonResult({
1458
- seed_symbols: symbols, seed_files: files,
1459
- resolution: usedGraph ? 'symbol-graph' : 'query-time',
1460
- precision,
1461
- changed: seed.map(chunkCard),
1462
- impacted: impactedCode.map(a => ({ ...chunkCard(a.chunk), depth: a.depth, via: a.kind })),
1463
- ambiguous: ambiguous.map(a => ({ ...chunkCard(a.chunk), via: a.kind })),
1464
- routes,
1465
- tests: tests.map(chunkCard),
1466
- co_changes: coChanges,
1467
- truncated,
1468
- index: fresh,
1469
- });
1470
- }
1471
-
1472
- const ref = (c) => `\`${c.class_context ? c.class_context + '.' : ''}${c.name}\` in \`${c.file_path}\`:${c.start_line}`;
1473
- const lines = [`# πŸ’₯ Impact of editing ${seed.map(c => `\`${c.name}\``).slice(0, 8).join(', ')}`];
1474
- lines.push(`> resolution: ${usedGraph ? 'symbol-graph (precise)' : 'query-time'} · precision: ${precision}${truncated ? ` · ⚠️ truncated at ${max_nodes} nodes` : ''}`);
1475
- lines.push('', `**Changed (${seed.length}):** ${seed.map(c => `\`${c.name}\``).join(', ')}`);
1476
- lines.push('', `**Impacted code (${impactedCode.length})** β€” transitive high-confidence callers/users:`);
1477
- for (const a of impactedCode.slice(0, 40)) lines.push(` - [d${a.depth}Β·${a.kind}] ${ref(a.chunk)}`);
1478
- if (impactedCode.length > 40) lines.push(` …and ${impactedCode.length - 40} more`);
1479
- lines.push('', tests.length ? `**Tests to run (${tests.length}):**` : `**Tests to run:** none found`);
1480
- for (const t of tests.slice(0, 20)) lines.push(` - πŸ§ͺ ${ref(t)} Β· id \`${t.id}\``);
1481
- if (routes.length) {
1482
- lines.push('', `**Affected routes (${routes.length}):**`);
1483
- for (const r of routes) lines.push(` - **${r.method}** \`${r.path}\` β†’ \`${r.handler_name}\``);
1484
- }
1485
- if (ambiguous.length) {
1486
- lines.push('', `**Ambiguous (${ambiguous.length}) β€” verify (same-named symbol, not expanded):**`);
1487
- for (const a of ambiguous.slice(0, 15)) lines.push(` - ❔ ${ref(a.chunk)}`);
1488
- }
1489
- if (coChanges.length) lines.push('', coChangeLine(coChanges));
1490
- if (!usedGraph) lines.push('', '> ℹ️ Build the index with `--symbol-graph` for a precomputed, chunk-precise blast radius.');
1491
- if (note) lines.push('', note);
1492
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1493
- } catch (err) {
1494
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
1495
- }
1496
- }
1497
- );
1498
-
1499
- // ─── trace_taint ─────────────────────────────────────────────────────────────────
1500
- server.tool(
1501
- 'trace_taint',
1502
- 'Security: trace UNTRUSTED data from a source (request body/query/params, argv/env, stdin, '
1503
- + 'socket/file reads) to a dangerous SINK (eval/exec, SQL, fs/path, HTML, outbound request) '
1504
- + 'across the call graph β€” surfacing injection-class risks (rce/sqli/xss/path/ssrf) with the '
1505
- + 'concrete source→sink path. Same-function flows are high-confidence; cross-function '
1506
- + 'reachability is medium; a sanitizer on the path lowers it. FINDER, not a verifier β€” it '
1507
- + 'misses dynamic dispatch / reflection / ORM indirection, so "no flows" β‰  "safe". '
1508
- + 'JS/TS + Python. Most precise on a `--symbol-graph` index.',
1509
- {
1510
- source_kind: z.string().optional().describe("Restrict to one source kind (e.g. 'http-request', 'process-input', 'stdin')."),
1511
- category: z.enum(['rce', 'sqli', 'xss', 'path', 'ssrf']).optional().describe('Restrict to one sink category.'),
1512
- max_depth: z.number().int().min(1).max(8).default(4).describe('Cross-function call hops to follow from a source (default 4).'),
1513
- max_flows: z.number().int().min(1).max(500).default(100).describe('Cap on flows returned (default 100).'),
1514
- response_format: z.enum(['markdown', 'json']).default('markdown').describe("'markdown' (default) or 'json' (typed { flows: [{ source, sink, path, via, confidence, sanitized }] })."),
1515
- },
1516
- async ({ source_kind, category, max_depth, max_flows, response_format }) => {
1517
- try {
1518
- const { flows, truncated, scanned } = traceTaint(db, {
1519
- sourceKind: source_kind || null, sinkCategory: category || null, maxDepth: max_depth, maxFlows: max_flows,
1520
- });
1521
- if (response_format === 'json') {
1522
- return jsonResult({ source_kind: source_kind || null, category: category || null, scanned, flow_count: flows.length, truncated, flows });
1523
- }
1524
- const lines = [`# πŸ§ͺ Taint flows β€” ${flows.length}${truncated ? '+' : ''} found (scanned ${scanned} chunks)`, ''];
1525
- if (!flows.length) lines.push('No sourceβ†’sink flows matched. ⚠️ This is a finder, not a verifier β€” absence of a flow is not proof of safety.');
1526
- for (const f of flows.slice(0, 40)) {
1527
- lines.push(`- **${f.sink.category.toUpperCase()}** [${f.confidence}${f.sanitized ? ' Β· sanitizer on path' : ''}] β€” \`${f.source.kind}\` β†’ \`${f.sink.label}\``);
1528
- lines.push(` - source \`${f.source.name}\` in \`${f.source.file_path}\`:${f.source.line}`);
1529
- lines.push(` - sink \`${f.sink.name}\` in \`${f.sink.file_path}\`:${f.sink.line} (${f.via}, depth ${f.depth})`);
1530
- }
1531
- if (flows.length > 40) lines.push(`\n…and ${flows.length - 40} more.`);
1532
- lines.push('', '> Heuristic finder (JS/TS + Python). Triage each flow against its path; it can over- and under-report.');
1533
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1534
- } catch (err) {
1535
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
1536
- }
1537
- }
1538
- );
1539
-
1540
- // ─── find_tainted_sinks ──────────────────────────────────────────────────────────
1541
- server.tool(
1542
- 'find_tainted_sinks',
1543
- 'Security orientation: list every dangerous SINK in the codebase (eval/exec, SQL, fs/path, '
1544
- + 'HTML, outbound requests) grouped by category, each flagged with whether an untrusted '
1545
- + 'SOURCE reaches it. Use it FIRST to map the attack surface, then trace_taint a specific '
1546
- + 'one. JS/TS + Python; same heuristic-finder caveats as trace_taint.',
1547
- {
1548
- category: z.enum(['rce', 'sqli', 'xss', 'path', 'ssrf']).optional().describe('Restrict to one sink category.'),
1549
- reachable_only: z.boolean().default(false).describe('Only sinks reachable from an untrusted source.'),
1550
- max_depth: z.number().int().min(1).max(8).default(4).describe('Reachability hops to consider (default 4).'),
1551
- response_format: z.enum(['markdown', 'json']).default('markdown').describe("'markdown' (default) or 'json' (typed { sinks_by_category })."),
1552
- },
1553
- async ({ category, reachable_only, max_depth, response_format }) => {
1554
- try {
1555
- const { byCategory, scanned, flowCount } = findTaintedSinks(db, {
1556
- category: category || null, reachableOnly: reachable_only, maxDepth: max_depth,
1557
- });
1558
- const total = Object.values(byCategory).reduce((s, a) => s + a.length, 0);
1559
- if (response_format === 'json') {
1560
- return jsonResult({ category: category || null, reachable_only, scanned, sink_count: total, reachable_flow_count: flowCount, sinks_by_category: byCategory });
1561
- }
1562
- const lines = [`# 🎯 Dangerous sinks β€” ${total} (scanned ${scanned} chunks)`, ''];
1563
- if (!total) lines.push('No sinks matched these filters.');
1564
- for (const cat of CATEGORY_SEVERITY) {
1565
- const list = byCategory[cat];
1566
- if (!list || !list.length) continue;
1567
- const reached = list.filter(s => s.reached_by_source).length;
1568
- lines.push(`## ${cat.toUpperCase()} β€” ${list.length}${reached ? ` (${reached} reachable from untrusted input ⚠️)` : ''}`);
1569
- for (const s of list.slice(0, 25)) {
1570
- lines.push(` - ${s.reached_by_source ? '⚠️ ' : ''}\`${s.label}\` in \`${s.file_path}\`:${s.line} β€” \`${s.name}\``);
1571
- }
1572
- if (list.length > 25) lines.push(` …and ${list.length - 25} more`);
1573
- lines.push('');
1574
- }
1575
- lines.push('> Heuristic finder (JS/TS + Python). A flagged sink is a place to look, not a confirmed bug.');
1576
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1577
- } catch (err) {
1578
- return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
1579
- }
1580
- }
1581
- );
1582
- }