@maxgfr/codeindex 2.18.0 → 2.19.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/src/mcp.ts CHANGED
@@ -9,17 +9,11 @@
9
9
  // (NOT `node scripts/engine.mjs mcp`: engine.mjs is a side-effect-free library
10
10
  // with no main-module guard — see src/engine.ts — so that command does nothing.
11
11
  // The entrypoint is the `codeindex` bin, i.e. scripts/cli.mjs.)
12
- import { existsSync, readFileSync, statSync } from "node:fs";
12
+ import { readFileSync } from "node:fs";
13
13
  import { isAbsolute, join } from "node:path";
14
- import { pathToFileURL } from "node:url";
15
14
  import { createInterface } from "node:readline";
16
15
  import { ENGINE_VERSION } from "./types.js";
17
- import { ensureGrammars, grammarKeysForExts } from "./ast/loader.js";
18
- import { buildArtifactsFromScan, type IndexArtifacts } from "./pipeline.js";
19
16
  import { renderGraphJson } from "./render/graph-json.js";
20
- import { scanRepo, scanSummary, type RepoScan, type ScanOptions, type ScanSummary } from "./scan.js";
21
- import { preloadSession, toCacheMap, INDEX_DIR, type PersistedCacheEntry, type PersistedCacheMap } from "./preload.js";
22
- import { walk, type WalkResult } from "./walk.js";
23
17
  import { buildCallerIndex } from "./callers.js";
24
18
  import { callerIndexFor } from "./derived.js";
25
19
  import { detectWorkspaces } from "./workspaces.js";
@@ -35,11 +29,59 @@ import { replaceSymbolBody, insertAfterSymbol, insertBeforeSymbol } from "./edit
35
29
  import { writeMemory, readMemory, deleteMemory, listMemories } from "./memory.js";
36
30
  import { searchIndex } from "./bm25.js";
37
31
  import { checkRules, parseRules } from "./rules.js";
38
- import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel, type StaticEmbedModel } from "./embed/model.js";
39
- import { buildEmbeddingIndex, type EmbeddingIndex } from "./embed/index.js";
32
+ import { EMBED_VERSION, resolveEmbedModelDir } from "./embed/model.js";
33
+ import { buildEmbeddingIndex } from "./embed/index.js";
40
34
  import { searchSemantic } from "./embed/search.js";
41
35
  import { resolveEmbedEndpoint, buildEndpointIndex, encodeQueryViaEndpoint, probeEndpoint } from "./embed/endpoint.js";
42
- import { sha1 } from "./hash.js";
36
+ import { walk, type WalkResult } from "./walk.js";
37
+ import { toolsFor, OUTPUT_SCHEMAS } from "./mcp/tools.js";
38
+ import {
39
+ DEFAULT_MAX_RESPONSE_BYTES,
40
+ RICH_TOOLS_SINCE,
41
+ PROTOCOL_VERSIONS,
42
+ capResponse,
43
+ negotiateProtocol,
44
+ resourceLinkFor,
45
+ structuredContentFor,
46
+ validateArgs,
47
+ } from "./mcp/protocol.js";
48
+ import {
49
+ getArtifacts,
50
+ getScan,
51
+ getScanSummary,
52
+ memoizedEmbeddingIndex,
53
+ memoizedEmbedModel,
54
+ scanFingerprint,
55
+ sessionClear,
56
+ warmGrammarsForWalk,
57
+ type SessionScanOptions,
58
+ } from "./mcp/session.js";
59
+
60
+ // The public surface of this module is unchanged: everything that used to live
61
+ // here is re-exported, so `src/engine.ts`, the tests and any consumer importing
62
+ // from "./mcp.js" keep working exactly as before.
63
+ export { toolsFor, TOOLS, TOOL_META, OUTPUT_SCHEMAS, annotationsFor } from "./mcp/tools.js";
64
+ export {
65
+ DEFAULT_MAX_RESPONSE_BYTES,
66
+ PROTOCOL_VERSIONS,
67
+ capResponse,
68
+ negotiateProtocol,
69
+ resourceLinkFor,
70
+ structuredContentFor,
71
+ validateArgs,
72
+ } from "./mcp/protocol.js";
73
+ export {
74
+ getArtifacts,
75
+ getScan,
76
+ getScanSummary,
77
+ memoizedEmbeddingIndex,
78
+ memoizedEmbedModel,
79
+ scanFingerprint,
80
+ toCacheMap,
81
+ warmGrammarsForRepo,
82
+ warmGrammarsForWalk,
83
+ } from "./mcp/session.js";
84
+ export type { EmbeddingIndexCacheKey, SessionScanOptions } from "./mcp/session.js";
43
85
 
