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.
- package/CHANGELOG.md +260 -1
- package/README.md +232 -312
- package/SECURITY.md +37 -59
- package/bin/graph-indexer.mjs +251 -0
- package/integrations/claude-code/.claude-plugin/plugin.json +8 -0
- package/integrations/claude-code/.mcp.json +8 -0
- package/integrations/claude-code/agents/code-structure.md +22 -0
- package/integrations/claude-code/hooks/gi-hook.mjs +99 -0
- package/integrations/claude-code/hooks/hooks.json +16 -0
- package/integrations/opencode/graph-indexer.js +124 -0
- package/package.json +32 -109
- package/src/cli/helper.mjs +67 -0
- package/src/cli/hook.mjs +356 -0
- package/src/cli/init.mjs +255 -0
- package/src/cli/resident.mjs +175 -0
- package/src/index/discover.mjs +130 -0
- package/src/index/fingerprint.mjs +30 -0
- package/src/index/graph.mjs +61 -0
- package/src/index/indexer.mjs +476 -0
- package/src/index/modules.mjs +331 -0
- package/src/index/resolver.mjs +954 -0
- package/src/mcp/render.mjs +60 -0
- package/src/mcp/server.mjs +147 -0
- package/src/mcp/tools.mjs +973 -0
- package/src/parse/arity.mjs +152 -0
- package/src/parse/extract.mjs +1031 -0
- package/src/parse/lang/csharp.mjs +106 -0
- package/src/parse/lang/ecma.mjs +438 -0
- package/src/parse/lang/go.mjs +194 -0
- package/src/parse/lang/jvm.mjs +118 -0
- package/src/parse/lang/kotlin.mjs +97 -0
- package/src/parse/lang/misc.mjs +115 -0
- package/src/parse/lang/native.mjs +126 -0
- package/src/parse/lang/python.mjs +153 -0
- package/src/parse/lang/rust.mjs +157 -0
- package/src/parse/lang/scripting.mjs +219 -0
- package/src/parse/languages.mjs +48 -0
- package/src/parse/runtime.mjs +56 -0
- package/src/query/check.mjs +343 -0
- package/src/query/facts.mjs +323 -0
- package/src/query/intel.mjs +680 -0
- package/src/query/read.mjs +81 -0
- package/src/query/testcmd.mjs +119 -0
- package/src/query/textsearch.mjs +160 -0
- package/src/search/search.mjs +210 -0
- package/src/search/tokenize.mjs +188 -0
- package/src/store/db.mjs +169 -0
- package/src/util/paths.mjs +24 -0
- package/vendor/grammars/bash.wasm.gz +0 -0
- package/vendor/grammars/c.wasm.gz +0 -0
- package/vendor/grammars/c_sharp.wasm.gz +0 -0
- package/vendor/grammars/cpp.wasm.gz +0 -0
- package/vendor/grammars/css.wasm.gz +0 -0
- package/vendor/grammars/go.wasm.gz +0 -0
- package/vendor/grammars/java.wasm.gz +0 -0
- package/vendor/grammars/javascript.wasm.gz +0 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +148 -0
- package/vendor/grammars/php.wasm.gz +0 -0
- package/vendor/grammars/python.wasm.gz +0 -0
- package/vendor/grammars/ruby.wasm.gz +0 -0
- package/vendor/grammars/rust.wasm.gz +0 -0
- package/vendor/grammars/scala.wasm.gz +0 -0
- package/vendor/grammars/tsx.wasm.gz +0 -0
- package/vendor/grammars/typescript.wasm.gz +0 -0
- package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
- package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/cli-ui.mjs +0 -78
- package/colbert.mjs +0 -168
- package/config.mjs +0 -494
- package/daemon-ctl.mjs +0 -175
- package/daemon-lock.mjs +0 -69
- package/embedders/python/mlx_embed_server.py +0 -61
- package/embedders/python/requirements-mlx.txt +0 -5
- package/embedders/setup-mlx.mjs +0 -110
- package/embeddings.mjs +0 -571
- package/engine/binary.mjs +0 -437
- package/engine/memory.mjs +0 -1003
- package/engine/sqlite.mjs +0 -1136
- package/enrichment.mjs +0 -521
- package/git-signals.mjs +0 -248
- package/indexer.mjs +0 -546
- package/init.mjs +0 -1809
- package/layout.mjs +0 -123
- package/mcp/centrality.mjs +0 -117
- package/mcp/format.mjs +0 -61
- package/mcp/resolver.mjs +0 -116
- package/mcp/symbolgraph.mjs +0 -137
- package/mcp/taint.mjs +0 -225
- package/mcp/tools.mjs +0 -1582
- package/mcp/topology.mjs +0 -676
- package/mcp-server.mjs +0 -197
- package/parse/extractor.mjs +0 -628
- package/parse/imports.mjs +0 -365
- package/parse/interprocedural.mjs +0 -134
- package/parse/languages.mjs +0 -253
- package/parse/metadata.mjs +0 -701
- package/parse/routes.mjs +0 -327
- package/parse/scip.mjs +0 -391
- package/parse/taint-patterns.mjs +0 -119
- package/prompts/CORE.md +0 -91
- package/prompts/DOMAIN_TEMPLATE.md +0 -46
- package/prompts/INTEGRATION.md +0 -123
- package/prompts/frameworks/ANDROID.md +0 -17
- package/prompts/frameworks/ASPNET_CORE.md +0 -19
- package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
- package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
- package/prompts/frameworks/RAILS.md +0 -20
- package/prompts/frameworks/REACT.md +0 -20
- package/prompts/frameworks/SPRING_BOOT.md +0 -20
- package/prompts/languages/BASH.md +0 -22
- package/prompts/languages/C.md +0 -21
- package/prompts/languages/CSHARP.md +0 -24
- package/prompts/languages/CSS_SCSS.md +0 -22
- package/prompts/languages/GO.md +0 -24
- package/prompts/languages/JAVA.md +0 -23
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
- package/prompts/languages/KOTLIN.md +0 -24
- package/prompts/languages/PHP.md +0 -23
- package/prompts/languages/PYTHON.md +0 -23
- package/prompts/languages/RUBY.md +0 -23
- package/prompts/languages/RUST.md +0 -21
- package/prompts/languages/SWIFT.md +0 -21
- package/seal.mjs +0 -400
- package/search-core.mjs +0 -777
- package/search-sparse.mjs +0 -183
- package/storage.mjs +0 -73
- 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
|
-
}
|