@esankhan3/code-search-mcp 1.1.1 → 1.1.3

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
+ });
@@ -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 && await getBlobStore(project, cfg).exists("chunks.json")) {
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 matched = store.resolveNodes(funcName, repoFilter, fuzzy);
431
- if (matched.length === 0) {
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 ${matched.length} entit${matched.length === 1 ? "y" : "ies"}, but no ${noun} via ${relLabel}.${hint}`);
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.resolveNodes(from, repo, fuzzy).map((m) => m.key);
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.resolveNodes(to, repo, fuzzy).map((m) => m.key);
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;
@@ -1,4 +1,4 @@
1
- import { getRetriever, getBlobStore, findChunksInProject } from "@esankhan3/anvil-knowledge-core";
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 && await getBlobStore(ctx.projectName, resolvedKnowledgeConfig()).exists("chunks.json")) {
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.1",
3
+ "version": "1.1.3",
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.7",
27
+ "@esankhan3/anvil-knowledge-core": "^0.4.9",
28
28
  "@modelcontextprotocol/node": "^2.0.0",
29
29
  "@modelcontextprotocol/server": "^2.0.0"
30
30
  },