44
86
  interface RpcRequest {
45
87
  jsonrpc: "2.0";
@@ -48,306 +90,6 @@ interface RpcRequest {
48
90
  params?: Record<string, unknown>;
49
91
  }
50
92
 
51
- const repoProp = { repo: { type: "string", description: "Absolute path to the repository root" } };
52
- const scopeProps = {
53
- scope: { type: "string", description: "Restrict to one directory (repo-relative)" },
54
- include: { type: "array", items: { type: "string" }, description: "Include globs" },
55
- exclude: { type: "array", items: { type: "string" }, description: "Exclude globs" },
56
- };
57
-
58
- const TOOLS = [
59
- {
60
- name: "scan_summary",
61
- description:
62
- "Deterministically scan a repository: file count, per-language file histogram, HEAD commit, and whether the walk was capped. Fast first look at any codebase.",
63
- inputSchema: { type: "object", properties: { ...repoProp, ...scopeProps }, required: ["repo"] },
64
- },
65
- {
66
- name: "graph",
67
- description:
68
- "Build the full typed cross-file link-graph (import/call/use/doc-link/mention edges, module grouping, PageRank centrality, Louvain communities, tests-map). Returns graph.json. Large on big repos — prefer scan_summary/symbols/callers for targeted questions.",
69
- inputSchema: { type: "object", properties: { ...repoProp, ...scopeProps }, required: ["repo"] },
70
- },
71
- {
72
- name: "symbols",
73
- description:
74
- "Where is a symbol defined and which files reference it? Returns the definition sites (file, line, kind, exported) and referencing files. Omit `name` for the full symbol index.",
75
- inputSchema: {
76
- type: "object",
77
- properties: { ...repoProp, name: { type: "string", description: "Symbol name to look up" } },
78
- required: ["repo"],
79
- },
80
- },
81
- {
82
- name: "callers",
83
- description:
84
- "Who calls a function? Per-symbol caller index: each defined symbol with the exact (file, line) call sites that bind to it. Omit `name` for the full index.",
85
- inputSchema: {
86
- type: "object",
87
- properties: {
88
- ...repoProp,
89
- name: { type: "string", description: "Symbol name to look up" },
90
- recall: {
91
- type: "boolean",
92
- description:
93
- "Recall-oriented binding: relax the JS/TS import gate to unique repo-wide names, labelling each site corroborated|unique-name (default false = precision)",
94
- },
95
- },
96
- required: ["repo"],
97
- },
98
- },
99
- {
100
- name: "workspaces",
101
- description:
102
- "Detect monorepo packages (npm/pnpm/yarn/lerna/nx/cargo/go.work/maven) with the workspace dependency graph, one cycle if present, and a topological build order.",
103
- inputSchema: { type: "object", properties: { ...repoProp }, required: ["repo"] },
104
- },
105
- {
106
- name: "churn",
107
- description: "Per-file git commit counts (whole history, or since a ref) — the churn half of hotspot analysis.",
108
- inputSchema: {
109
- type: "object",
110
- properties: { ...repoProp, since: { type: "string", description: "Only count commits after this ref" } },
111
- required: ["repo"],
112
- },
113
- },
114
- {
115
- name: "symbols_overview",
116
- description:
117
- "All symbols declared in ONE file (name, kind, line span, exported, parent), in declaration order — the fastest way to understand a file without reading it.",
118
- inputSchema: {
119
- type: "object",
120
- properties: { ...repoProp, file: { type: "string", description: "Repo-relative file path" } },
121
- required: ["repo", "file"],
122
- },
123
- },
124
- {
125
- name: "find_symbol",
126
- description:
127
- "Find symbol declarations by name or name path ('Class/method' matches a method inside Class). Options: substring matching, includeBody to return the declaration's source. Exact-name matches rank first.",
128
- inputSchema: {
129
- type: "object",
130
- properties: {
131
- ...repoProp,
132
- namePath: { type: "string", description: "Symbol name or Parent/child path" },
133
- substring: { type: "boolean" },
134
- includeBody: { type: "boolean" },
135
- maxResults: { type: "number", description: "Cap matches (default 50)" },
136
- },
137
- required: ["repo", "namePath"],
138
- },
139
- },
140
- {
141
- name: "find_references",
142
- description:
143
- "Who references a symbol? Three labeled tiers: defs (declarations), callSites (line-precise, import-corroborated call bindings), referencingFiles (file-level identifier/doc mentions — may include homonyms). Confidence decreases across tiers; the labels let you decide what to trust.",
144
- inputSchema: {
145
- type: "object",
146
- properties: { ...repoProp, name: { type: "string", description: "Symbol name" } },
147
- required: ["repo", "name"],
148
- },
149
- },
150
- {
151
- name: "repo_map",
152
- description:
153
- "Token-budgeted map of the repository: the highest-PageRank files with their key exported signatures, deterministically rendered to fit `budgetTokens` (default 1024). The densest single read to understand an unfamiliar codebase.",
154
- inputSchema: {
155
- type: "object",
156
- properties: { ...repoProp, budgetTokens: { type: "number", description: "Approximate token budget (default 1024)" } },
157
- required: ["repo"],
158
- },
159
- },
160
- {
161
- name: "hotspots",
162
- description:
163
- "Where does work concentrate? Files ranked by git churn × size (commits × log2 lines). High-scoring files are where changes and defects cluster.",
164
- inputSchema: {
165
- type: "object",
166
- properties: { ...repoProp, since: { type: "string", description: "Only count commits after this ref" } },
167
- required: ["repo"],
168
- },
169
- },
170
- {
171
- name: "coupling",
172
- description:
173
- "Change coupling: pairs of files that repeatedly change in the same commits — hidden dependencies no import shows. strength 1.0 = every change to one touched the other.",
174
- inputSchema: {
175
- type: "object",
176
- properties: { ...repoProp, since: { type: "string", description: "Only mine commits after this ref" } },
177
- required: ["repo"],
178
- },
179
- },
180
- {
181
- name: "replace_symbol_body",
182
- description:
183
- "WRITE: replace a symbol's whole declaration with `body` (verbatim, supply full indentation). The symbol is resolved by name path ('Class/method'); ambiguity errors list the candidates — qualify with `file`. Line spans come from the AST index.",
184
- inputSchema: {
185
- type: "object",
186
- properties: {
187
- ...repoProp,
188
- namePath: { type: "string" },
189
- body: { type: "string" },
190
- file: { type: "string", description: "Disambiguate: repo-relative file containing the symbol" },
191
- },
192
- required: ["repo", "namePath", "body"],
193
- },
194
- },
195
- {
196
- name: "insert_after_symbol",
197
- description:
198
- "WRITE: insert `body` after a symbol's declaration (blank-line separation preserved for definition-like kinds). Resolved like replace_symbol_body.",
199
- inputSchema: {
200
- type: "object",
201
- properties: { ...repoProp, namePath: { type: "string" }, body: { type: "string" }, file: { type: "string" } },
202
- required: ["repo", "namePath", "body"],
203
- },
204
- },
205
- {
206
- name: "insert_before_symbol",
207
- description:
208
- "WRITE: insert `body` before a symbol's declaration (blank-line separation preserved). Resolved like replace_symbol_body.",
209
- inputSchema: {
210
- type: "object",
211
- properties: { ...repoProp, namePath: { type: "string" }, body: { type: "string" }, file: { type: "string" } },
212
- required: ["repo", "namePath", "body"],
213
- },
214
- },
215
- {
216
- name: "write_memory",
217
- description:
218
- "Persist a named markdown note under <repo>/.codeindex/memories/ (names may use topic/name form). Write small, focused notes: project map, build commands, conventions.",
219
- inputSchema: {
220
- type: "object",
221
- properties: { ...repoProp, name: { type: "string" }, content: { type: "string" } },
222
- required: ["repo", "name", "content"],
223
- },
224
- },
225
- {
226
- name: "read_memory",
227
- description: "Read one persisted memory by name.",
228
- inputSchema: {
229
- type: "object",
230
- properties: { ...repoProp, name: { type: "string" } },
231
- required: ["repo", "name"],
232
- },
233
- },
234
- {
235
- name: "list_memories",
236
- description: "List persisted memory names — load this first, then read individual memories on relevance.",
237
- inputSchema: { type: "object", properties: { ...repoProp }, required: ["repo"] },
238
- },
239
- {
240
- name: "delete_memory",
241
- description: "Delete one persisted memory by name.",
242
- inputSchema: {
243
- type: "object",
244
- properties: { ...repoProp, name: { type: "string" } },
245
- required: ["repo", "name"],
246
- },
247
- },
248
- {
249
- name: "dead_code",
250
- description:
251
- "Dead-code candidates in two labeled tiers: 'unreferenced' (no call site binds AND nothing references the name) and 'uncalled' (referenced somewhere — re-export, type position — but never called). Exported symbols only; test files and entrypoint-looking files excluded as roots. On a large repo this list runs to thousands of entries — pass `limit`, or `scope` to one subdirectory.",
252
- inputSchema: {
253
- type: "object",
254
- properties: {
255
- ...repoProp,
256
- ...scopeProps,
257
- limit: { type: "number", description: "Cap entries (default: all)" },
258
- },
259
- required: ["repo"],
260
- },
261
- },
262
- {
263
- name: "complexity",
264
- description:
265
- "Cyclomatic-complexity estimates (branch-token counting over AST line spans), most-complex first. Pass `file` for one file's symbols, omit for the repo-wide top. Combine with hotspots: the `risk` field of this tool's sibling ranks complexity × churn.",
266
- inputSchema: {
267
- type: "object",
268
- properties: { ...repoProp, file: { type: "string" }, risk: { type: "boolean", description: "Return complexity × git-churn risk ranking instead" } },
269
- required: ["repo"],
270
- },
271
- },
272
- {
273
- name: "mermaid",
274
- description:
275
- "Mermaid diagram of the module graph (renders inline in Claude/GitHub — no graph database). Optionally scoped to one module's neighborhood.",
276
- inputSchema: {
277
- type: "object",
278
- properties: { ...repoProp, module: { type: "string", description: "Module slug to focus on" } },
279
- required: ["repo"],
280
- },
281
- },
282
- {
283
- name: "grep",
284
- description:
285
- "Search file contents (ripgrep when available, deterministic JS fallback otherwise). Returns sorted (file, line, text) hits.",
286
- inputSchema: {
287
- type: "object",
288
- properties: {
289
- ...repoProp,
290
- pattern: { type: "string", description: "Regular expression to search for" },
291
- scope: { type: "string", description: "Restrict to one directory (repo-relative)" },
292
- globs: { type: "array", items: { type: "string" }, description: "Restrict to matching paths" },
293
- ignoreCase: { type: "boolean" },
294
- maxHits: { type: "number" },
295
- },
296
- required: ["repo", "pattern"],
297
- },
298
- },
299
- {
300
- name: "search",
301
- description:
302
- 'Natural-language-ish lexical search: BM25 ranking (k1=1.2, b=0.75) over symbol names (camelCase/snake_case subtokens), file path segments, markdown headings and summary lines. NOT embeddings by default — deterministic, diacritic-folded, zero API keys. Answers "where is auth handled?"-style queries with ranked files, matched terms and top symbols. Query terms with zero document frequency get a deterministic trigram-fuzzy fallback (typo-tolerant) unless `fuzzy: false`. Set `semantic: true` to RRF-fuse an embedding tier (HTTP endpoint, else a local static model) with lexical — the response then wraps the ranked list as `{ results, tier, degradedReason? }`, `tier` being "endpoint"/"static" when fusion happened or "lexical" (with `degradedReason`) when it did not (see embed_status). Without `semantic`, the response is the bare ranked array, unchanged.',
303
- inputSchema: {
304
- type: "object",
305
- properties: {
306
- ...repoProp,
307
- ...scopeProps,
308
- query: { type: "string", description: "Natural-language or identifier query" },
309
- limit: { type: "number", description: "Max results (default 20)" },
310
- fuzzy: {
311
- type: "boolean",
312
- description:
313
- "Trigram fuzzy fallback for query terms with zero document frequency (default true)",
314
- },
315
- semantic: {
316
- type: "boolean",
317
- description:
318
- 'RRF-fuse an embedding tier with lexical (default false). Precedence: the HTTP endpoint (CODEINDEX_EMBED_ENDPOINT) if set, else a local static model. The response reports the effective tier as a top-level `tier` field ("endpoint"/"static" on success, "lexical" plus `degradedReason` when neither is available/reachable) instead of degrading silently — see embed_status.',
319
- },
320
- },
321
- required: ["repo", "query"],
322
- },
323
- },
324
- {
325
- name: "embed_status",
326
- description:
327
- "Report the embedding tier: the effective mode (none/static/endpoint; endpoint > static model), the resolved model (opt-in, never shipped in the package) with its modelId/dim, EMBED_VERSION, and the configured HTTP endpoint with its reachability. Use to check whether `search` with semantic:true will fuse embeddings or degrade to lexical.",
328
- inputSchema: { type: "object", properties: { ...repoProp }, required: ["repo"] },
329
- },
330
- {
331
- name: "check_rules",
332
- description:
333
- 'Validate dependency-cruiser-style architecture rules against the link-graph. Rules (inline JSON array): forbidden edges {name, from, to, kind?, severity?, comment?} with glob paths, plus builtins {name, builtin: "cycles"|"orphans"} (module-level import cycles; edge-less code files). Returns deterministic violations with severity error|warn — a CI gate.',
334
- inputSchema: {
335
- type: "object",
336
- properties: {
337
- ...repoProp,
338
- ...scopeProps,
339
- rules: { type: "array", description: "Rules array (inline JSON — see description)" },
340
- configPath: {
341
- type: "string",
342
- description:
343
- "Read the rules from this JSON file instead (repo-relative or absolute) — the CLI's --config. Ignored when `rules` is given.",
344
- },
345
- },
346
- required: ["repo"],
347
- },
348
- },
349
- ] as const;
350
-
351
93
  function str(v: unknown): string | undefined {
352
94
  return typeof v === "string" && v ? v : undefined;
353
95
  }
@@ -365,276 +107,6 @@ function errMessage(e: unknown): string {
365
107
  return e instanceof Error ? e.message : String(e);
366
108
  }
367
109
 
368
- // --- embedding index memoization --------------------------------------------
369
- // The MCP server process is long-lived, but every `search` call used to redo
370
- // the FULL corpus embedding build — N `buildEndpointIndex` HTTP round-trips,
371
- // or a full `buildEmbeddingIndex` re-encode pass — even when nothing in the
372
- // repo changed between requests. Memoize the last build behind a fingerprint
373
- // of the scan contents plus the tier's identity, so an unchanged repo reuses
374
- // the cached index and any file add/edit/remove (or a switch of endpoint/model)
375
- // still rebuilds. RepoScan carries no fingerprint of its own (checked
376
- // scan.ts/types.ts) — every FileRecord already carries a content hash, so
377
- // hashing the (rel, hash) pairs is the staleness oracle scan.ts itself uses.
378
- export function scanFingerprint(scan: RepoScan): string {
379
- return sha1(scan.files.map((f) => `${f.rel}:${f.hash}`).join("\n"));
380
- }
381
-
382
- export interface EmbeddingIndexCacheKey {
383
- mode: "endpoint" | "static";
384
- // Distinguishes cache entries across configs sharing the same scan: the
385
- // endpoint URL, or the model dir + modelId for the static tier.
386
- identity: string;
387
- scan: RepoScan;
388
- }
389
-
390
- // A SINGLE entry — never an unbounded map — holding the most recent build.
391
- let embeddingIndexCache: { key: string; index: EmbeddingIndex } | undefined;
392
-
393
- // Reuse the cached index when (mode, identity, scanFingerprint) matches the
394
- // last build; otherwise call `build` and cache its result. A failed build is
395
- // NEVER cached (matches today's per-call error behavior: the next request
396
- // retries from scratch, and a still-valid previous entry — under a different
397
- // key — is left untouched).
398
- export async function memoizedEmbeddingIndex(
399
- key: EmbeddingIndexCacheKey,
400
- build: () => Promise<EmbeddingIndex> | EmbeddingIndex,
401
- ): Promise<EmbeddingIndex> {
402
- const cacheKey = `${key.mode}:${key.identity}:${scanFingerprint(key.scan)}`;
403
- if (embeddingIndexCache && embeddingIndexCache.key === cacheKey) return embeddingIndexCache.index;
404
- const index = await build();
405
- embeddingIndexCache = { key: cacheKey, index };
406
- return index;
407
- }
408
-
409
- // A SINGLE entry — never an unbounded map — holding the most recent parse.
410
- let embedModelCache: { key: string; model: StaticEmbedModel } | undefined;
411
-
412
- // model.json is 10-30 MB with the real asset; reading + JSON.parsing it on
413
- // EVERY request dominates static-tier latency, so the parsed model is memoized
414
- // across requests. One statSync per request keys the cache on
415
- // (dir, mtimeMs, size) so an in-place re-pull invalidates on the next call.
416
- // Same discipline as memoizedEmbeddingIndex: a failed load is NEVER cached —
417
- // the throw propagates and the cache is left as it was, so the next request
418
- // retries from scratch. A missing model.json returns undefined (the
419
- // not-present case, exactly like loadEmbedModel).
420
- export function memoizedEmbedModel(modelDir: string): StaticEmbedModel | undefined {
421
- let stat;
422
- try {
423
- stat = statSync(join(modelDir, "model.json"));
424
- } catch {
425
- return undefined;
426
- }
427
- const key = `${modelDir}:${stat.mtimeMs}:${stat.size}`;
428
- if (embedModelCache && embedModelCache.key === key) return embedModelCache.model;
429
- const model = loadEmbedModel(modelDir);
430
- if (model) embedModelCache = { key, model };
431
- return model;
432
- }
433
-
434
- // --- session-level scan + artifacts memoization ------------------------------
435
- // Same single-entry discipline as the embedding caches above: the MCP server
436
- // process is long-lived, but every tool call used to redo a FULL scanRepo walk
437
- // + read + hash + extraction pass (and, for graph-shaped tools, the whole
438
- // pipeline) even when nothing in the repo changed between requests. Cache the
439
- // last (repo, scan-opts) scan and feed its records back to scanRepo as `cache`
440
- // on the next call — scan.ts's EXISTING stat-fastpath + exact-hash machinery
441
- // is the freshness oracle, so a cache hit costs one walk + per-file stats, not
442
- // reads. When the oracle proves the content unchanged the SAME RepoScan object
443
- // is returned, which keeps the per-scan WeakMap of derived structures
444
- // (src/derived.ts) warm across requests. Artifacts are memoized on scan object
445
- // identity. Rendered strings are NEVER memoized — a big repo's graph.json runs
446
- // tens of MB, so renders stay per-call while the expensive structures behind
447
- // them are reused.
448
- //
449
- // Determinism: reused records come from scan.ts's own reuse paths (stat
450
- // fastpath / exact content-hash match), which produce records value-identical
451
- // to a from-scratch scan — artifacts stay byte-identical; only repeated work
452
- // disappears.
453
-
454
- // The scan options a session entry is keyed on. `cache`/`precomputedWalk` are
455
- // excluded from the contract: the session cache OWNS the cache it feeds back,
456
- // and a caller-supplied stale walk would desynchronize the freshness oracle.
457
- export type SessionScanOptions = Omit<ScanOptions, "cache" | "precomputedWalk">;
458
-
459
- type SessionCacheEntry = PersistedCacheEntry;
460
- type SessionCacheMap = PersistedCacheMap;
461
-
462
- interface SessionEntry {
463
- key: string;
464
- scan: RepoScan;
465
- cacheMap: SessionCacheMap;
466
- arts?: IndexArtifacts;
467
- }
468
-
469
- // A SMALL bounded LRU — never an unbounded map.
470
- //
471
- // This was a single entry, which made two entirely normal agent behaviours
472
- // pathological: alternating between two repos, and alternating between two
473
- // `scope` values on one repo. Either one evicted the other on every call, so
474
- // every call paid a full cold rebuild. Four entries covers those patterns while
475
- // keeping the memory story the same order of magnitude as before.
476
- const SESSION_CACHE_MAX = 4;
477
- const sessionCaches: SessionEntry[] = []; // most-recently-used first
478
-
479
- function sessionGet(key: string): SessionEntry | undefined {
480
- const i = sessionCaches.findIndex((e) => e.key === key);
481
- if (i < 0) return undefined;
482
- const [entry] = sessionCaches.splice(i, 1);
483
- sessionCaches.unshift(entry!);
484
- return entry;
485
- }
486
-
487
- function sessionPut(entry: SessionEntry): SessionEntry {
488
- const i = sessionCaches.findIndex((e) => e.key === entry.key);
489
- if (i >= 0) sessionCaches.splice(i, 1);
490
- sessionCaches.unshift(entry);
491
- sessionCaches.length = Math.min(sessionCaches.length, SESSION_CACHE_MAX);
492
- return entry;
493
- }
494
-
495
- // Drop every entry. Used by the symbolic-edit tools: an edit landing in the same
496
- // mtime tick with the same byte count would pass the (size, mtimeMs) fastpath
497
- // and serve a stale scan.
498
- function sessionClear(): void {
499
- sessionCaches.length = 0;
500
- }
501
-
502
- // Fixed property order (and JSON.stringify dropping undefined) keeps the key
503
- // deterministic regardless of how the caller assembled the options object.
504
- function sessionKey(repo: string, opts: SessionScanOptions): string {
505
- return (
506
- repo +
507
- "\0" +
508
- JSON.stringify({
509
- scope: opts.scope,
510
- include: opts.include,
511
- exclude: opts.exclude,
512
- gitignore: opts.gitignore,
513
- ignoreDirs: opts.ignoreDirs,
514
- maxBytes: opts.maxBytes,
515
- maxFiles: opts.maxFiles,
516
- maxCallsPerFile: opts.maxCallsPerFile,
517
- out: opts.out,
518
- fullHash: opts.fullHash,
519
- })
520
- );
521
- }
522
-
523
- // Re-exported for tests and for consumers that imported it from here before the
524
- // preload machinery moved into src/preload.ts.
525
- export { toCacheMap };
526
-
527
- // The memoizing replacement for scanRepo inside callTool. Exported for tests.
528
- export function getScan(repo: string, opts: SessionScanOptions = {}, walked?: WalkResult): RepoScan {
529
- const key = sessionKey(repo, opts);
530
- const hit = sessionGet(key);
531
- if (hit) {
532
- const fresh = scanRepo(repo, { ...opts, cache: hit.cacheMap, precomputedWalk: walked });
533
- if (fresh.contentUnchanged) {
534
- // Content proven identical → return the SAME object (object identity is
535
- // what keeps derived.ts's WeakMap and the memoized artifacts warm). A
536
- // stat-only drift (e.g. a bare touch) still refreshes the cache map so
537
- // the next call's stat fastpath keys on the new (size, mtimeMs).
538
- if (fresh.cacheDirty) hit.cacheMap = toCacheMap(fresh);
539
- // `commit` (headCommit(root)) is NOT part of the stat/hash freshness
540
- // oracle: a git HEAD move that leaves the worktree untouched — commit /
541
- // commit --amend / reset --soft / checkout to an identical-tree branch —
542
- // changes headCommit without altering any file's size or mtime, so
543
- // contentUnchanged stays true while the cached scan's commit went stale.
544
- // `fresh` recomputed it just now (exactly what a cold process reports), so
545
- // sync it onto the returned object; otherwise scan_summary would emit the
546
- // OLD commit a from-scratch scanRepo never would. Mutate the SAME object
547
- // rather than clone — cloning would forfeit the identity the artifacts and
548
- // derived.ts WeakMap key on. Safe: no artifact carries commit (graph /
549
- // symbols render byte-identically regardless), so nothing memoized here
550
- // depends on this field.
551
- if (hit.scan.commit !== fresh.commit) hit.scan.commit = fresh.commit;
552
- return hit.scan;
553
- }
554
- sessionPut({ key, scan: fresh, cacheMap: toCacheMap(fresh) });
555
- return fresh;
556
- }
557
- // First touch of this (repo, opts): try the persisted-index preload before a
558
- // cold scan. A present, version-compatible .codeindex/cache.json seeds the
559
- // scan (and, when the guard holds, the artifacts); absent it, fall through to
560
- // the cold path EXACTLY as before.
561
- const preloaded = preloadSession(repo, { ...opts, precomputedWalk: walked });
562
- if (preloaded) {
563
- sessionPut({ key, scan: preloaded.scan, cacheMap: preloaded.cacheMap, arts: preloaded.arts });
564
- return preloaded.scan;
565
- }
566
- const scan = scanRepo(repo, { ...opts, precomputedWalk: walked });
567
- sessionPut({ key, scan, cacheMap: toCacheMap(scan) });
568
- return scan;
569
- }
570
-
571
- // The scan_summary numbers, without paying for a scan.
572
- //
573
- // A file count and a language histogram come from the walk plus the path-based
574
- // classifiers — no read, no hash, no tree-sitter. When this session already
575
- // holds a scan for the same (repo, opts) we derive from it instead (identical
576
- // numbers, and it keeps a warm session warm); otherwise scanSummary walks once.
577
- // The summary is NEVER written into the session cache: it carries no
578
- // FileRecords, so caching it would starve every record-shaped tool that ran next.
579
- export function getScanSummary(repo: string, opts: SessionScanOptions = {}, walked?: WalkResult): ScanSummary {
580
- if (sessionCaches.some((e) => e.key === sessionKey(repo, opts))) {
581
- const scan = getScan(repo, opts, walked);
582
- return {
583
- root: scan.root,
584
- commit: scan.commit,
585
- fileCount: scan.files.length,
586
- languages: scan.languages,
587
- capped: scan.capped,
588
- excluded: scan.excluded,
589
- };
590
- }
591
- return scanSummary(repo, { ...opts, precomputedWalk: walked });
592
- }
593
-
594
- // Lazy pipeline memoized on scan OBJECT IDENTITY: graph-shaped tools reuse the
595
- // artifacts exactly as long as getScan keeps returning the same scan object.
596
- // Exported for tests.
597
- export function getArtifacts(repo: string, opts: SessionScanOptions = {}, walked?: WalkResult): IndexArtifacts {
598
- const scan = getScan(repo, opts, walked);
599
- const entry = sessionCaches.find((e) => e.scan === scan);
600
- if (entry) return (entry.arts ??= buildArtifactsFromScan(scan, opts));
601
- // Defensive fallback (getScan always leaves an entry holding `scan`).
602
- return buildArtifactsFromScan(scan, opts);
603
- }
604
-
605
- // Warm the grammars for the languages CURRENTLY present in `repo`, re-derived on
606
- // EVERY scan-needing call — never frozen on first touch. The server no longer
607
- // warms every committed grammar at startup; most sessions touch one repo and a
608
- // handful of languages, so each scan-needing tool warms the walk-derived set
609
- // itself. It MUST re-derive per call because the session cache (getScan) is built
610
- // to pick up mid-session file adds/edits/removes: a language whose first file
611
- // appears only AFTER the initial scan-needing call must still get its grammar
612
- // warmed, or that file falls to the regex tier and its symbols diverge from a
613
- // cold build on the identical on-disk state — a byte-identity break. (A per-
614
- // repo-path memo froze the grammar set at first touch and silently missed
615
- // exactly this case.) ensureGrammars is idempotent and near-free once a grammar
616
- // is loaded — it warms only newly-seen keys — so the sole repeated cost is the
617
- // walk; the wasm for a given language loads at most once. Determinism: the walk's
618
- // extension set is a superset of what scanRepo keeps (scope/include/exclude only
619
- // filter further), so every extracted file has its grammar loaded; Language.load
620
- // calls are independent, so warming fewer grammars cannot alter the parse of a
621
- // loaded one.
622
- export async function warmGrammarsForRepo(repo: string): Promise<void> {
623
- await warmGrammarsForWalk(walk(repo, {}));
624
- }
625
-
626
- // The same warm, against a walk the caller already has.
627
- //
628
- // callTool used to walk TWICE per scan-needing call: once here to derive the
629
- // present languages, then again inside scanRepo. On a large repo that fixed cost
630
- // dominated every response (the project's own benchmark shows find-symbol,
631
- // references and file-overview all landing within a few ms of each other on a
632
- // 20k-file monorepo — the signature of a per-call constant, not of the query).
633
- // One walk now feeds both.
634
- export async function warmGrammarsForWalk(walked: WalkResult): Promise<void> {
635
- await ensureGrammars(grammarKeysForExts(walked.files.map((f) => f.ext)));
636
- }
637
-
638
110
  // Tools that never scan the file tree (git/grep/memory/embed-status only) — they
639
111
  // must not trigger a grammar warm. Every other tool is scan-needing and warms
640
112
  // the repo's grammars first; defaulting to "warm" keeps a newly added scan tool
@@ -906,237 +378,6 @@ async function callTool(name: string, args: Record<string, unknown>, defaultRepo
906
378
  throw new Error(`unknown tool: ${name}`);
907
379
  }
908
380
 
909
- // --- protocol versions -------------------------------------------------------
910
- // The server announced "2024-11-05" hard-coded and never even read the version
911
- // the client asked for. Three revisions have shipped since.
912
- //
913
- // Negotiation is what makes moving forward non-breaking: a client that asks for
914
- // an old revision gets that revision, and every field introduced later is
915
- // withheld — so its responses are exactly the bytes it received before. Newer
916
- // clients opt themselves in simply by asking.
917
- //
918
- // Dates sort lexicographically, so `>=` on the strings is a version comparison.
919
- const PROTOCOL_VERSIONS = ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] as const;
920
- const LATEST_PROTOCOL = PROTOCOL_VERSIONS[PROTOCOL_VERSIONS.length - 1]!;
921
-
922
- // Feature floors, by the revision that introduced them.
923
- const ANNOTATIONS_SINCE = "2025-03-26"; // tool behaviour hints
924
- const RICH_TOOLS_SINCE = "2025-06-18"; // Tool.title, resource_link content
925
-
926
- // Validate `arguments` against the tool's declared inputSchema.
927
- //
928
- // There was no validation at all beyond presence checks, and the readers failed
929
- // silently in both directions: str() returns undefined for a non-string, so a
930
- // number where a path belongs became "missing", and every boolean was `=== true`,
931
- // so `"false"` and `1` alike read as false. The caller saw its option ignored
932
- // with no way to tell why.
933
- //
934
- // Only the shapes these schemas actually use are checked (string / number /
935
- // boolean / array-of-string) — this is a guard against silent misreads, not a
936
- // JSON Schema implementation. The spec (2025-11-25) is explicit that input
937
- // validation failures belong in a Tool Execution Error, not a protocol error,
938
- // precisely so the model can read the message and retry.
939
- // Required-ness stays with callTool, which raises tool-specific messages
940
- // ("`rules` (or `configPath`) is required"); duplicating it here would only let
941
- // the two drift.
942
- export function validateArgs(
943
- schema: { properties?: Record<string, unknown> },
944
- args: Record<string, unknown>,
945
- ): string | undefined {
946
- const props = (schema.properties ?? {}) as Record<string, { type?: string; items?: { type?: string } }>;
947
- for (const [key, value] of Object.entries(args)) {
948
- if (value === undefined || value === null) continue;
949
- const spec = props[key];
950
- if (!spec?.type) continue; // undeclared extras stay tolerated
951
- const actual = Array.isArray(value) ? "array" : typeof value;
952
- if (spec.type === "number") {
953
- // A numeric string is accepted (num() coerces it); anything else is not.
954
- if (actual === "number") continue;
955
- if (actual === "string" && Number.isFinite(Number(value as string)) && (value as string).trim() !== "") continue;
956
- return `\`${key}\` must be a number, got ${actual === "string" ? JSON.stringify(value) : actual}`;
957
- }
958
- if (spec.type === "array") {
959
- if (actual !== "array") return `\`${key}\` must be an array of strings, got ${actual}`;
960
- if (spec.items?.type === "string" && !(value as unknown[]).every((x) => typeof x === "string")) {
961
- return `\`${key}\` must be an array of strings`;
962
- }
963
- continue;
964
- }
965
- if (actual !== spec.type) return `\`${key}\` must be a ${spec.type}, got ${actual}`;
966
- }
967
- return undefined;
968
- }
969
-
970
- export function negotiateProtocol(requested: unknown): string {
971
- return typeof requested === "string" && (PROTOCOL_VERSIONS as readonly string[]).includes(requested)
972
- ? requested
973
- : LATEST_PROTOCOL;
974
- }
975
-
976
- // Per-tool display title and behaviour hints.
977
- //
978
- // The hints matter operationally: they are what lets a host auto-approve the 23
979
- // read-only tools and hold a confirmation for the 5 that write. Without them a
980
- // client must treat `scan_summary` and `replace_symbol_body` alike.
981
- //
982
- // openWorldHint is true only where a call can leave this machine — `search`
983
- // with semantic:true and `embed_status` may contact CODEINDEX_EMBED_ENDPOINT.
984
- // Everything else reads the repo and nothing but the repo.
985
- interface ToolMeta {
986
- title: string;
987
- write?: boolean;
988
- destructive?: boolean;
989
- idempotent?: boolean;
990
- openWorld?: boolean;
991
- }
992
-
993
- const TOOL_META: Record<string, ToolMeta> = {
994
- scan_summary: { title: "Scan summary" },
995
- graph: { title: "Link graph" },
996
- symbols: { title: "Symbol index" },
997
- callers: { title: "Caller index" },
998
- workspaces: { title: "Monorepo workspaces" },
999
- churn: { title: "Git churn" },
1000
- symbols_overview: { title: "File symbol overview" },
1001
- find_symbol: { title: "Find symbol" },
1002
- find_references: { title: "Find references" },
1003
- repo_map: { title: "Repository map" },
1004
- hotspots: { title: "Hotspots" },
1005
- coupling: { title: "Change coupling" },
1006
- replace_symbol_body: { title: "Replace symbol body", write: true, destructive: true, idempotent: true },
1007
- insert_after_symbol: { title: "Insert after symbol", write: true, destructive: false, idempotent: false },
1008
- insert_before_symbol: { title: "Insert before symbol", write: true, destructive: false, idempotent: false },
1009
- write_memory: { title: "Write memory", write: true, destructive: false, idempotent: true },
1010
- read_memory: { title: "Read memory" },
1011
- list_memories: { title: "List memories" },
1012
- delete_memory: { title: "Delete memory", write: true, destructive: true, idempotent: true },
1013
- dead_code: { title: "Dead-code candidates" },
1014
- complexity: { title: "Complexity" },
1015
- mermaid: { title: "Mermaid module diagram" },
1016
- grep: { title: "Grep file contents" },
1017
- search: { title: "Lexical search", openWorld: true },
1018
- embed_status: { title: "Embedding tier status", openWorld: true },
1019
- check_rules: { title: "Check architecture rules" },
1020
- };
1021
-
1022
- function annotationsFor(name: string): Record<string, boolean> | undefined {
1023
- const meta = TOOL_META[name];
1024
- if (!meta) return undefined;
1025
- return {
1026
- readOnlyHint: !meta.write,
1027
- ...(meta.write ? { destructiveHint: meta.destructive === true, idempotentHint: meta.idempotent === true } : {}),
1028
- openWorldHint: meta.openWorld === true,
1029
- };
1030
- }
1031
-
1032
- // The advertised tool list, for one negotiated protocol version.
1033
- //
1034
- // Without a server-level repo pin and on 2024-11-05 this is TOOLS verbatim —
1035
- // byte-compat for every existing consumer. A pin drops `repo` from each
1036
- // `required` set and documents the default, so a client that omits it is
1037
- // spec-correct rather than relying on the server being lenient. Newer protocol
1038
- // revisions additionally get `title` and `annotations`.
1039
- function toolsFor(defaultRepo?: string, protocolVersion: string = PROTOCOL_VERSIONS[0]): readonly unknown[] {
1040
- const withAnnotations = protocolVersion >= ANNOTATIONS_SINCE;
1041
- const withTitle = protocolVersion >= RICH_TOOLS_SINCE;
1042
- if (!defaultRepo && !withAnnotations && !withTitle) return TOOLS;
1043
- return TOOLS.map((t) => ({
1044
- ...t,
1045
- ...(withTitle && TOOL_META[t.name] ? { title: TOOL_META[t.name]!.title } : {}),
1046
- ...(withAnnotations ? { annotations: annotationsFor(t.name) } : {}),
1047
- inputSchema: !defaultRepo
1048
- ? t.inputSchema
1049
- : {
1050
- ...t.inputSchema,
1051
- properties: {
1052
- ...t.inputSchema.properties,
1053
- repo: {
1054
- type: "string",
1055
- description: `Absolute path to the repository root (optional — defaults to ${defaultRepo})`,
1056
- },
1057
- },
1058
- required: (t.inputSchema.required as readonly string[]).filter((r) => r !== "repo"),
1059
- },
1060
- }));
1061
- }
1062
-
1063
- // --- response size guard -----------------------------------------------------
1064
- // Several tools returned unbounded payloads. On facebook/react (7091 files):
1065
- // graph 9.4 MB, symbols 6.3 MB, callers 6.0 MB, dead_code 771 KB — roughly
1066
- // 2.35M, 1.57M, 1.51M and 193k tokens. A single `graph` call does not merely
1067
- // bloat an agent's context, it exceeds what any MCP client can accept, so the
1068
- // call fails and the turn is wasted.
1069
- //
1070
- // The guard is deliberately NOT a default page size: below the limit a response
1071
- // is byte-identical to what it always was. Above it, the response could not be
1072
- // consumed by any client anyway, so replacing it with something actionable
1073
- // cannot regress a working call — it converts a hard failure into a usable
1074
- // answer that says how big the payload is, where the artifact already sits on
1075
- // disk, and which narrower tool answers the question.
1076
- export const DEFAULT_MAX_RESPONSE_BYTES = 1_000_000;
1077
-
1078
- // What to steer a caller toward when their whole-repo request is too large.
1079
- const NARROWER: Record<string, string> = {
1080
- graph: "pass `scope` to a subdirectory, or use repo_map / mermaid for an overview",
1081
- symbols: "pass `name` to look up one symbol, or use find_symbol / symbols_overview",
1082
- callers: "pass `name` to look up one symbol's call sites",
1083
- dead_code: "pass `scope` to a subdirectory",
1084
- find_references: "the symbol is referenced very widely — narrow with `scope` on a graph query",
1085
- check_rules: "narrow the rule set, or pass `scope` to a subdirectory",
1086
- };
1087
-
1088
- // The persisted artifact backing a tool, when a `codeindex index` already wrote
1089
- // one — far more useful to hand back than a truncated blob.
1090
- const ARTIFACT_FOR: Record<string, string> = { graph: "graph.json", symbols: "symbols.json" };
1091
-
1092
- export function capResponse(text: string, tool: string, repo: string, maxBytes: number): string {
1093
- const bytes = Buffer.byteLength(text, "utf8");
1094
- if (bytes <= maxBytes) return text;
1095
- const artifact = ARTIFACT_FOR[tool] ? join(repo, INDEX_DIR, ARTIFACT_FOR[tool]!) : undefined;
1096
- return (
1097
- JSON.stringify(
1098
- {
1099
- truncated: true,
1100
- tool,
1101
- bytes,
1102
- maxBytes,
1103
- reason:
1104
- "This response exceeds the configured limit and was withheld rather than sent as an unusable partial payload.",
1105
- narrower: NARROWER[tool] ?? "narrow the request with `scope`, `include`/`exclude`, or a `limit`",
1106
- ...(artifact && existsSync(artifact)
1107
- ? { artifact, artifactNote: "The full result is already on disk here — read it directly if you need all of it." }
1108
- : artifact
1109
- ? { artifactNote: `Run \`codeindex index --repo ${repo} --out ${join(repo, INDEX_DIR)}\` to get this as a file.` }
1110
- : {}),
1111
- },
1112
- null,
1113
- 2,
1114
- ) + "\n"
1115
- );
1116
- }
1117
-
1118
- // When capResponse withheld a payload AND the artifact is on disk, hand the
1119
- // client a resource_link to it. Returns undefined for every normal response —
1120
- // this only ever adds a second content block to a capped one.
1121
- export function resourceLinkFor(text: string, tool: string): Record<string, unknown> | undefined {
1122
- const artifactName = ARTIFACT_FOR[tool];
1123
- if (!artifactName) return undefined;
1124
- let parsed: { truncated?: boolean; artifact?: string };
1125
- try {
1126
- parsed = JSON.parse(text) as typeof parsed;
1127
- } catch {
1128
- return undefined; // a normal (non-JSON, or non-capped) response
1129
- }
1130
- if (parsed.truncated !== true || typeof parsed.artifact !== "string") return undefined;
1131
- return {
1132
- type: "resource_link",
1133
- uri: pathToFileURL(parsed.artifact).href,
1134
- name: artifactName,
1135
- description: `The full ${tool} result this call was too large to inline.`,
1136
- mimeType: "application/json",
1137
- };
1138
- }
1139
-
1140
381
  export interface McpServerOptions {
1141
382
  // Override the serverInfo announced in the initialize response — for
1142
383
  // downstream consumers embedding this server under their own identity.
@@ -1229,11 +470,21 @@ export async function runMcpServer(opts: McpServerOptions = {}): Promise<void> {
1229
470
  // Gated on `text !== raw` — i.e. capResponse actually replaced the
1230
471
  // payload. Otherwise a normal 900 KB graph would be JSON.parsed on
1231
472
  // every single call just to discover it was not truncated.
1232
- const link =
1233
- text !== raw && protocolVersion >= RICH_TOOLS_SINCE ? resourceLinkFor(text, name) : undefined;
473
+ const capped = text !== raw;
474
+ const link = capped && protocolVersion >= RICH_TOOLS_SINCE ? resourceLinkFor(text, name) : undefined;
475
+ // Typed, validatable result alongside the text block — for the tools
476
+ // that declare an outputSchema, and never when the guard replaced the
477
+ // payload (see structuredContentFor).
478
+ const structured =
479
+ protocolVersion >= RICH_TOOLS_SINCE
480
+ ? structuredContentFor(text, capped, OUTPUT_SCHEMAS[name] !== undefined)
481
+ : undefined;
1234
482
  send({
1235
483
  id: req.id,
1236
- result: { content: link ? [{ type: "text", text }, link] : [{ type: "text", text }] },
484
+ result: {
485
+ content: link ? [{ type: "text", text }, link] : [{ type: "text", text }],
486
+ ...(structured ? { structuredContent: structured } : {}),
487
+ },
1237
488
  });
1238
489
  } catch (e) {
1239
490
  send({