@esankhan3/code-search-mcp 0.2.0 → 0.4.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/README.md CHANGED
@@ -118,15 +118,21 @@ retrieval pipeline that powers the Anvil dashboard.
118
118
  | `search_code` | Hybrid retrieval — vector + BM25 + graph + rerank |
119
119
  | `search_semantic` | Vector-only (paraphrases, intent) |
120
120
  | `search_exact` | BM25-only (identifiers, error codes) |
121
+ | `get_code_snippet` | Fetch the source for one entity by qualified name (cheaper than reading the file) |
121
122
 
122
123
  ### Graph
123
124
  | Tool | What it does |
124
125
  |---|---|
125
126
  | `get_repo_graph` | Single-repo AST graph |
126
127
  | `get_cross_repo_edges` | Inter-repo edges (Kafka, HTTP, gRPC, shared types, …) |
127
- | `find_callers` | Who calls this function |
128
+ | `find_callers` | Who calls this function (exact by default; `fuzzy:true` for substring) |
128
129
  | `find_dependencies` | What this function calls |
129
130
  | `impact_analysis` | What breaks if this changes |
131
+ | `trace_path` | Multi-hop call chain — shortest path between two functions, or the reachable call tree |
132
+ | `search_graph` | Structural query — filter entities by name/type/file, ranked by connectivity |
133
+ | `find_dead_code` | Entities with no callers (heuristic) |
134
+ | `detect_changes` | Map a git diff to affected entities + their dependents |
135
+ | `get_architecture` | Project overview — repo roles, key flows, connections |
130
136
 
131
137
  ### Profiles
132
138
  | Tool | What it does |
