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.
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/dist/config.js +223 -0
- package/dist/git/analyzer.js +177 -0
- package/dist/git/git-service.js +568 -0
- package/dist/git/head-watcher.js +113 -0
- package/dist/git/runner.js +204 -0
- package/dist/index.js +138 -0
- package/dist/indexer/code-index.js +1801 -0
- package/dist/indexer/complexity.js +633 -0
- package/dist/indexer/extractor.js +354 -0
- package/dist/indexer/languages/cpp.js +934 -0
- package/dist/indexer/languages/csharp.js +854 -0
- package/dist/indexer/languages/dart.js +777 -0
- package/dist/indexer/languages/go.js +665 -0
- package/dist/indexer/languages/java.js +507 -0
- package/dist/indexer/languages/kotlin.js +709 -0
- package/dist/indexer/languages/objc.js +397 -0
- package/dist/indexer/languages/php.js +771 -0
- package/dist/indexer/languages/python.js +455 -0
- package/dist/indexer/languages/ruby.js +697 -0
- package/dist/indexer/languages/rust.js +754 -0
- package/dist/indexer/languages/swift.js +691 -0
- package/dist/indexer/languages/typescript.js +485 -0
- package/dist/indexer/parser.js +175 -0
- package/dist/indexer/pipeline.js +342 -0
- package/dist/indexer/scanner.js +279 -0
- package/dist/indexer/watcher.js +353 -0
- package/dist/logger.js +16 -0
- package/dist/server.js +170 -0
- package/dist/tools/common.js +207 -0
- package/dist/tools/find-references.js +224 -0
- package/dist/tools/find-symbol.js +94 -0
- package/dist/tools/get-context.js +370 -0
- package/dist/tools/impact.js +218 -0
- package/dist/tools/overview.js +482 -0
- package/dist/tools/search-structure.js +303 -0
- package/dist/types.js +61 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-dart.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-objc.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- 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
|
+
}
|