codedeep-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/dist/config.js +223 -0
  4. package/dist/git/analyzer.js +177 -0
  5. package/dist/git/git-service.js +568 -0
  6. package/dist/git/head-watcher.js +113 -0
  7. package/dist/git/runner.js +204 -0
  8. package/dist/index.js +138 -0
  9. package/dist/indexer/code-index.js +1801 -0
  10. package/dist/indexer/complexity.js +633 -0
  11. package/dist/indexer/extractor.js +354 -0
  12. package/dist/indexer/languages/cpp.js +934 -0
  13. package/dist/indexer/languages/csharp.js +854 -0
  14. package/dist/indexer/languages/dart.js +777 -0
  15. package/dist/indexer/languages/go.js +665 -0
  16. package/dist/indexer/languages/java.js +507 -0
  17. package/dist/indexer/languages/kotlin.js +709 -0
  18. package/dist/indexer/languages/objc.js +397 -0
  19. package/dist/indexer/languages/php.js +771 -0
  20. package/dist/indexer/languages/python.js +455 -0
  21. package/dist/indexer/languages/ruby.js +697 -0
  22. package/dist/indexer/languages/rust.js +754 -0
  23. package/dist/indexer/languages/swift.js +691 -0
  24. package/dist/indexer/languages/typescript.js +485 -0
  25. package/dist/indexer/parser.js +175 -0
  26. package/dist/indexer/pipeline.js +342 -0
  27. package/dist/indexer/scanner.js +279 -0
  28. package/dist/indexer/watcher.js +353 -0
  29. package/dist/logger.js +16 -0
  30. package/dist/server.js +170 -0
  31. package/dist/tools/common.js +207 -0
  32. package/dist/tools/find-references.js +224 -0
  33. package/dist/tools/find-symbol.js +94 -0
  34. package/dist/tools/get-context.js +370 -0
  35. package/dist/tools/impact.js +218 -0
  36. package/dist/tools/overview.js +482 -0
  37. package/dist/tools/search-structure.js +303 -0
  38. package/dist/types.js +61 -0
  39. package/grammars/tree-sitter-c.wasm +0 -0
  40. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  41. package/grammars/tree-sitter-cpp.wasm +0 -0
  42. package/grammars/tree-sitter-dart.wasm +0 -0
  43. package/grammars/tree-sitter-go.wasm +0 -0
  44. package/grammars/tree-sitter-java.wasm +0 -0
  45. package/grammars/tree-sitter-javascript.wasm +0 -0
  46. package/grammars/tree-sitter-kotlin.wasm +0 -0
  47. package/grammars/tree-sitter-objc.wasm +0 -0
  48. package/grammars/tree-sitter-php.wasm +0 -0
  49. package/grammars/tree-sitter-python.wasm +0 -0
  50. package/grammars/tree-sitter-ruby.wasm +0 -0
  51. package/grammars/tree-sitter-rust.wasm +0 -0
  52. package/grammars/tree-sitter-swift.wasm +0 -0
  53. package/grammars/tree-sitter-tsx.wasm +0 -0
  54. package/grammars/tree-sitter-typescript.wasm +0 -0
  55. package/package.json +67 -0