@@ -0,0 +1,208 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, appendFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { execSync } from "node:child_process";
7
+ import { handleGraphTool } from "../tools/graph.js";
8
+ import { handleSearchTool } from "../tools/search.js";
9
+ const PROJECT = "demo";
10
+ let tmp = "";
11
+ let stashDataDir;
12
+ function ctx() {
13
+ return {
14
+ projectName: PROJECT,
15
+ directoryPath: null,
16
+ indexReady: true,
17
+ startedAt: 0,
18
+ indexing: {
19
+ status: "idle",
20
+ phase: null,
21
+ message: null,
22
+ percent: 0,
23
+ startedAt: null,
24
+ error: null,
25
+ lastSuccess: null,
26
+ lastDurationMs: 0,
27
+ history: []
28
+ }
29
+ };
30
+ }
31
+ const text = (r) => r?.content?.[0]?.text ?? "";
32
+ const SYSTEM_GRAPH = {
33
+ nodes: [
34
+ { key: "app::a.ts::handleRequest", attributes: { label: "handleRequest", type: "function", repo: "app", file: "a.ts" } },
35
+ { key: "app::a.ts::validateToken", attributes: { label: "validateToken", type: "function", repo: "app", file: "a.ts" } },
36
+ { key: "app::b.ts::dbQuery", attributes: { label: "dbQuery", type: "function", repo: "app", file: "b.ts" } },
37
+ { key: "app::a.ts::get", attributes: { label: "get", type: "function", repo: "app", file: "a.ts" } },
38
+ { key: "app::a.ts::getUser", attributes: { label: "getUser", type: "function", repo: "app", file: "a.ts" } },
39
+ { key: "app::a.ts::caller1", attributes: { label: "caller1", type: "function", repo: "app", file: "a.ts" } },
40
+ { key: "app::a.ts::caller2", attributes: { label: "caller2", type: "function", repo: "app", file: "a.ts" } }
41
+ ],
42
+ edges: [
43
+ { source: "app::a.ts::handleRequest", target: "app::a.ts::validateToken", attributes: { type: "calls", confidence: 0.9 } },
44
+ { source: "app::a.ts::validateToken", target: "app::b.ts::dbQuery", attributes: { type: "calls", confidence: 0.9 } },
45
+ { source: "app::a.ts::caller1", target: "app::a.ts::get", attributes: { type: "calls", confidence: 0.9 } },
46
+ { source: "app::a.ts::caller2", target: "app::a.ts::getUser", attributes: { type: "calls", confidence: 0.9 } }
47
+ ]
48
+ };
49
+ const REPO_GRAPH = {
50
+ nodes: [
51
+ { id: "app::a.ts::usedFn", label: "usedFn", type: "function", file: "a.ts" },
52
+ { id: "app::a.ts::deadFn", label: "deadFn", type: "function", file: "a.ts" }
53
+ ],
54
+ links: [
55
+ { source: "app::a.ts::caller", target: "app::a.ts::usedFn", type: "calls", confidence: 0.9 },
56
+ { source: "app::a.ts::Container", target: "app::a.ts::deadFn", type: "contains", confidence: 1 }
57
+ ]
58
+ };
59
+ const CHUNKS = [
60
+ { id: "c1", repoName: "app", filePath: "a.ts", entityName: "handleRequest", startLine: 1, endLine: 3, content: "function handleRequest() { return 1; }", language: "typescript" }
61
+ ];
62
+ const PROJECT_GRAPH = {
63
+ architectureSummary: "Two services talk over HTTP.",
64
+ repoRoles: { app: { role: "service", criticality: "high", responsibilities: ["serves the API"] } },
65
+ relationships: [{ from: "app", to: "db", type: "sync-http", description: "queries the database" }],
66
+ keyFlows: [{ name: "login", trigger: "POST /login", steps: [{ repo: "app", component: "handler", action: "validate", protocol: "http" }] }]
67
+ };
68
+ beforeEach(() => {
69
+ stashDataDir = process.env.CODE_SEARCH_DATA_DIR;
70
+ tmp = mkdtempSync(join(tmpdir(), "cs-graph-"));
71
+ process.env.CODE_SEARCH_DATA_DIR = tmp;
72
+ const kb = join(tmp, PROJECT);
73
+ mkdirSync(join(kb, "app"), { recursive: true });
74
+ writeFileSync(join(kb, "system_graph_v2.json"), JSON.stringify(SYSTEM_GRAPH));
75
+ writeFileSync(join(kb, "app", "graph.json"), JSON.stringify(REPO_GRAPH));
76
+ writeFileSync(join(kb, "chunks.json"), JSON.stringify(CHUNKS));
77
+ writeFileSync(join(kb, "PROJECT_GRAPH.json"), JSON.stringify(PROJECT_GRAPH));
78
+ });
79
+ afterEach(() => {
80
+ rmSync(tmp, { recursive: true, force: true });
81
+ if (stashDataDir === void 0) delete process.env.CODE_SEARCH_DATA_DIR;
82
+ else process.env.CODE_SEARCH_DATA_DIR = stashDataDir;
83
+ });
84
+ describe("trace_path", () => {
85
+ it("finds a 2-hop call chain", async () => {
86
+ const r = await handleGraphTool("trace_path", { from: "handleRequest", to: "dbQuery" }, ctx());
87
+ const t = text(r);
88
+ assert.match(t, /2 hops/);
89
+ assert.match(t, /handleRequest.*validateToken.*dbQuery/s);
90
+ });
91
+ it("respects maxDepth", async () => {
92
+ const r = await handleGraphTool("trace_path", { from: "handleRequest", to: "dbQuery", maxDepth: 1 }, ctx());
93
+ assert.match(text(r), /No path/);
94
+ });
95
+ it("lists reachable callees without a target", async () => {
96
+ const r = await handleGraphTool("trace_path", { from: "handleRequest" }, ctx());
97
+ const t = text(r);
98
+ assert.match(t, /validateToken/);
99
+ assert.match(t, /dbQuery/);
100
+ });
101
+ });
102
+ describe("find_callers precision", () => {
103
+ it('exact match does not treat "get" as a substring of "getUser"', async () => {
104
+ const r = await handleGraphTool("find_callers", { function: "get" }, ctx());
105
+ const t = text(r);
106
+ assert.match(t, /caller1/);
107
+ assert.doesNotMatch(t, /caller2/);
108
+ assert.doesNotMatch(t, /getUser/);
109
+ });
110
+ it("fuzzy:true re-enables substring matching", async () => {
111
+ const r = await handleGraphTool("find_callers", { function: "get", fuzzy: true }, ctx());
112
+ const t = text(r);
113
+ assert.match(t, /caller1/);
114
+ assert.match(t, /caller2/);
115
+ });
116
+ });
117
+ describe("find_dead_code", () => {
118
+ it("flags zero-caller entities and ignores contains-only edges", async () => {
119
+ const r = await handleGraphTool("find_dead_code", { repo: "app" }, ctx());
120
+ const t = text(r);
121
+ assert.match(t, /deadFn/);
122
+ assert.doesNotMatch(t, /\busedFn\b/);
123
+ });
124
+ });
125
+ describe("get_architecture", () => {
126
+ it("renders the project graph when present", async () => {
127
+ const r = await handleGraphTool("get_architecture", {}, ctx());
128
+ const t = text(r);
129
+ assert.match(t, /Two services talk over HTTP/);
130
+ assert.match(t, /login/);
131
+ });
132
+ it("degrades cleanly when no architecture data exists", async () => {
133
+ const c = ctx();
134
+ c.projectName = "empty-project";
135
+ const r = await handleGraphTool("get_architecture", {}, c);
136
+ assert.match(text(r), /No architecture data/);
137
+ });
138
+ });
139
+ describe("get_code_snippet", () => {
140
+ it("returns source for a qualified id", async () => {
141
+ const r = await handleSearchTool("get_code_snippet", { id: "app::a.ts::handleRequest" }, ctx());
142
+ assert.match(text(r), /function handleRequest/);
143
+ });
144
+ it("reports when nothing matches", async () => {
145
+ const r = await handleSearchTool("get_code_snippet", { id: "app::a.ts::nope" }, ctx());
146
+ assert.match(text(r), /No snippet found/);
147
+ });
148
+ it("reads the NDJSON chunks format (org-scale)", async () => {
149
+ const ndjson = CHUNKS.map((c) => JSON.stringify(c)).join("\n") + "\n";
150
+ writeFileSync(join(tmp, PROJECT, "chunks.json"), ndjson);
151
+ const r = await handleSearchTool("get_code_snippet", { id: "app::a.ts::handleRequest" }, ctx());
152
+ assert.match(text(r), /function handleRequest/);
153
+ });
154
+ });
155
+ describe("search_graph", () => {
156
+ it("filters by type and minDegree, ranked by connectivity", async () => {
157
+ const r = await handleGraphTool("search_graph", { type: "function", minDegree: 2 }, ctx());
158
+ const t = text(r);
159
+ assert.match(t, /validateToken/);
160
+ assert.doesNotMatch(t, /handleRequest/);
161
+ });
162
+ it("matches names by regex/substring", async () => {
163
+ const r = await handleGraphTool("search_graph", { name: "caller" }, ctx());
164
+ const t = text(r);
165
+ assert.match(t, /caller1/);
166
+ assert.match(t, /caller2/);
167
+ });
168
+ it("reports when no entity matches", async () => {
169
+ const r = await handleGraphTool("search_graph", { type: "class" }, ctx());
170
+ assert.match(text(r), /No entities match/);
171
+ });
172
+ });
173
+ describe("detect_changes", () => {
174
+ it("requires a local repo path", async () => {
175
+ const r = await handleGraphTool("detect_changes", { repo: "app" }, ctx());
176
+ assert.match(text(r), /needs a local repo path/);
177
+ });
178
+ it("maps a git working-tree change to affected entities + dependents", async () => {
179
+ const ws = mkdtempSync(join(tmpdir(), "cs-ws-"));
180
+ const repoDir = join(ws, "app");
181
+ mkdirSync(repoDir, { recursive: true });
182
+ let baseSha = "";
183
+ try {
184
+ const git = (cmd) => execSync(`git ${cmd}`, { cwd: repoDir, stdio: ["pipe", "pipe", "pipe"] });
185
+ git("init -q");
186
+ git("config user.email t@t.dev");
187
+ git("config user.name test");
188
+ git("config commit.gpgsign false");
189
+ writeFileSync(join(repoDir, "a.ts"), "export function handleRequest() {}\n");
190
+ writeFileSync(join(repoDir, "b.ts"), "export function dbQuery() {}\n");
191
+ git("add -A");
192
+ git("commit -q -m init");
193
+ baseSha = execSync("git rev-parse HEAD", { cwd: repoDir }).toString().trim();
194
+ appendFileSync(join(repoDir, "b.ts"), "// touched\n");
195
+ } catch {
196
+ rmSync(ws, { recursive: true, force: true });
197
+ return;
198
+ }
199
+ const c = ctx();
200
+ c.directoryPath = ws;
201
+ const r = await handleGraphTool("detect_changes", { repo: "app", baseSha }, c);
202
+ const t = text(r);
203
+ rmSync(ws, { recursive: true, force: true });
204
+ assert.match(t, /b\.ts/);
205
+ assert.match(t, /dbQuery/);
206
+ assert.match(t, /validateToken.*dbQuery/s);
207
+ });
208
+ });
package/dist/server.js CHANGED
@@ -20,7 +20,7 @@ import { toKnowledgeConfig } from "./core/config.js";
20
20
  import { startHttpTransport } from "./transports/http-transport.js";
