@esankhan3/code-search-mcp 1.1.2 → 1.1.4
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.
|
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { createSystemGraphSqliteWriter, getGraphStore, invalidateRetriever } from "@esankhan3/anvil-knowledge-core";
|
|
7
7
|
import { handleGraphTool } from "../tools/graph.js";
|
|
8
8
|
const MAX_NODES = 5e4;
|
|
9
|
+
const FUZZY_CAP = 200;
|
|
9
10
|
let tmp = "";
|
|
10
11
|
let stashDataDir;
|
|
11
12
|
function ctx(projectName) {
|
|
@@ -260,3 +261,43 @@ describe("traversal budget on a high-fan-out graph", () => {
|
|
|
260
261
|
}
|
|
261
262
|
});
|
|
262
263
|
});
|
|
264
|
+
describe("fuzzy resolution", () => {
|
|
265
|
+
before(async () => {
|
|
266
|
+
const names = [...Array.from({ length: 250 }, (_, i) => `handleThing${i}`), "ab"];
|
|
267
|
+
await writeGraph("fuzzy", [...names, "sink"].map((name) => ({ name })), names.map((s) => ({ s, t: "sink", type: "calls", confidence: 0.9 })));
|
|
268
|
+
});
|
|
269
|
+
it("a name matching more than FUZZY_CAP symbols starts from the first FUZZY_CAP and says so", async () => {
|
|
270
|
+
const store = await getGraphStore("fuzzy");
|
|
271
|
+
const firstBatch = [];
|
|
272
|
+
const orig = store.neighborEdges;
|
|
273
|
+
store.neighborEdges = (keys, ...rest) => {
|
|
274
|
+
firstBatch.push(keys.length);
|
|
275
|
+
return orig.call(store, keys, ...rest);
|
|
276
|
+
};
|
|
277
|
+
try {
|
|
278
|
+
const t = await call("fuzzy", "trace_path", { from: "thing", fuzzy: true });
|
|
279
|
+
assert.match(t, /^# Reachable from "thing"/);
|
|
280
|
+
assert.match(t, new RegExp(`"thing" matched more than ${FUZZY_CAP} symbols \u2014 be more specific`));
|
|
281
|
+
assert.equal(firstBatch[0], FUZZY_CAP);
|
|
282
|
+
} finally {
|
|
283
|
+
delete store.neighborEdges;
|
|
284
|
+
}
|
|
285
|
+
const deps = await call("fuzzy", "find_dependencies", { function: "Thing", fuzzy: true });
|
|
286
|
+
assert.match(deps, /sink/);
|
|
287
|
+
assert.match(deps, new RegExp(`matched more than ${FUZZY_CAP} symbols`));
|
|
288
|
+
});
|
|
289
|
+
it("no note under the cap", async () => {
|
|
290
|
+
const t = await call("fuzzy", "trace_path", { from: "Thing1", to: "sink", fuzzy: true });
|
|
291
|
+
assert.match(t, /^# Path \(1 hops\)/);
|
|
292
|
+
assert.doesNotMatch(t, /matched more than/);
|
|
293
|
+
});
|
|
294
|
+
it("a term under 3 characters is matched exactly, and the answer says so", async () => {
|
|
295
|
+
const t = await call("fuzzy", "trace_path", { from: "ab", to: "sink", fuzzy: true });
|
|
296
|
+
assert.match(t, /^# Path \(1 hops\)\n\nab → sink/);
|
|
297
|
+
assert.match(t, /needs at least 3 characters — "ab" was matched exactly/);
|
|
298
|
+
const none = await call("fuzzy", "find_callers", { function: "AB", fuzzy: true });
|
|
299
|
+
assert.match(none, /^No entity found matching "AB"/);
|
|
300
|
+
assert.match(none, /needs at least 3 characters/);
|
|
301
|
+
assert.doesNotMatch(none, /Try fuzzy:true/);
|
|
302
|
+
});
|
|
303
|
+
});
|
package/dist/tools/graph.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
loadProfile,
|
|
11
11
|
getAllChanges,
|
|
12
12
|
getChangedFilesList,
|
|
13
|
+
FUZZY_MIN_CHARS,
|
|
13
14
|
SYMMETRIC_EDGE_TYPES
|
|
14
15
|
} from "@esankhan3/anvil-knowledge-core";
|
|
15
16
|
import { resolvedKnowledgeConfig } from "../core/env-config.js";
|
|
@@ -105,6 +106,17 @@ function reachable(store, fromKeys, maxDepth, direction, types, budget, cap = 50
|
|
|
105
106
|
}
|
|
106
107
|
return out;
|
|
107
108
|
}
|
|
109
|
+
const FUZZY_MATCH_CAP = 200;
|
|
110
|
+
function resolveKeys(store, name, repo, fuzzy) {
|
|
111
|
+
if (!fuzzy) return { keys: store.resolveNodes(name, repo).map((m) => m.key), note: "" };
|
|
112
|
+
const keys = store.resolveNodes(name, repo, true, FUZZY_MATCH_CAP + 1).map((m) => m.key);
|
|
113
|
+
const notes = [];
|
|
114
|
+
if ([...name].length < FUZZY_MIN_CHARS) notes.push(`_Fuzzy matching needs at least ${FUZZY_MIN_CHARS} characters \u2014 "${name}" was matched exactly._`);
|
|
115
|
+
if (keys.length > FUZZY_MATCH_CAP) notes.push(`_"${name}" matched more than ${FUZZY_MATCH_CAP} symbols \u2014 be more specific; using the first ${FUZZY_MATCH_CAP}._`);
|
|
116
|
+
return { keys: keys.slice(0, FUZZY_MATCH_CAP), note: notes.map((n) => `
|
|
117
|
+
|
|
118
|
+
${n}`).join("") };
|
|
119
|
+
}
|
|
108
120
|
function endpointsIn(store, keys) {
|
|
109
121
|
const types = store.nodeTypes(keys);
|
|
110
122
|
return keys.filter((k) => types.get(k) === "external");
|
|
@@ -154,8 +166,8 @@ async function indexedSources(project, repo, files) {
|
|
|
154
166
|
} catch {
|
|
155
167
|
}
|
|
156
168
|
const missing = new Set(files.filter((f) => !parts.has(f)));
|
|
157
|
-
if (missing.size > 0
|
|
158
|
-
const found = await findChunksInProject(project, (c) => c.repoName === repo && missing.has(c.filePath), Number.POSITIVE_INFINITY, cfg);
|
|
169
|
+
if (missing.size > 0) {
|
|
170
|
+
const found = await findChunksInProject(project, (c) => c.repoName === repo && missing.has(c.filePath), Number.POSITIVE_INFINITY, cfg, repo);
|
|
159
171
|
for (const c of found) {
|
|
160
172
|
if (!parts.has(c.filePath)) parts.set(c.filePath, []);
|
|
161
173
|
parts.get(c.filePath).push(c.content);
|
|
@@ -254,7 +266,7 @@ function registerGraphTools() {
|
|
|
254
266
|
properties: {
|
|
255
267
|
function: { type: "string", minLength: 1, description: "Symbol name or qualified id (repo::file::entity)" },
|
|
256
268
|
repo: { type: "string", minLength: 1, description: "Limit resolution to this repo (optional)" },
|
|
257
|
-
fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false)" },
|
|
269
|
+
fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false). Needs 3+ characters (a shorter term matches exactly); uses the first 200 matches." },
|
|
258
270
|
relations: relationsProp("callers")
|
|
259
271
|
},
|
|
260
272
|
required: ["function"]
|
|
@@ -268,7 +280,7 @@ function registerGraphTools() {
|
|
|
268
280
|
properties: {
|
|
269
281
|
function: { type: "string", minLength: 1, description: "Symbol name or qualified id (repo::file::entity)" },
|
|
270
282
|
repo: { type: "string", minLength: 1, description: "Limit resolution to this repo (optional)" },
|
|
271
|
-
fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false)" },
|
|
283
|
+
fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false). Needs 3+ characters (a shorter term matches exactly); uses the first 200 matches." },
|
|
272
284
|
relations: relationsProp("dependencies")
|
|
273
285
|
},
|
|
274
286
|
required: ["function"]
|
|
@@ -299,7 +311,7 @@ function registerGraphTools() {
|
|
|
299
311
|
repo: { type: "string", minLength: 1, description: "Limit resolution to this repo (optional)" },
|
|
300
312
|
direction: { type: "string", enum: ["callees", "callers", "both"], description: "Traverse callees (what it calls), callers (who calls it), or both. Default callees." },
|
|
301
313
|
maxDepth: { type: "integer", minimum: 1, maximum: 10, description: "Max hops to traverse (default 4, max 10)" },
|
|
302
|
-
fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false)" },
|
|
314
|
+
fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false). Needs 3+ characters (a shorter term matches exactly); uses the first 200 matches." },
|
|
303
315
|
relations: relationsProp("the traversal")
|
|
304
316
|
},
|
|
305
317
|
required: ["from"]
|
|
@@ -427,11 +439,10 @@ async function handleGraphTool(name, args, ctx) {
|
|
|
427
439
|
const repoFilter = args.repo;
|
|
428
440
|
const fuzzy = args.fuzzy === true;
|
|
429
441
|
const types = edgeTypes(args);
|
|
430
|
-
const
|
|
431
|
-
if (
|
|
432
|
-
return text(`No entity found matching "${funcName}"${repoFilter ? ` in ${repoFilter}` : ""}.${fuzzy ? "" : " Try fuzzy:true for substring matching."}`);
|
|
442
|
+
const { keys, note } = resolveKeys(store, funcName, repoFilter, fuzzy);
|
|
443
|
+
if (keys.length === 0) {
|
|
444
|
+
return text(`No entity found matching "${funcName}"${repoFilter ? ` in ${repoFilter}` : ""}.${fuzzy ? "" : " Try fuzzy:true for substring matching."}${note}`);
|
|
433
445
|
}
|
|
434
|
-
const keys = matched.map((m) => m.key);
|
|
435
446
|
const fetch = (t) => name === "find_callers" ? store.callers(keys, 30, t) : store.dependencies(keys, 30, t);
|
|
436
447
|
const found = fetch(types);
|
|
437
448
|
const noun = name === "find_callers" ? "callers" : "dependencies";
|
|
@@ -445,7 +456,7 @@ async function handleGraphTool(name, args, ctx) {
|
|
|
445
456
|
Other relations: ${[...other].map(([t, n]) => `${t} ${n}`).join(", ")} \u2014 pass relations: ${JSON.stringify([...other.keys()])} to list them.` : "";
|
|
446
457
|
return text(`${title}
|
|
447
458
|
|
|
448
|
-
Matched ${
|
|
459
|
+
Matched ${keys.length} entit${keys.length === 1 ? "y" : "ies"}, but no ${noun} via ${relLabel}.${hint}${note}`);
|
|
449
460
|
}
|
|
450
461
|
const byType = /* @__PURE__ */ new Map();
|
|
451
462
|
for (const f of found) {
|
|
@@ -458,7 +469,7 @@ ${ks.map((k) => `- \`${k}\``).join("\n")}`).join("\n\n");
|
|
|
458
469
|
|
|
459
470
|
${found.length} found (${relLabel}):
|
|
460
471
|
|
|
461
|
-
${sections}`);
|
|
472
|
+
${sections}${note}`);
|
|
462
473
|
}
|
|
463
474
|
if (name === "impact_analysis") {
|
|
464
475
|
const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
|
|
@@ -513,18 +524,19 @@ ${sections}`);
|
|
|
513
524
|
const fuzzy = args.fuzzy === true;
|
|
514
525
|
const direction = args.direction ?? "callees";
|
|
515
526
|
const maxDepth = args.maxDepth ?? 4;
|
|
516
|
-
const fromKeys = store
|
|
517
|
-
if (fromKeys.length === 0) return text(`No entity found matching "${from}".${fuzzy ? "" : " Try fuzzy:true."}`);
|
|
527
|
+
const { keys: fromKeys, note: fromNote } = resolveKeys(store, from, repo, fuzzy);
|
|
528
|
+
if (fromKeys.length === 0) return text(`No entity found matching "${from}".${fuzzy ? "" : " Try fuzzy:true."}${fromNote}`);
|
|
518
529
|
const types = edgeTypes(args);
|
|
519
530
|
const budget = new TraversalBudget();
|
|
520
531
|
if (to) {
|
|
521
|
-
const toKeys = store
|
|
522
|
-
if (toKeys.length === 0) return text(`No entity found matching target "${to}".${fuzzy ? "" : " Try fuzzy:true."}`);
|
|
532
|
+
const { keys: toKeys, note: toNote } = resolveKeys(store, to, repo, fuzzy);
|
|
533
|
+
if (toKeys.length === 0) return text(`No entity found matching target "${to}".${fuzzy ? "" : " Try fuzzy:true."}${toNote}`);
|
|
534
|
+
const notes = fromNote + toNote;
|
|
523
535
|
const { path, depth } = shortestPath(store, fromKeys, toKeys, maxDepth, direction, types, budget);
|
|
524
536
|
if (!path && budget.exhausted) {
|
|
525
|
-
return text(`No path from "${from}" to "${to}" found within the traversal budget (explored ${budget.explored} nodes, depth ${depth} of ${maxDepth}, direction: ${direction}) \u2014 narrow the search (repo, exact names, relations) or lower maxDepth
|
|
537
|
+
return text(`No path from "${from}" to "${to}" found within the traversal budget (explored ${budget.explored} nodes, depth ${depth} of ${maxDepth}, direction: ${direction}) \u2014 narrow the search (repo, exact names, relations) or lower maxDepth.${notes}`);
|
|
526
538
|
}
|
|
527
|
-
if (!path) return text(`No path from "${from}" to "${to}" within ${maxDepth} hops (direction: ${direction})
|
|
539
|
+
if (!path) return text(`No path from "${from}" to "${to}" within ${maxDepth} hops (direction: ${direction}).${notes}`);
|
|
528
540
|
const labelOf2 = store.labelsOf(path);
|
|
529
541
|
const arrow = direction === "callers" ? " \u2190 " : " \u2192 ";
|
|
530
542
|
const rendered = path.map((k) => labelOf2.get(k) ?? k).join(arrow);
|
|
@@ -532,13 +544,13 @@ ${sections}`);
|
|
|
532
544
|
|
|
533
545
|
${rendered}
|
|
534
546
|
|
|
535
|
-
${path.map((k) => `- \`${k}\``).join("\n")}`);
|
|
547
|
+
${path.map((k) => `- \`${k}\``).join("\n")}${notes}`);
|
|
536
548
|
}
|
|
537
549
|
const nodes = reachable(store, fromKeys, maxDepth, direction, types, budget);
|
|
538
550
|
const stopped = budget.exhausted ? `
|
|
539
551
|
|
|
540
552
|
_Stopped early at the traversal budget (explored ${budget.explored} nodes) \u2014 narrow the search or lower maxDepth._` : "";
|
|
541
|
-
if (nodes.length === 0) return text(`No ${direction} reachable from "${from}" within ${maxDepth} hops.${stopped}`);
|
|
553
|
+
if (nodes.length === 0) return text(`No ${direction} reachable from "${from}" within ${maxDepth} hops.${stopped}${fromNote}`);
|
|
542
554
|
const labelOf = store.labelsOf(nodes.map((n) => n.key));
|
|
543
555
|
const byDepth = /* @__PURE__ */ new Map();
|
|
544
556
|
for (const { key, depth } of nodes) {
|
|
@@ -551,7 +563,7 @@ ${ks.map((k) => `- ${k}`).join("\n")}`).join("\n\n");
|
|
|
551
563
|
|
|
552
564
|
${nodes.length} nodes:
|
|
553
565
|
|
|
554
|
-
${sections}${stopped}`);
|
|
566
|
+
${sections}${stopped}${fromNote}`);
|
|
555
567
|
}
|
|
556
568
|
if (name === "find_dead_code") {
|
|
557
569
|
const repo = args.repo;
|
package/dist/tools/search.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getRetriever,
|
|
1
|
+
import { getRetriever, findChunksInProject } from "@esankhan3/anvil-knowledge-core";
|
|
2
2
|
import { resolvedKnowledgeConfig } from "../core/env-config.js";
|
|
3
3
|
import { metrics } from "../observability/metrics.js";
|
|
4
4
|
import { toolError, toolText, validateArgs } from "./validate.js";
|
|
@@ -139,15 +139,16 @@ async function handleGetCodeSnippet(args, ctx) {
|
|
|
139
139
|
matches = scored.slice(0, 5).map((sc) => sc.chunk);
|
|
140
140
|
} catch {
|
|
141
141
|
}
|
|
142
|
-
if (matches.length === 0
|
|
142
|
+
if (matches.length === 0) {
|
|
143
143
|
matches = await findChunksInProject(
|
|
144
144
|
ctx.projectName,
|
|
145
145
|
(c) => c.repoName === repo && c.filePath === file && (!entity || namesEntity(c, entity)),
|
|
146
146
|
5,
|
|
147
|
-
resolvedKnowledgeConfig()
|
|
147
|
+
resolvedKnowledgeConfig(),
|
|
148
|
+
repo
|
|
148
149
|
);
|
|
149
150
|
if (matches.length === 0 && entity) {
|
|
150
|
-
matches = await findChunksInProject(ctx.projectName, (c) => c.repoName === repo && namesEntity(c, entity), 5, resolvedKnowledgeConfig());
|
|
151
|
+
matches = await findChunksInProject(ctx.projectName, (c) => c.repoName === repo && namesEntity(c, entity), 5, resolvedKnowledgeConfig(), repo);
|
|
151
152
|
}
|
|
152
153
|
}
|
|
153
154
|
if (matches.length === 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esankhan3/code-search-mcp",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "MCP server for multi-repo code search \u2014 GitHub org or local path, zero config",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@esankhan3/anvil-agent-core": "^0.5.0",
|
|
27
|
-
"@esankhan3/anvil-knowledge-core": "^0.4.
|
|
27
|
+
"@esankhan3/anvil-knowledge-core": "^0.4.10",
|
|
28
28
|
"@modelcontextprotocol/node": "^2.0.0",
|
|
29
29
|
"@modelcontextprotocol/server": "^2.0.0"
|
|
30
30
|
},
|