@@ -0,0 +1,353 @@
1
+ // Debounced fs.watch wrapper driving live incremental re-indexing.
2
+ //
3
+ // Platform notes (fs.watch recursive): native on macOS (FSEvents) and
4
+ // Windows (ReadDirectoryChangesW); on Linux, Node >= 20 emulates it with
5
+ // one inotify watch per directory, so very large trees can exhaust
6
+ // fs.inotify.max_user_watches — that surfaces as an 'error' event, which
7
+ // disables the watcher while the server keeps serving from the existing
8
+ // index (indexChanged heals on next start). The `watchFactory` seam is
9
+ // the swap point for a chokidar backend if that ever bites in practice.
10
+ //
11
+ // Persistence: the design notes suggested a 5-minute save timer; this saves once
12
+ // per debounced flush instead — data loss is bounded by one debounce
13
+ // window rather than five minutes, there is no extra keep-alive timer to
14
+ // manage, and saves are event-driven (no disk writes when idle).
15
+ // CodeIndex.save is mutexed and atomic, so per-flush saves are safe.
16
+ import { realpathSync, watch as fsWatch } from 'node:fs';
17
+ import { lstat } from 'node:fs/promises';
18
+ import { join } from 'node:path';
19
+ import { errMsg, log } from '../logger.js';
20
+ import { compileExcludeMatcher, isBinaryByExtension, toPosix } from './scanner.js';
21
+ // On Windows, fs.watch (ReadDirectoryChangesW) aborts the whole PROCESS with a
22
+ // libuv assertion (`!_wcsnicmp(filename, dir, dirlen)`, src/win/fs-event.c)
23
+ // when the watched directory is opened through a non-canonical path — most
24
+ // commonly an 8.3 short name like C:\Users\ADMINI~1\... that os.tmpdir() can
25
+ // hand back, but any case/junction-spelling mismatch trips it: libuv compares
26
+ // the kernel-reported filename against the watched-dir prefix and asserts on
27
+ // the difference. Resolving to the real on-disk path first keeps the two
28
+ // consistent. POSIX has no such bug, so leave its paths (and symlinks) alone.
29
+ export function canonicalWatchPath(dir) {
30
+ if (process.platform !== 'win32')
31
+ return dir;
32
+ try {
33
+ return realpathSync.native(dir);
34
+ }
35
+ catch (err) {
36
+ // MUST NOT fall back to the raw `dir` here: if it is an 8.3 short name
37
+ // (or case/junction mismatch) and is still PRESENT — which a transient
38
+ // realpath failure like EMFILE/EACCES leaves it — fs.watch SUCCEEDS, then
39
+ // aborts the whole process via the libuv `!_wcsnicmp` assertion on the
40
+ // first event. That abort bypasses the start() try/catch (it is not a JS
41
+ // throw) and kills the stdio server. Throwing instead lets start() degrade
42
+ // to "no live updates", which is strictly better than a process abort.
43
+ // (When the dir is genuinely gone, fs.watch would have failed catchably
44
+ // anyway — so nothing is lost in that case either.)
45
+ throw new Error(`cannot canonicalize watch path ${dir}: ${errMsg(err)}`);
46
+ }
47
+ }
48
+ const defaultWatchFactory = (root, onEvent, onError) => {
49
+ const w = fsWatch(canonicalWatchPath(root), { recursive: true });
50
+ w.on('change', onEvent);
51
+ w.on('error', onError);
52
+ // The stdio transport governs process lifetime. Without unref, the
53
+ // watcher's libuv handle would keep the process alive forever after
54
+ // the MCP client closes stdin.
55
+ w.unref();
56
+ return w;
57
+ };
58
+ const DEFAULT_DEBOUNCE_MS = 100;
59
+ const DEFAULT_RETRY_MS = 250;
60
+ const DEFAULT_MAX_FLUSH_DELAY_MS = 1000;
61
+ // Each incomplete rescan costs a full scanProject walk; bound the retries
62
+ // so a permanently unreadable subdirectory degrades gracefully.
63
+ const MAX_INCOMPLETE_RESCANS = 5;
64
+ export class Watcher {
65
+ indexer;
66
+ index;
67
+ config;
68
+ matchExclude;
69
+ debounceMs;
70
+ retryMs;
71
+ maxFlushDelayMs;
72
+ watchFactory;
73
+ // Canonical project-relative POSIX paths awaiting a flush.
74
+ pending = new Set();
75
+ rescanPending = false;
76
+ // Consecutive rescans that completed but saw a PARTIAL scan (transient
77
+ // readdir failures). Bounded so a permanently unreadable subdirectory
78
+ // can't turn the retry tick into a full-scan-every-250ms loop.
79
+ incompleteRescans = 0;
80
+ // Wall-clock bound for the current accumulation window (max-wait).
81
+ flushDeadline = null;
82
+ timer = null;
83
+ flushPromise = Promise.resolve();
84
+ backend = null;
85
+ closed = false;
86
+ constructor(indexer, index, config, options = {}) {
87
+ this.indexer = indexer;
88
+ this.index = index;
89
+ this.config = config;
90
+ this.matchExclude = compileExcludeMatcher(config.exclude);
91
+ this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
92
+ this.retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
93
+ this.maxFlushDelayMs = options.maxFlushDelayMs ?? DEFAULT_MAX_FLUSH_DELAY_MS;
94
+ this.watchFactory = options.watchFactory ?? defaultWatchFactory;
95
+ }
96
+ // Never throws: a watcher failure must degrade to "no live updates",
97
+ // not crash the server.
98
+ start() {
99
+ if (this.backend || this.closed)
100
+ return;
101
+ try {
102
+ this.backend = this.watchFactory(this.config.projectRoot, (eventType, filename) => {
103
+ try {
104
+ this.handleEvent(eventType, filename);
105
+ }
106
+ catch (err) {
107
+ log.warn(`watcher: event handling failed: ${errMsg(err)}`);
108
+ }
109
+ }, (err) => {
110
+ // e.g. Linux inotify watch exhaustion (ENOSPC).
111
+ log.warn(`watcher: backend error (${errMsg(err)}); live re-indexing disabled`);
112
+ this.backend?.close();
113
+ this.backend = null;
114
+ });
115
+ log.debug(`watcher: watching ${this.config.projectRoot} (recursive)`);
116
+ }
117
+ catch (err) {
118
+ log.warn(`watcher: fs.watch unavailable (${errMsg(err)}); live re-indexing disabled`);
119
+ this.backend = null;
120
+ }
121
+ }
122
+ // Stops the event source, waits for any in-flight drain, then runs one
123
+ // FINAL drain of whatever is still pending so the last debounce batch is
124
+ // not discarded (it saves through the normal per-flush path, so close()
125
+ // leaves the on-disk cache current). Idempotent: a concurrent second
126
+ // close() awaits the same final drain rather than resolving early.
127
+ async close() {
128
+ if (this.closed)
129
+ return this.settle();
130
+ this.closed = true;
131
+ if (this.timer) {
132
+ clearTimeout(this.timer);
133
+ this.timer = null;
134
+ }
135
+ this.backend?.close();
136
+ this.backend = null;
137
+ // Chaining serializes after any in-flight drain; a final drain over
138
+ // an empty state is a no-op, so no pre-check is needed.
139
+ this.flush(true);
140
+ await this.settle();
141
+ }
142
+ // Awaits the current flush chain (drains already started or chained;
143
+ // NOT timers still pending). Used by close() and tests.
144
+ async settle() {
145
+ await this.flushPromise;
146
+ }
147
+ // Public so unit tests can drive events without a real fs.watch.
148
+ // Must stay cheap and exception-free — it runs on every OS event.
149
+ handleEvent(eventType, filename) {
150
+ if (this.closed)
151
+ return;
152
+ const name = typeof filename === 'string'
153
+ ? filename
154
+ : filename instanceof Buffer
155
+ ? filename.toString('utf8')
156
+ : null;
157
+ if (name === null || name === '') {
158
+ // Some platforms emit events without a filename; the only safe
159
+ // recovery is a full incremental rescan.
160
+ this.rescanPending = true;
161
+ this.scheduleDebounced();
162
+ return;
163
+ }
164
+ const rel = toPosix(name);
165
+ // Pre-debounce storm filter (node_modules churn, binary assets).
166
+ // indexFile re-checks everything; this only keeps the pending set
167
+ // small. Unknown-language files are NOT filtered — they are
168
+ // legitimately indexed for overview's "Other files" count.
169
+ if (this.matchExclude(rel) || isBinaryByExtension(rel))
170
+ return;
171
+ // 'rename' and 'change' are handled identically: indexFile stats the
172
+ // path and treats a missing file as a deletion, which also covers
173
+ // renames (one event per old/new name).
174
+ this.pending.add(rel);
175
+ this.scheduleDebounced();
176
+ }
177
+ // Trailing debounce capped by the max-wait deadline: each event pushes
178
+ // the flush out by debounceMs, but never past maxFlushDelayMs after the
179
+ // window's first event.
180
+ scheduleDebounced() {
181
+ this.flushDeadline ??= Date.now() + this.maxFlushDelayMs;
182
+ const remaining = Math.max(0, this.flushDeadline - Date.now());
183
+ this.schedule(Math.min(this.debounceMs, remaining));
184
+ }
185
+ schedule(delayMs) {
186
+ if (this.closed)
187
+ return;
188
+ if (this.timer)
189
+ clearTimeout(this.timer);
190
+ this.timer = setTimeout(() => {
191
+ this.timer = null;
192
+ this.flush();
193
+ }, delayMs);
194
+ this.timer.unref?.();
195
+ }
196
+ // Chains drains on flushPromise so they never overlap — watcher updates
197
+ // are processed sequentially, and close() can await the tail.
198
+ flush(final = false) {
199
+ this.flushPromise = this.flushPromise
200
+ .then(() => this.drain(final))
201
+ .catch((err) => {
202
+ log.error(`watcher: flush failed: ${errMsg(err)}`);
203
+ });
204
+ }
205
+ // `final` is the close()-time last pass: it ignores the closed flag,
206
+ // never re-arms timers, and skips the rescan (indexChanged heals on
207
+ // next start) in favor of flushing the per-file batch.
208
+ async drain(final = false) {
209
+ if (this.closed && !final)
210
+ return;
211
+ // Until the startup indexAll/indexChanged completes, indexFile would
212
+ // race the cache load (updates discarded by the load's Map swap, and
213
+ // a premature save could clobber the populated on-disk cache) or be
214
+ // dropped by the run guard — defer until the index is ready.
215
+ if (this.indexer.isIndexing || !this.indexer.ready) {
216
+ // Reset the max-wait window: nothing can flush while deferred, and
217
+ // an expired deadline would turn every incoming event into an
218
+ // immediate schedule(0), clobbering the retryMs backoff with
219
+ // per-event timer churn for the rest of the busy period.
220
+ this.flushDeadline = null;
221
+ if (!final)
222
+ this.schedule(this.retryMs);
223
+ return;
224
+ }
225
+ // Snapshot: events arriving mid-drain re-enter pending and re-arm
226
+ // the timer via handleEvent.
227
+ const batch = [...this.pending];
228
+ this.pending.clear();
229
+ const rescan = this.rescanPending;
230
+ this.rescanPending = false;
231
+ this.flushDeadline = null;
232
+ if (rescan && !final) {
233
+ // The rescan covers the batched paths too — but only when its scan
234
+ // completes, which a successful indexChanged does not guarantee
235
+ // (a transiently unreadable directory yields a partial scan).
236
+ // Re-queue the batch: paths the rescan DID cover become hash
237
+ // no-ops in indexFile, while edits a partial scan missed stay
238
+ // queued instead of being silently lost. A partial scan also
239
+ // re-arms the rescan itself (bounded retries) — its trigger may
240
+ // have had no batch representation (directory deletions).
241
+ try {
242
+ const ran = await this.indexer.indexChanged();
243
+ if (!ran) {
244
+ this.rescanPending = true;
245
+ }
246
+ else if (!this.indexer.lastScanComplete) {
247
+ this.incompleteRescans++;
248
+ if (this.incompleteRescans <= MAX_INCOMPLETE_RESCANS) {
249
+ this.rescanPending = true;
250
+ }
251
+ else {
252
+ log.error(`watcher: rescan saw ${this.incompleteRescans} consecutive partial scans; giving up until the next event (exclude the unreadable directory to silence this)`);
253
+ this.incompleteRescans = 0;
254
+ }
255
+ }
256
+ else {
257
+ this.incompleteRescans = 0;
258
+ }
259
+ }
260
+ catch (err) {
261
+ // Restore the flag so the retry tick heals a transient failure.
262
+ this.rescanPending = true;
263
+ log.error(`watcher: rescan failed: ${errMsg(err)}`);
264
+ }
265
+ for (const rel of batch)
266
+ this.pending.add(rel);
267
+ this.rearmIfNeeded();
268
+ return;
269
+ }
270
+ // Stat phase: classify so deletions process before creations — at
271
+ // the maxFiles cap, a rename's delete must free its slot before the
272
+ // create claims one. Stats are independent; issue them concurrently
273
+ // (threadpool-capped) so large batches on slow filesystems don't
274
+ // serialize the flush.
275
+ const stats = await Promise.all(batch.map((rel) => lstat(join(this.config.projectRoot, rel)).catch(() => null)));
276
+ const deleted = [];
277
+ const present = [];
278
+ for (let i = 0; i < batch.length; i++) {
279
+ if (stats[i] === null)
280
+ deleted.push(batch[i]);
281
+ else if (!stats[i].isDirectory())
282
+ present.push(batch[i]);
283
+ // A created/moved-in directory emits one event — its children may
284
+ // emit none. Only a rescan finds them.
285
+ else
286
+ this.rescanPending = true;
287
+ }
288
+ if (!this.rescanPending) {
289
+ // Deleted/moved-out directory: fs.watch gives no per-child events,
290
+ // so indexed children need a rescan to prune — UNLESS this very
291
+ // batch already carries every child's deletion (the common case:
292
+ // per-child events land in the same window as the dir's), in
293
+ // which case the per-file deletions below fully cover it.
294
+ const deletedSet = new Set(deleted);
295
+ for (const rel of deleted) {
296
+ const children = this.index.filesUnder(`${rel}/`);
297
+ if (children.length > 0 && children.some((c) => !deletedSet.has(c))) {
298
+ this.rescanPending = true;
299
+ break;
300
+ }
301
+ }
302
+ }
303
+ let mutated = false;
304
+ let freedSlot = false;
305
+ const capSkipped = [];
306
+ for (const rel of [...deleted, ...present]) {
307
+ if (this.closed && !final) {
308
+ // close() mid-drain: keep the remainder queued for the final
309
+ // drain that close() runs after this one settles.
310
+ this.pending.add(rel);
311
+ continue;
312
+ }
313
+ try {
314
+ const outcome = await this.indexer.indexFile(rel);
315
+ if (outcome === 'indexed' || outcome === 'removed')
316
+ mutated = true;
317
+ if (outcome === 'removed')
318
+ freedSlot = true;
319
+ else if (outcome === 'dropped')
320
+ this.pending.add(rel); // guard drop — retry
321
+ else if (outcome === 'cap-skipped')
322
+ capSkipped.push(rel);
323
+ }
324
+ catch (err) {
325
+ log.warn(`watcher: failed to index ${rel}: ${errMsg(err)}`);
326
+ }
327
+ }
328
+ // Cap-skipped creations get ONE retry when this same batch freed a
329
+ // slot (rename-at-cap whose delete was racing behind the create) —
330
+ // re-queueing unconditionally would retry forever while at the cap.
331
+ if (freedSlot) {
332
+ for (const rel of capSkipped)
333
+ this.pending.add(rel);
334
+ }
335
+ if (mutated) {
336
+ try {
337
+ await this.index.save(this.indexer.cachePath);
338
+ log.debug(`watcher: saved cache after flush (${batch.length} paths)`);
339
+ }
340
+ catch (err) {
341
+ log.error(`watcher: failed to save cache: ${errMsg(err)}`);
342
+ }
343
+ }
344
+ if (!final)
345
+ this.rearmIfNeeded();
346
+ }
347
+ rearmIfNeeded() {
348
+ // schedule() no-ops once closed; no second lifecycle guard here.
349
+ if (this.pending.size > 0 || this.rescanPending) {
350
+ this.schedule(this.retryMs);
351
+ }
352
+ }
353
+ }
package/dist/logger.js ADDED
@@ -0,0 +1,16 @@
1
+ // stderr-only logging. console.log would corrupt stdio JSON-RPC.
2
+ const DEBUG_ENABLED = process.env.CODEDEEP_DEBUG === '1';
3
+ function write(level, msg) {
4
+ process.stderr.write(`[codedeep-mcp ${level}] ${msg}\n`);
5
+ }
6
+ export const log = {
7
+ error: (msg) => write('error', msg),
8
+ warn: (msg) => write('warn', msg),
9
+ debug: (msg) => {
10
+ if (DEBUG_ENABLED)
11
+ write('debug', msg);
12
+ },
13
+ };
14
+ export function errMsg(err) {
15
+ return err?.message ?? String(err);
16
+ }
package/dist/server.js ADDED
@@ -0,0 +1,170 @@
1
+ // SDK v1.29 takes `inputSchema` as a RAW Zod shape ({ k: z.string() }), not
2
+ // `z.object({...})`. The v2 alpha uses the wrapped form — do not confuse them.
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { z } from "zod";
5
+ import { runFindReferences } from "./tools/find-references.js";
6
+ import { runFindSymbol } from "./tools/find-symbol.js";
7
+ import { runGetContext } from "./tools/get-context.js";
8
+ import { runImpact } from "./tools/impact.js";
9
+ import { runOverview } from "./tools/overview.js";
10
+ import { runSearchStructure } from "./tools/search-structure.js";
11
+ const SHARED_ANNOTATIONS = {
12
+ readOnlyHint: true,
13
+ destructiveHint: false,
14
+ idempotentHint: true,
15
+ openWorldHint: false,
16
+ };
17
+ export function createServer(deps) {
18
+ const server = new McpServer({
19
+ name: "codedeep-mcp",
20
+ version: "0.1.0",
21
+ });
22
+ server.registerTool("overview", {
23
+ description: "Get a structural overview of the codebase: language breakdown, top-level directories, entry points, and symbol counts — plus branch summary, git hotspots, and risk hotspots (churn × call-graph coupling) when in a git repo.",
24
+ inputSchema: {
25
+ path: z.string().optional().describe("Project root (default: cwd)"),
26
+ },
27
+ annotations: SHARED_ANNOTATIONS,
28
+ }, async (args) => runOverview(args, deps));
29
+ server.registerTool("find_symbol", {
30
+ description: "AST-aware symbol lookup. Returns definitions matching a name (exact, prefix, or fuzzy), each with fan-in (references), fan-out (callees), and complexity — cyclomatic and cognitive, available for all 14 supported languages. Optional kind/scope/limit filters.",
31
+ inputSchema: {
32
+ name: z.string().describe("Symbol name (exact, prefix, or fuzzy)"),
33
+ kind: z
34
+ .enum([
35
+ "function",
36
+ "class",
37
+ "interface",
38
+ "type",
39
+ "variable",
40
+ "method",
41
+ "module",
42
+ "enum",
43
+ ])
44
+ .optional()
45
+ .describe("Filter by symbol kind"),
46
+ scope: z
47
+ .string()
48
+ .optional()
49
+ .describe("File path prefix to narrow search (e.g., 'src/auth/')"),
50
+ limit: z
51
+ .number()
52
+ .int()
53
+ .positive()
54
+ .optional()
55
+ .describe("Max results (default: 10)"),
56
+ },
57
+ annotations: SHARED_ANNOTATIONS,
58
+ }, async (args) => runFindSymbol(args, deps));
59
+ server.registerTool("get_context", {
60
+ description: "Return everything needed to understand a symbol: full body, within-file callers/callees, coupling (fan-in/fan-out/cyclomatic+cognitive complexity/blast radius), and imports — plus co-change partners and recent commits when git is available.",
61
+ inputSchema: {
62
+ file: z.string().describe("File path (relative to project root)"),
63
+ symbol: z
64
+ .string()
65
+ .optional()
66
+ .describe("Symbol name within the file (omit for file-level context)"),
67
+ line: z
68
+ .number()
69
+ .int()
70
+ .optional()
71
+ .describe("Disambiguate when multiple symbols share a name"),
72
+ max_tokens: z
73
+ .number()
74
+ .int()
75
+ .positive()
76
+ .optional()
77
+ .describe("Soft response budget (default: 3000)"),
78
+ include: z
79
+ .array(z.string())
80
+ .optional()
81
+ .describe("Sections to include: body, callers, callees, coupling, imports, co_changes, git"),
82
+ },
83
+ annotations: SHARED_ANNOTATIONS,
84
+ }, async (args) => runGetContext(args, deps));
85
+ server.registerTool("find_references", {
86
+ description: "Cross-file usage navigation. Returns approximate AST name-matched callers for a symbol, ranked by directory and import proximity — plus co-change partners from git history when available. LSP-precise tiers ship in Phase 2.",
87
+ inputSchema: {
88
+ file: z.string().describe("File containing the symbol (relative to project root)"),
89
+ symbol: z.string().describe("Symbol name"),
90
+ line: z
91
+ .number()
92
+ .int()
93
+ .optional()
94
+ .describe("Disambiguate when multiple symbols share a name"),
95
+ kind: z
96
+ .enum([
97
+ "callers",
98
+ "callees",
99
+ "implementations",
100
+ "type_references",
101
+ "all",
102
+ ])
103
+ .optional()
104
+ .describe("Result kind (default: 'all')"),
105
+ limit: z
106
+ .number()
107
+ .int()
108
+ .positive()
109
+ .optional()
110
+ .describe("Max results per section (default: 20, max: 100)"),
111
+ },
112
+ annotations: SHARED_ANNOTATIONS,
113
+ }, async (args) => runFindReferences(args, deps));
114
+ server.registerTool("impact", {
115
+ description: "Trace the transitive blast radius of changing a symbol: upstream callers grouped by hop (depth 1, 2, …), with co-change partners from git history. Edges are AST name-matches, not compiler-verified; downstream callees and inheritance ship with LSP in Phase 2.",
116
+ inputSchema: {
117
+ file: z
118
+ .string()
119
+ .describe("File containing the symbol (relative to project root)"),
120
+ symbol: z.string().describe("Symbol name"),
121
+ line: z
122
+ .number()
123
+ .int()
124
+ .optional()
125
+ .describe("Disambiguate when multiple symbols share a name"),
126
+ depth: z
127
+ .number()
128
+ .int()
129
+ .positive()
130
+ .optional()
131
+ .describe("Caller hops to trace upstream (default: 3, max: 5)"),
132
+ max_tokens: z
133
+ .number()
134
+ .int()
135
+ .positive()
136
+ .optional()
137
+ .describe("Soft response budget (default: 3000); deeper hops drop first"),
138
+ include_weak: z
139
+ .boolean()
140
+ .optional()
141
+ .describe("Expand weak edges — unresolved member calls (obj.method()) and low-confidence deep chains; noisier. Default: false"),
142
+ },
143
+ annotations: SHARED_ANNOTATIONS,
144
+ }, async (args) => runImpact(args, deps));
145
+ server.registerTool("search_structure", {
146
+ description: "Keyword and structural code search. Fuzzy-matches symbol names, signatures, and docstrings; with `pattern`, runs an ast-grep structural query instead (TypeScript/TSX/JavaScript only for now).",
147
+ inputSchema: {
148
+ query: z
149
+ .string()
150
+ .optional()
151
+ .describe("Keywords matched fuzzily against symbol names, signatures, and docstrings (required unless `pattern` is set)"),
152
+ pattern: z
153
+ .string()
154
+ .optional()
155
+ .describe("ast-grep pattern, e.g. 'app.use($HANDLER)'. Takes precedence over `query`. TS/TSX/JS only."),
156
+ language: z
157
+ .string()
158
+ .optional()
159
+ .describe("Filter to one language: typescript, tsx, javascript, python, java, go, rust, swift, kotlin, dart, csharp, php, ruby, cpp, c, objc"),
160
+ limit: z
161
+ .number()
162
+ .int()
163
+ .positive()
164
+ .optional()
165
+ .describe("Max results (default: 10, max: 100)"),
166
+ },
167
+ annotations: SHARED_ANNOTATIONS,
168
+ }, async (args) => runSearchStructure(args, deps));
169
+ return server;
170
+ }