21
21
  function createMcpServerInstance(ctx) {
22
22
  const server = new Server(
23
- { name: "code-search-mcp", version: "0.1.0" },
23
+ { name: "code-search-mcp", version: "0.4.0" },
24
24
  { capabilities: { tools: {}, resources: {} } }
25
25
  );
26
26
  const allTools = [
@@ -1,6 +1,98 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { getKnowledgeBasePath } from "@esankhan3/anvil-knowledge-core";
3
+ import {
4
+ getKnowledgeBasePath,
5
+ loadAllProfiles,
6
+ loadProfile,
7
+ getAllChanges,
8
+ getChangedFilesList
9
+ } from "@esankhan3/anvil-knowledge-core";
10
+ const GRAPH_TOOL_NAMES = [
11
+ "get_repo_graph",
12
+ "get_cross_repo_edges",
13
+ "find_callers",
14
+ "find_dependencies",
15
+ "impact_analysis",
16
+ "trace_path",
17
+ "find_dead_code",
18
+ "get_architecture",
19
+ "search_graph",
20
+ "detect_changes"
21
+ ];
22
+ function loadSystemGraph(kbPath) {
23
+ const p = join(kbPath, "system_graph_v2.json");
24
+ if (!existsSync(p)) return null;
25
+ try {
26
+ const g = JSON.parse(readFileSync(p, "utf-8"));
27
+ return { nodes: g.nodes ?? [], edges: g.edges ?? [] };
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+ function nodeLabel(node) {
33
+ return node.attributes?.label ?? node.key.split("::").slice(2).join("::") ?? node.key;
34
+ }
35
+ const edgeType = (e) => e.attributes?.type ?? e.attributes?.relation;
36
+ function resolveEntityNodes(nodes, name, repo, fuzzy = false) {
37
+ const inRepo = (key) => !repo || key.startsWith(repo + "::");
38
+ if (fuzzy) {
39
+ return nodes.filter((n) => inRepo(n.key) && ((n.attributes?.label ?? "").includes(name) || n.key.includes(name))).map((n) => n.key);
40
+ }
41
+ return nodes.filter((n) => inRepo(n.key) && (n.attributes?.label === name || n.key.endsWith("::" + name))).map((n) => n.key);
42
+ }
43
+ function buildAdjacency(edges, direction) {
44
+ const adj = /* @__PURE__ */ new Map();
45
+ const add = (a, b) => {
46
+ if (!adj.has(a)) adj.set(a, /* @__PURE__ */ new Set());
47
+ adj.get(a).add(b);
48
+ };
49
+ for (const e of edges) {
50
+ if (!e.source || !e.target) continue;
51
+ if (edgeType(e) === "contains") continue;
52
+ if ((e.attributes?.confidence ?? 0.8) < 0.7) continue;
53
+ if (direction === "callees" || direction === "both") add(e.source, e.target);
54
+ if (direction === "callers" || direction === "both") add(e.target, e.source);
55
+ }
56
+ return adj;
57
+ }
58
+ function shortestPath(adj, fromKeys, toKeys, maxDepth) {
59
+ const seed = fromKeys.find((k) => toKeys.has(k));
60
+ if (seed) return [seed];
61
+ const visited = new Set(fromKeys);
62
+ let frontier = fromKeys.map((k) => [k]);
63
+ for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
64
+ const next = [];
65
+ for (const path of frontier) {
66
+ for (const nb of adj.get(path[path.length - 1]) ?? []) {
67
+ if (visited.has(nb)) continue;
68
+ if (toKeys.has(nb)) return [...path, nb];
69
+ visited.add(nb);
70
+ next.push([...path, nb]);
71
+ }
72
+ }
73
+ frontier = next;
74
+ }
75
+ return null;
76
+ }
77
+ function reachable(adj, fromKeys, maxDepth, cap = 50) {
78
+ const visited = new Set(fromKeys);
79
+ const out = [];
80
+ let frontier = [...fromKeys];
81
+ for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
82
+ const next = [];
83
+ for (const k of frontier) {
84
+ for (const nb of adj.get(k) ?? []) {
85
+ if (visited.has(nb)) continue;
86
+ visited.add(nb);
87
+ out.push({ key: nb, depth });
88
+ next.push(nb);
89
+ if (out.length >= cap) return out;
90
+ }
91
+ }
92
+ frontier = next;
93
+ }
94
+ return out;
95
+ }
4
96
  function registerGraphTools() {
5
97
  return [
6
98
  {
@@ -8,9 +100,7 @@ function registerGraphTools() {
8
100
  description: "Get the AST knowledge graph for a repo \u2014 entities (functions, classes, types) and their relationships (calls, imports, inheritance).",
9
101
  inputSchema: {
10
102
  type: "object",
11
- properties: {
12
- repo: { type: "string", description: "Repository name" }
13
- },
103
+ properties: { repo: { type: "string", description: "Repository name" } },
14
104
  required: ["repo"]
15
105
  }
16
106
  },
@@ -19,31 +109,31 @@ function registerGraphTools() {
19
109
  description: "Get connections between repos \u2014 shared deps, Kafka topics, HTTP routes, database tables, gRPC services, etc.",
20
110
  inputSchema: {
21
111
  type: "object",
22
- properties: {
23
- repo: { type: "string", description: "Filter to edges involving this repo (optional)" }
24
- }
112
+ properties: { repo: { type: "string", description: "Filter to edges involving this repo (optional)" } }
25
113
  }
26
114
  },
27
115
  {
28
116
  name: "find_callers",
29
- description: "Find all functions/methods that call a given function. Uses the AST graph.",
117
+ description: "Find all functions/methods that call a given function (exact name match by default). Uses the AST graph.",
30
118
  inputSchema: {
31
119
  type: "object",
32
120
  properties: {
33
121
  function: { type: "string", description: "Function name to find callers of" },
34
- repo: { type: "string", description: "Limit search to this repo (optional)" }
122
+ repo: { type: "string", description: "Limit search to this repo (optional)" },
123
+ fuzzy: { type: "boolean", description: "Substring-match the name instead of exact (default false)" }
35
124
  },
36
125
  required: ["function"]
37
126
  }
38
127
  },
39
128
  {
40
129
  name: "find_dependencies",
41
- description: "Find what a function depends on \u2014 calls, imports, type references.",
130
+ description: "Find what a function depends on \u2014 calls, imports, type references (exact name match by default).",
42
131
  inputSchema: {
43
132
  type: "object",
44
133
  properties: {
45
134
  function: { type: "string", description: "Function name" },
46
- repo: { type: "string", description: "Limit to this repo (optional)" }
135
+ repo: { type: "string", description: "Limit to this repo (optional)" },
136
+ fuzzy: { type: "boolean", description: "Substring-match the name instead of exact (default false)" }
47
137
  },
48
138
  required: ["function"]
49
139
  }
@@ -60,11 +150,75 @@ function registerGraphTools() {
60
150
  },
61
151
  required: ["file", "repo"]
62
152
  }
153
+ },
154
+ {
155
+ name: "trace_path",
156
+ description: 'Trace the call chain from a function across multiple hops. With "to", returns the shortest path between two functions; otherwise returns the reachable call tree. Follows confidence-weighted call/import/use edges.',
157
+ inputSchema: {
158
+ type: "object",
159
+ properties: {
160
+ from: { type: "string", description: "Starting function name or qualified id (repo::file::entity)" },
161
+ to: { type: "string", description: "Target function name or qualified id (optional \u2014 shortest path)" },
162
+ repo: { type: "string", description: "Limit resolution to this repo (optional)" },
163
+ direction: { type: "string", enum: ["callees", "callers", "both"], description: "Traverse callees (what it calls), callers (who calls it), or both. Default callees." },
164
+ maxDepth: { type: "number", description: "Max hops to traverse (default 4)" },
165
+ fuzzy: { type: "boolean", description: "Substring-match names instead of exact (default false)" }
166
+ },
167
+ required: ["from"]
168
+ }
169
+ },
170
+ {
171
+ name: "find_dead_code",
172
+ description: "List entities (functions, methods, classes) with no callers/importers in a repo \u2014 likely dead code. Heuristic: exported APIs, reflection, and dynamic dispatch produce false positives.",
173
+ inputSchema: {
174
+ type: "object",
175
+ properties: {
176
+ repo: { type: "string", description: "Repository name" },
177
+ limit: { type: "number", description: "Max results (default 50)" }
178
+ },
179
+ required: ["repo"]
180
+ }
181
+ },
182
+ {
183
+ name: "get_architecture",
184
+ description: "High-level architecture overview of the project \u2014 repo roles, key flows, and how services connect. Built from the LLM-generated project graph and repo profiles.",
185
+ inputSchema: {
186
+ type: "object",
187
+ properties: { repo: { type: "string", description: "Drill into one repo profile (optional)" } }
188
+ }
189
+ },
190
+ {
191
+ name: "search_graph",
192
+ description: `Structural search over the knowledge graph: filter entities by name (regex/substring), type, file, and repo; rank by connectivity (degree). Answers "which functions matter most in this module" \u2014 a structural question search/grep can't.`,
193
+ inputSchema: {
194
+ type: "object",
195
+ properties: {
196
+ name: { type: "string", description: "Name regex or substring to match (optional)" },
197
+ type: { type: "string", description: "Entity type filter: function | class | interface | method | type | \u2026 (optional)" },
198
+ file: { type: "string", description: "File-path substring filter (optional)" },
199
+ repo: { type: "string", description: "Limit to this repo (optional)" },
200
+ minDegree: { type: "number", description: "Only entities with at least this many connections (default 0)" },
201
+ limit: { type: "number", description: "Max results (default 50)" }
202
+ }
203
+ }
204
+ },
205
+ {
206
+ name: "detect_changes",
207
+ description: "Map a git diff to affected symbols and their dependents. Compares the working tree against a base commit (or the last-indexed SHA) and reports which entities changed and what depends on them \u2014 a blast-radius / code-review tool. Requires a local repo (local/serve mode).",
208
+ inputSchema: {
209
+ type: "object",
210
+ properties: {
211
+ repo: { type: "string", description: "Repository name" },
212
+ baseSha: { type: "string", description: "Git base commit to diff against (default: last-indexed SHA)" },
213
+ limit: { type: "number", description: "Max dependent edges to list (default 50)" }
214
+ },
215
+ required: ["repo"]
216
+ }
63
217
  }
64
218
  ];
65
219
  }
66
220
  async function handleGraphTool(name, args, ctx) {
67
- if (!["get_repo_graph", "get_cross_repo_edges", "find_callers", "find_dependencies", "impact_analysis"].includes(name)) return null;
221
+ if (!GRAPH_TOOL_NAMES.includes(name)) return null;
68
222
  try {
69
223
  const kbPath = getKnowledgeBasePath(ctx.projectName);
70
224
  if (name === "get_repo_graph") {
@@ -86,26 +240,21 @@ ${graph.nodes?.length > 50 ? `... and ${graph.nodes.length - 50} more` : ""}`;
86
240
  return { content: [{ type: "text", text: summary }] };
87
241
  }
88
242
  if (name === "get_cross_repo_edges") {
89
- const sysGraphPath = join(kbPath, "system_graph_v2.json");
90
- if (!existsSync(sysGraphPath)) {
91
- return { content: [{ type: "text", text: "No system graph found. Build KB first." }] };
92
- }
93
- const sysGraph = JSON.parse(readFileSync(sysGraphPath, "utf-8"));
94
- const edges = sysGraph.edges ?? [];
243
+ const sys = loadSystemGraph(kbPath);
244
+ if (!sys) return { content: [{ type: "text", text: "No system graph found. Build KB first." }] };
95
245
  const repo = args.repo;
96
- const relevant = repo ? edges.filter((e) => (e.source ?? "").startsWith(repo + "::") || (e.target ?? "").startsWith(repo + "::")) : edges;
246
+ const relevant = repo ? sys.edges.filter((e) => e.source.startsWith(repo + "::") || e.target.startsWith(repo + "::")) : sys.edges;
97
247
  const crossRepo = relevant.filter((e) => {
98
- const src = (e.source ?? "").split("::")[0];
99
- const tgt = (e.target ?? "").split("::")[0];
248
+ const src = e.source.split("::")[0];
249
+ const tgt = e.target.split("::")[0];
100
250
  return src && tgt && src !== tgt;
101
251
  });
102
252
  if (crossRepo.length === 0) {
103
253
  return { content: [{ type: "text", text: repo ? `No cross-repo edges found for "${repo}"` : "No cross-repo edges found" }] };
104
254
  }
105
- const text = crossRepo.slice(0, 50).map((e) => {
106
- const attrs = e.attributes ?? {};
107
- return `- ${(e.source ?? "").split("::")[0]} \u2192 ${(e.target ?? "").split("::")[0]} (${attrs.relation ?? attrs.type ?? "edge"})`;
108
- }).join("\n");
255
+ const text = crossRepo.slice(0, 50).map(
256
+ (e) => `- ${e.source.split("::")[0]} \u2192 ${e.target.split("::")[0]} (${edgeType(e) ?? "edge"})`
257
+ ).join("\n");
109
258
  return { content: [{ type: "text", text: `# Cross-Repo Edges${repo ? ` for ${repo}` : ""}
110
259
 
111
260
  ${crossRepo.length} edges found:
@@ -117,30 +266,21 @@ ${text}${crossRepo.length > 50 ? `
117
266
  if (name === "find_callers" || name === "find_dependencies") {
118
267
  const funcName = args.function;
119
268
  const repoFilter = args.repo;
120
- const sysGraphPath = join(kbPath, "system_graph_v2.json");
121
- if (!existsSync(sysGraphPath)) {
122
- return { content: [{ type: "text", text: "No system graph found." }] };
123
- }
124
- const sysGraph = JSON.parse(readFileSync(sysGraphPath, "utf-8"));
125
- const edges = sysGraph.edges ?? [];
126
- const matchingNodes = (sysGraph.nodes ?? []).filter((n) => {
127
- const label = n.attributes?.label ?? n.key ?? "";
128
- const matchesName = label.includes(funcName) || (n.key ?? "").includes(funcName);
129
- const matchesRepo = !repoFilter || (n.key ?? "").startsWith(repoFilter + "::");
130
- return matchesName && matchesRepo;
131
- });
132
- if (matchingNodes.length === 0) {
133
- return { content: [{ type: "text", text: `No entity found matching "${funcName}"${repoFilter ? ` in ${repoFilter}` : ""}` }] };
134
- }
135
- const nodeKeys = new Set(matchingNodes.map((n) => n.key));
136
- let results;
137
- if (name === "find_callers") {
138
- results = edges.filter((e) => nodeKeys.has(e.target)).map((e) => e.source);
139
- } else {
140
- results = edges.filter((e) => nodeKeys.has(e.source)).map((e) => e.target);
269
+ const fuzzy = args.fuzzy === true;
270
+ const sys = loadSystemGraph(kbPath);
271
+ if (!sys) return { content: [{ type: "text", text: "No system graph found." }] };
272
+ const matchKeys = new Set(resolveEntityNodes(sys.nodes, funcName, repoFilter, fuzzy));
273
+ if (matchKeys.size === 0) {
274
+ return { content: [{ type: "text", text: `No entity found matching "${funcName}"${repoFilter ? ` in ${repoFilter}` : ""}. Try fuzzy:true for substring matching.` }] };
141
275
  }
276
+ const results = name === "find_callers" ? sys.edges.filter((e) => matchKeys.has(e.target) && edgeType(e) !== "contains").map((e) => e.source) : sys.edges.filter((e) => matchKeys.has(e.source) && edgeType(e) !== "contains").map((e) => e.target);
142
277
  const unique = [...new Set(results)].slice(0, 30);
143
278
  const direction = name === "find_callers" ? "Callers of" : "Dependencies of";
279
+ if (unique.length === 0) {
280
+ return { content: [{ type: "text", text: `# ${direction} "${funcName}"
281
+
282
+ Matched ${matchKeys.size} entit${matchKeys.size === 1 ? "y" : "ies"}, but no ${name === "find_callers" ? "callers" : "dependencies"} found.` }] };
283
+ }
144
284
  return { content: [{ type: "text", text: `# ${direction} "${funcName}"
145
285
 
146
286
  ${unique.length} found:
@@ -150,22 +290,16 @@ ${unique.map((r) => `- \`${r}\``).join("\n")}` }] };
150
290
  const file = args.file;
151
291
  const repo = args.repo;
152
292
  const entity = args.entity;
153
- const sysGraphPath = join(kbPath, "system_graph_v2.json");
154
- if (!existsSync(sysGraphPath)) {
155
- return { content: [{ type: "text", text: "No system graph found." }] };
156
- }
157
- const sysGraph = JSON.parse(readFileSync(sysGraphPath, "utf-8"));
158
- const edges = sysGraph.edges ?? [];
159
- const nodes = sysGraph.nodes ?? [];
160
- const fileNodes = nodes.filter((n) => {
161
- const key = n.key ?? "";
162
- const matchesFile = key.includes(`${repo}::${file}::`);
163
- const matchesEntity = !entity || key.includes(entity);
293
+ const sys = loadSystemGraph(kbPath);
294
+ if (!sys) return { content: [{ type: "text", text: "No system graph found." }] };
295
+ const fileNodes = sys.nodes.filter((n) => {
296
+ const matchesFile = n.key.includes(`${repo}::${file}::`);
297
+ const matchesEntity = !entity || n.key.endsWith(`::${entity}`);
164
298
  return matchesFile && matchesEntity;
165
299
  });
166
300
  const nodeKeys = new Set(fileNodes.map((n) => n.key));
167
- const dependents = edges.filter((e) => nodeKeys.has(e.target) && !nodeKeys.has(e.source));
168
- const dependentRepos = new Set(dependents.map((e) => (e.source ?? "").split("::")[0]));
301
+ const dependents = sys.edges.filter((e) => nodeKeys.has(e.target) && !nodeKeys.has(e.source) && edgeType(e) !== "contains");
302
+ const dependentRepos = new Set(dependents.map((e) => e.source.split("::")[0]));
169
303
  const text = [
170
304
  `# Impact Analysis: ${repo}/${file}${entity ? `::${entity}` : ""}`,
171
305
  "",
@@ -173,7 +307,7 @@ ${unique.map((r) => `- \`${r}\``).join("\n")}` }] };
173
307
  ...fileNodes.slice(0, 20).map((n) => `- \`${n.key}\``),
174
308
  "",
175
309
  `## Dependents: ${dependents.length} edges from ${dependentRepos.size} repos`,
176
- ...dependents.slice(0, 30).map((e) => `- \`${e.source}\` \u2192 \`${e.target}\` (${(e.attributes ?? {}).relation ?? "edge"})`),
310
+ ...dependents.slice(0, 30).map((e) => `- \`${e.source}\` \u2192 \`${e.target}\` (${edgeType(e) ?? "edge"})`),
177
311
  dependents.length > 30 ? `
178
312
  ... and ${dependents.length - 30} more` : "",
179
313
  "",
@@ -181,6 +315,252 @@ ${unique.map((r) => `- \`${r}\``).join("\n")}` }] };
181
315
  ].join("\n");
182
316
  return { content: [{ type: "text", text }] };
183
317
  }
318
+ if (name === "trace_path") {
319
+ const sys = loadSystemGraph(kbPath);
320
+ if (!sys) return { content: [{ type: "text", text: "No system graph found." }] };
321
+ const from = args.from;
322
+ const to = args.to;
323
+ const repo = args.repo;
324
+ const fuzzy = args.fuzzy === true;
325
+ const direction = args.direction ?? "callees";
326
+ const maxDepth = Math.max(1, Math.min(10, args.maxDepth || 4));
327
+ const fromKeys = resolveEntityNodes(sys.nodes, from, repo, fuzzy);
328
+ if (fromKeys.length === 0) {
329
+ return { content: [{ type: "text", text: `No entity found matching "${from}". Try fuzzy:true.` }] };
330
+ }
331
+ const labelOf = new Map(sys.nodes.map((n) => [n.key, nodeLabel(n)]));
332
+ const adj = buildAdjacency(sys.edges, direction);
333
+ if (to) {
334
+ const toKeys = new Set(resolveEntityNodes(sys.nodes, to, repo, fuzzy));
335
+ if (toKeys.size === 0) {
336
+ return { content: [{ type: "text", text: `No entity found matching target "${to}". Try fuzzy:true.` }] };
337
+ }
338
+ const path = shortestPath(adj, fromKeys, toKeys, maxDepth);
339
+ if (!path) {
340
+ return { content: [{ type: "text", text: `No path from "${from}" to "${to}" within ${maxDepth} hops (direction: ${direction}).` }] };
341
+ }
342
+ const arrow = direction === "callers" ? " \u2190 " : " \u2192 ";
343
+ const rendered = path.map((k) => labelOf.get(k) ?? k).join(arrow);
344
+ return { content: [{ type: "text", text: `# Path (${path.length - 1} hops)
345
+
346
+ ${rendered}
347
+
348
+ ${path.map((k) => `- \`${k}\``).join("\n")}` }] };
349
+ }
350
+ const nodes = reachable(adj, fromKeys, maxDepth);
351
+ if (nodes.length === 0) {
352
+ return { content: [{ type: "text", text: `No ${direction} reachable from "${from}" within ${maxDepth} hops.` }] };
353
+ }
354
+ const byDepth = /* @__PURE__ */ new Map();
355
+ for (const { key, depth } of nodes) {
356
+ if (!byDepth.has(depth)) byDepth.set(depth, []);
357
+ byDepth.get(depth).push(`\`${labelOf.get(key) ?? key}\` (${key})`);
358
+ }
359
+ const sections = [...byDepth.entries()].sort((a, b) => a[0] - b[0]).map(([d, ks]) => `## Depth ${d}
360
+ ${ks.map((k) => `- ${k}`).join("\n")}`).join("\n\n");
361
+ return { content: [{ type: "text", text: `# Reachable from "${from}" (${direction}, \u2264${maxDepth} hops)
362
+
363
+ ${nodes.length} nodes:
364
+
365
+ ${sections}` }] };
366
+ }
367
+ if (name === "find_dead_code") {
368
+ const repo = args.repo;
369
+ const limit = args.limit || 50;
370
+ const graphPath = join(kbPath, repo, "graph.json");
371
+ if (!existsSync(graphPath)) {
372
+ return { content: [{ type: "text", text: `No graph found for repo "${repo}"` }] };
373
+ }
374
+ const graph = JSON.parse(readFileSync(graphPath, "utf-8"));
375
+ const ENTITY_TYPES = /* @__PURE__ */ new Set(["function", "method", "class", "struct", "enum", "trait"]);
376
+ const inDegree = /* @__PURE__ */ new Map();
377
+ for (const l of graph.links ?? []) {
378
+ if (l.type === "contains") continue;
379
+ inDegree.set(l.target, (inDegree.get(l.target) ?? 0) + 1);
380
+ }
381
+ const dead = (graph.nodes ?? []).filter((n) => ENTITY_TYPES.has(n.type) && (inDegree.get(n.id) ?? 0) === 0);
382
+ if (dead.length === 0) {
383
+ return { content: [{ type: "text", text: `# Dead code in ${repo}
384
+
385
+ No zero-caller entities found.` }] };
386
+ }
387
+ const rows = dead.slice(0, limit).map((n) => `- \`${n.label ?? n.id}\` (${n.type})${n.file ? ` \u2014 ${n.file}` : ""}`).join("\n");
388
+ return { content: [{ type: "text", text: `# Dead code in ${repo} (heuristic)
389
+
390
+ ${dead.length} zero-caller entit${dead.length === 1 ? "y" : "ies"}:
391
+
392
+ ${rows}${dead.length > limit ? `
393
+
394
+ ... and ${dead.length - limit} more` : ""}
395
+
396
+ _Note: exported APIs, reflection, and dynamic dispatch can cause false positives._` }] };
397
+ }
398
+ if (name === "get_architecture") {
399
+ const repo = args.repo;
400
+ if (repo) {
401
+ const profile = loadProfile(ctx.projectName, repo);
402
+ if (!profile) {
403
+ return { content: [{ type: "text", text: `No profile for "${repo}". Run profiling (requires LLM) to generate one.` }] };
404
+ }
405
+ const ep = (xs) => xs?.length ? xs.map((e) => ` - ${e.type}: ${e.identifier} \u2014 ${e.description}`).join("\n") : " - (none)";
406
+ const text = [
407
+ `# ${profile.name} \u2014 ${profile.role} (${profile.domain})`,
408
+ "",
409
+ profile.description,
410
+ "",
411
+ `**Tech:** ${profile.technologies?.join(", ") || "unknown"}`,
412
+ `**Entry points:** ${profile.entryPoints?.join(", ") || "unknown"}`,
413
+ "",
414
+ "## Exposes",
415
+ ep(profile.exposes),
416
+ "",
417
+ "## Consumes",
418
+ ep(profile.consumes)
419
+ ].join("\n");
420
+ return { content: [{ type: "text", text }] };
421
+ }
422
+ const pgPath = join(kbPath, "PROJECT_GRAPH.json");
423
+ const pg = existsSync(pgPath) ? JSON.parse(readFileSync(pgPath, "utf-8")) : null;
424
+ if (pg) {
425
+ const lines = ["# Project Architecture", "", pg.architectureSummary ?? ""];
426
+ if (pg.repoRoles && Object.keys(pg.repoRoles).length) {
427
+ lines.push("", "## Repo Roles");
428
+ for (const [name2, r] of Object.entries(pg.repoRoles)) {
429
+ lines.push(`- **${name2}** \u2014 ${r.role} (${r.criticality}): ${(r.responsibilities ?? []).join("; ")}`);
430
+ }
431
+ }
432
+ if (pg.relationships?.length) {
433
+ lines.push("", "## Relationships");
434
+ for (const rel of pg.relationships) {
435
+ lines.push(`- ${rel.from} \u2192 ${rel.to} (${rel.type}): ${rel.description}`);
436
+ }
437
+ }
438
+ if (pg.keyFlows?.length) {
439
+ lines.push("", "## Key Flows");
440
+ for (const f of pg.keyFlows) {
441
+ lines.push(`- **${f.name}** (trigger: ${f.trigger})`);
442
+ for (const s of f.steps ?? []) lines.push(` - ${s.repo}/${s.component}: ${s.action} [${s.protocol}]`);
443
+ }
444
+ }
445
+ return { content: [{ type: "text", text: lines.join("\n") }] };
446
+ }
447
+ const summaryPath = join(kbPath, "PROJECT_SUMMARY.md");
448
+ if (existsSync(summaryPath)) {
449
+ return { content: [{ type: "text", text: readFileSync(summaryPath, "utf-8") }] };
450
+ }
451
+ const profiles = loadAllProfiles(ctx.projectName);
452
+ if (profiles.length) {
453
+ const text = `# Project Repos
454
+
455
+ ${profiles.map((p) => `- **${p.name}** \u2014 ${p.role} (${p.domain}): ${p.description}`).join("\n")}
456
+
457
+ _Run project-graph generation (requires LLM) for a full architecture view._`;
458
+ return { content: [{ type: "text", text }] };
459
+ }
460
+ return { content: [{ type: "text", text: "No architecture data yet. Index the project, then run profiling / project-graph generation (requires an LLM) for an architecture overview." }] };
461
+ }
462
+ if (name === "search_graph") {
463
+ const sys = loadSystemGraph(kbPath);
464
+ if (!sys) return { content: [{ type: "text", text: "No system graph found. Build KB first." }] };
465
+ const repo = args.repo;
466
+ const type = args.type;
467
+ const file = args.file;
468
+ const minDegree = args.minDegree ?? 0;
469
+ const limit = args.limit || 50;
470
+ const nameArg = args.name;
471
+ let nameTest = () => true;
472
+ if (nameArg) {
473
+ try {
474
+ const re = new RegExp(nameArg, "i");
475
+ nameTest = (s) => re.test(s);
476
+ } catch {
477
+ const lc = nameArg.toLowerCase();
478
+ nameTest = (s) => s.toLowerCase().includes(lc);
479
+ }
480
+ }
481
+ const degree = /* @__PURE__ */ new Map();
482
+ for (const e of sys.edges) {
483
+ if (edgeType(e) === "contains") continue;
484
+ degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
485
+ degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
486
+ }
487
+ const matches = sys.nodes.filter((n) => {
488
+ if (repo && !n.key.startsWith(repo + "::")) return false;
489
+ if (type && n.attributes?.type !== type) return false;
490
+ const f = n.attributes?.file ?? n.key.split("::")[1] ?? "";
491
+ if (file && !f.includes(file)) return false;
492
+ if (nameArg && !(nameTest(n.attributes?.label ?? "") || nameTest(n.key))) return false;
493
+ return (degree.get(n.key) ?? 0) >= minDegree;
494
+ }).map((n) => ({ key: n.key, label: nodeLabel(n), type: n.attributes?.type ?? "?", file: n.attributes?.file ?? n.key.split("::")[1] ?? "", deg: degree.get(n.key) ?? 0 })).sort((a, b) => b.deg - a.deg);
495
+ if (matches.length === 0) {
496
+ return { content: [{ type: "text", text: "No entities match the given filters." }] };
497
+ }
498
+ const rows = matches.slice(0, limit).map((m) => `- \`${m.label}\` (${m.type}, ${m.file}) \u2014 degree ${m.deg} \`${m.key}\``).join("\n");
499
+ return { content: [{ type: "text", text: `# search_graph \u2014 ${matches.length} match${matches.length === 1 ? "" : "es"}
500
+
501
+ ${rows}${matches.length > limit ? `
502
+
503
+ ... and ${matches.length - limit} more` : ""}` }] };
504
+ }
505
+ if (name === "detect_changes") {
506
+ const repo = args.repo;
507
+ const limit = args.limit || 50;
508
+ if (!ctx.directoryPath) {
509
+ return { content: [{ type: "text", text: "detect_changes needs a local repo path \u2014 available in local/serve mode only (not remote-proxy mode)." }] };
510
+ }
511
+ const candidate = join(ctx.directoryPath, repo);
512
+ const repoPath = existsSync(join(candidate, ".git")) ? candidate : ctx.directoryPath;
513
+ if (!existsSync(repoPath)) {
514
+ return { content: [{ type: "text", text: `Repo path not found: ${repoPath}` }] };
515
+ }
516
+ let baseSha = args.baseSha;
517
+ if (!baseSha) {
518
+ const metaPath = join(kbPath, repo, "index_meta.json");
519
+ if (existsSync(metaPath)) {
520
+ try {
521
+ baseSha = JSON.parse(readFileSync(metaPath, "utf-8")).lastIndexedSha;
522
+ } catch {
523
+ }
524
+ }
525
+ }
526
+ if (!baseSha) {
527
+ return { content: [{ type: "text", text: "No base commit available. Pass baseSha, or index the repo first so a last-indexed SHA exists." }] };
528
+ }
529
+ const diff = getAllChanges(repoPath, baseSha);
530
+ if (diff.fallbackToFull) {
531
+ return { content: [{ type: "text", text: `git diff against ${baseSha.slice(0, 7)} failed or is too large to map incrementally. Check the base SHA.` }] };
532
+ }
533
+ const changedFiles = getChangedFilesList(diff);
534
+ if (changedFiles.length === 0 && diff.deleted.length === 0) {
535
+ return { content: [{ type: "text", text: `No source changes since ${baseSha.slice(0, 7)}.` }] };
536
+ }
537
+ const sys = loadSystemGraph(kbPath);
538
+ const changedKeys = /* @__PURE__ */ new Set();
539
+ if (sys) {
540
+ for (const f of changedFiles) {
541
+ for (const n of sys.nodes) {
542
+ if (n.key.startsWith(`${repo}::${f}::`) || n.key === `${repo}::${f}`) changedKeys.add(n.key);
543
+ }
544
+ }
545
+ }
546
+ const dependents = sys ? sys.edges.filter((e) => changedKeys.has(e.target) && !changedKeys.has(e.source) && edgeType(e) !== "contains") : [];
547
+ const dependentRepos = new Set(dependents.map((e) => e.source.split("::")[0]));
548
+ const lines = [
549
+ `# Changes since ${baseSha.slice(0, 7)} \u2014 ${repo}`,
550
+ "",
551
+ `## Changed files: ${changedFiles.length} (+${diff.added.length} / ~${diff.modified.length}), deleted ${diff.deleted.length}`,
552
+ ...changedFiles.slice(0, 30).map((f) => `- ${f}`),
553
+ "",
554
+ `## Affected entities: ${changedKeys.size}`,
555
+ ...[...changedKeys].slice(0, 30).map((k) => `- \`${k}\``),
556
+ "",
557
+ `## Dependents: ${dependents.length} edges from ${dependentRepos.size} repo(s)`,
558
+ ...dependents.slice(0, limit).map((e) => `- \`${e.source}\` \u2192 \`${e.target}\` (${edgeType(e) ?? "edge"})`),
559
+ "",
560
+ `## Affected repos: ${[...dependentRepos].join(", ") || "none"}`
561
+ ];
562
+ return { content: [{ type: "text", text: lines.join("\n") }] };
563
+ }
184
564
  return null;
185
565
  } catch (err) {
186
566
  const msg = err instanceof Error ? err.message : String(err);
@@ -1,4 +1,6 @@
1
- import { getRetriever } from "@esankhan3/anvil-knowledge-core";
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getRetriever, getKnowledgeBasePath, findChunksInFile } from "@esankhan3/anvil-knowledge-core";
2
4
  function registerSearchTools() {
3
5
  return [
4
6
  {
@@ -37,10 +39,24 @@ function registerSearchTools() {
37
39
  },
38
40
  required: ["query"]
39
41
  }
42
+ },
43
+ {
44
+ name: "get_code_snippet",
45
+ description: 'Fetch the source code for one entity by qualified name. Pass either id="repo::path/to/file::entity" or the separate repo/file/entity fields. Returns just that symbol \u2014 far cheaper than reading the whole file.',
46
+ inputSchema: {
47
+ type: "object",
48
+ properties: {
49
+ id: { type: "string", description: 'Qualified id "repo::filePath::entity" (or "repo::filePath" for a whole file)' },
50
+ repo: { type: "string", description: "Repository name (alternative to id)" },
51
+ file: { type: "string", description: "File path relative to repo root (alternative to id)" },
52
+ entity: { type: "string", description: "Entity name (optional; with repo/file)" }
53
+ }
54
+ }
40
55
  }
41
56
  ];
42
57
  }
43
58
  async function handleSearchTool(name, args, ctx) {
59
+ if (name === "get_code_snippet") return await handleGetCodeSnippet(args, ctx);
44
60
  if (!["search_code", "search_semantic", "search_exact"].includes(name)) return null;
45
61
  if (!ctx.indexReady) {
46
62
  return { content: [{ type: "text", text: "Index not ready. Run reindex tool or wait for auto-indexing to complete." }] };
@@ -78,6 +94,52 @@ ${text}` }] };
78
94
  return { content: [{ type: "text", text: `Search failed: ${msg}` }] };
79
95
  }
80
96
  }
97
+ async function handleGetCodeSnippet(args, ctx) {
98
+ let repo = args.repo;
99
+ let file = args.file;
100
+ let entity = args.entity;
101
+ if (typeof args.id === "string") {
102
+ const parts = args.id.split("::");
103
+ repo = parts[0];
104
+ if (parts.length >= 3) {
105
+ file = parts[1];
106
+ entity = parts.slice(2).join("::");
107
+ } else if (parts.length === 2) {
108
+ file = parts[1];
109
+ }
110
+ }
111
+ if (!repo || !file) {
112
+ return { content: [{ type: "text", text: 'Provide id="repo::file::entity" or repo + file (entity optional).' }] };
113
+ }
114
+ const chunksPath = join(getKnowledgeBasePath(ctx.projectName), "chunks.json");
115
+ if (!existsSync(chunksPath)) {
116
+ return { content: [{ type: "text", text: "No index found \u2014 chunks.json missing. Index the project first." }] };
117
+ }
118
+ try {
119
+ let matches = await findChunksInFile(
120
+ chunksPath,
121
+ (c) => c.repoName === repo && c.filePath === file && (!entity || c.entityName === entity),
122
+ 5
123
+ );
124
+ if (matches.length === 0 && entity) {
125
+ matches = await findChunksInFile(chunksPath, (c) => c.repoName === repo && c.entityName === entity, 5);
126
+ }
127
+ if (matches.length === 0) {
128
+ return { content: [{ type: "text", text: `No snippet found for ${repo}::${file}${entity ? `::${entity}` : ""}` }] };
129
+ }
130
+ matches.sort((a, b) => (a.startLine ?? 0) - (b.startLine ?? 0));
131
+ const text = matches.map(
132
+ (c) => `### ${c.repoName}/${c.filePath}:${c.startLine}-${c.endLine}${c.entityName ? ` \u2014 ${c.entityName}` : ""}
133
+ \`\`\`${c.language ?? ""}
134
+ ${c.content}
135
+ \`\`\``
136
+ ).join("\n\n");
137
+ return { content: [{ type: "text", text }] };
138
+ } catch (err) {
139
+ const msg = err instanceof Error ? err.message : String(err);
140
+ return { content: [{ type: "text", text: `Failed to read chunks: ${msg}` }] };
141
+ }
142
+ }
81
143
  export {
82
144
  handleSearchTool,
83
145
  registerSearchTools
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esankhan3/code-search-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for multi-repo code search — GitHub org or local path, zero config",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,8 +23,8 @@
23
23
  "smoke": "bash scripts/smoke/run-smoke.sh"
24
24
  },
25
25
  "dependencies": {
26
- "@esankhan3/anvil-agent-core": "^0.2.0",
27
- "@esankhan3/anvil-knowledge-core": "^0.2.0",
26
+ "@esankhan3/anvil-agent-core": "^0.4.0",
27
+ "@esankhan3/anvil-knowledge-core": "^0.3.1",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0"
29
29
  },
30
30
  "optionalDependencies": {