@maxgfr/codeindex 2.17.1 → 2.18.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
@@ -1,20 +1,27 @@
1
1
  // MCP (Model Context Protocol) server over stdio — hand-rolled JSON-RPC 2.0 so
2
2
  // the engine stays zero-dependency. Newline-delimited JSON messages, protocol
3
3
  // 2024-11-05 (compatible with later revisions' initialize handshake). Exposes
4
- // the engine's read-only indexing capabilities as MCP tools; every tool takes a
5
- // `repo` path and returns JSON text content.
4
+ // the engine's indexing capabilities as MCP tools; every tool takes a `repo`
5
+ // path and returns text content — JSON, except repo_map, mermaid and
6
+ // read_memory, which return their own formats.
6
7
  //
7
- // Register in an MCP client as: node scripts/engine.mjs mcp
8
- import { readFileSync, statSync } from "node:fs";
9
- import { join } from "node:path";
8
+ // Register in an MCP client as: codeindex mcp
9
+ // (NOT `node scripts/engine.mjs mcp`: engine.mjs is a side-effect-free library
10
+ // with no main-module guard — see src/engine.ts — so that command does nothing.
11
+ // The entrypoint is the `codeindex` bin, i.e. scripts/cli.mjs.)
12
+ import { existsSync, readFileSync, statSync } from "node:fs";
13
+ import { isAbsolute, join } from "node:path";
14
+ import { pathToFileURL } from "node:url";
10
15
  import { createInterface } from "node:readline";
11
- import { ENGINE_VERSION, SCHEMA_VERSION, EXTRACTOR_VERSION, type FileRecord, type Graph, type SymbolIndex } from "./types.js";
16
+ import { ENGINE_VERSION } from "./types.js";
12
17
  import { ensureGrammars, grammarKeysForExts } from "./ast/loader.js";
13
18
  import { buildArtifactsFromScan, type IndexArtifacts } from "./pipeline.js";
14
19
  import { renderGraphJson } from "./render/graph-json.js";
15
- import { scanRepo, type RepoScan, type ScanOptions } from "./scan.js";
16
- import { walk } from "./walk.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";
17
23
  import { buildCallerIndex } from "./callers.js";
24
+ import { callerIndexFor } from "./derived.js";
18
25
  import { detectWorkspaces } from "./workspaces.js";
19
26
  import { gitChurn } from "./git.js";
20
27
  import { grepRepo } from "./grep.js";
@@ -77,7 +84,15 @@ const TOOLS = [
77
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.",
78
85
  inputSchema: {
79
86
  type: "object",
80
- properties: { ...repoProp, name: { type: "string", description: "Symbol name to look up" } },
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
+ },
81
96
  required: ["repo"],
82
97
  },
83
98
  },
@@ -117,6 +132,7 @@ const TOOLS = [
117
132
  namePath: { type: "string", description: "Symbol name or Parent/child path" },
118
133
  substring: { type: "boolean" },
119
134
  includeBody: { type: "boolean" },
135
+ maxResults: { type: "number", description: "Cap matches (default 50)" },
120
136
  },
121
137
  required: ["repo", "namePath"],
122
138
  },
@@ -232,8 +248,16 @@ const TOOLS = [
232
248
  {
233
249
  name: "dead_code",
234
250
  description:
235
- "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.",
236
- inputSchema: { type: "object", properties: { ...repoProp, ...scopeProps }, required: ["repo"] },
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
+ },
237
261
  },
238
262
  {
239
263
  name: "complexity",
@@ -264,6 +288,7 @@ const TOOLS = [
264
288
  properties: {
265
289
  ...repoProp,
266
290
  pattern: { type: "string", description: "Regular expression to search for" },
291
+ scope: { type: "string", description: "Restrict to one directory (repo-relative)" },
267
292
  globs: { type: "array", items: { type: "string" }, description: "Restrict to matching paths" },
268
293
  ignoreCase: { type: "boolean" },
269
294
  maxHits: { type: "number" },
@@ -312,8 +337,13 @@ const TOOLS = [
312
337
  ...repoProp,
313
338
  ...scopeProps,
314
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
+ },
315
345
  },
316
- required: ["repo", "rules"],
346
+ required: ["repo"],
317
347
  },
318
348
  },
319
349
  ] as const;
@@ -324,6 +354,13 @@ function str(v: unknown): string | undefined {
324
354
  function strArray(v: unknown): string[] | undefined {
325
355
  return Array.isArray(v) && v.every((x) => typeof x === "string") && v.length ? (v as string[]) : undefined;
326
356
  }
357
+ // A positive numeric argument. Also accepts the numeric STRING a JSON-Schema-less
358
+ // client may send: `"50"` used to fall through to the default in silence, which
359
+ // reads to the caller as the option being ignored.
360
+ function num(v: unknown): number | undefined {
361
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN;
362
+ return Number.isFinite(n) && n > 0 ? n : undefined;
363
+ }
327
364
  function errMessage(e: unknown): string {
328
365
  return e instanceof Error ? e.message : String(e);
329
366
  }
@@ -419,13 +456,48 @@ export function memoizedEmbedModel(modelDir: string): StaticEmbedModel | undefin
419
456
  // and a caller-supplied stale walk would desynchronize the freshness oracle.
420
457
  export type SessionScanOptions = Omit<ScanOptions, "cache" | "precomputedWalk">;
421
458
 
422
- type SessionCacheEntry = { hash: string; record: FileRecord; size?: number; mtimeMs?: number };
423
- type SessionCacheMap = Map<string, SessionCacheEntry>;
459
+ type SessionCacheEntry = PersistedCacheEntry;
460
+ type SessionCacheMap = PersistedCacheMap;
424
461
 
425
- // A SINGLE entry — never an unbounded map — holding the most recent scan.
426
- let sessionCache:
427
- | { key: string; scan: RepoScan; cacheMap: SessionCacheMap; arts?: IndexArtifacts }
428
- | undefined;
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
+ }
429
501
 
430
502
  // Fixed property order (and JSON.stringify dropping undefined) keeps the key
431
503
  // deterministic regardless of how the caller assembled the options object.
@@ -448,149 +520,22 @@ function sessionKey(repo: string, opts: SessionScanOptions): string {
448
520
  );
449
521
  }
450
522
 
451
- // A scan re-expressed as the `ScanOptions.cache` shape (the exact map the CLI
452
- // persists as cache.json): rel → (hash, record, size, mtimeMs), so the next
453
- // scanRepo can take the stat fastpath / hash-match reuse paths against it.
454
- // Exported for tests.
455
- export function toCacheMap(scan: RepoScan): SessionCacheMap {
456
- const m: SessionCacheMap = new Map();
457
- for (const f of scan.files) m.set(f.rel, { hash: f.hash, record: f, size: f.size, mtimeMs: scan.mtimes.get(f.rel) });
458
- return m;
459
- }
460
-
461
- // --- persisted-index preload -------------------------------------------------
462
- // On the FIRST tool call for a repo, a committed .codeindex/ index (written by
463
- // `codeindex index`) lets the session skip work TWO ways. cache.json seeds the
464
- // session scan, so every unchanged file takes scan.ts's stat fastpath instead
465
- // of a read + hash + extraction; and — only when the T4 freshness guard holds —
466
- // the persisted graph.json/symbols.json are deserialized straight into the
467
- // session, so the first graph/symbols/mermaid/repo_map/check_rules call skips
468
- // the whole downstream pipeline (buildArtifactsFromScan). Both are pure
469
- // optimizations: the seeded scan's reused records are value-identical to a cold
470
- // scan's (T3/T4 determinism), and the guard is the SAME oracle the CLI's index
471
- // fastpath uses to prove the on-disk artifacts equal a fresh build here. Absent,
472
- // stale, corrupt, or any version/commit/sha mismatch → every step falls back to
473
- // today's cold path EXACTLY (a fresh scanRepo / buildArtifactsFromScan), never a
474
- // throw.
475
-
476
- // ADDITIVE cache.json meta describing the artifacts a prior `index` run wrote
477
- // (see engine-cli.ts's CacheMeta). Old caches lacking these keys simply never
478
- // pass the guard below — their per-file records are still reused to seed the
479
- // scan. Only the graph/symbols shas matter here; the embed sidecar has its own
480
- // memoization path.
481
- interface PersistedMeta {
482
- engineVersion?: string;
483
- commit?: string;
484
- graphSha1?: string;
485
- symbolsSha1?: string;
486
- }
487
-
488
- // Read <repo>/.codeindex/cache.json into the (cacheMap, meta) the preload needs.
489
- // Per-file records are reusable ONLY when (schemaVersion, extractorVersion)
490
- // match this engine — the exact gate the CLI applies before trusting a cache —
491
- // otherwise the whole cache is discarded (cold scan). Any read/parse failure (no
492
- // index yet, unreadable, malformed) returns undefined: the cold path.
493
- function readPersistedIndex(repo: string): { cacheMap: SessionCacheMap; meta: PersistedMeta } | undefined {
494
- let parsed:
495
- | ({ schemaVersion?: number; extractorVersion?: number; files?: Record<string, SessionCacheEntry> } & PersistedMeta)
496
- | undefined;
497
- try {
498
- parsed = JSON.parse(readFileSync(join(repo, ".codeindex", "cache.json"), "utf8")) as typeof parsed;
499
- } catch {
500
- return undefined;
501
- }
502
- if (!parsed || parsed.schemaVersion !== SCHEMA_VERSION || parsed.extractorVersion !== EXTRACTOR_VERSION || !parsed.files) {
503
- return undefined;
504
- }
505
- const cacheMap: SessionCacheMap = new Map(Object.entries(parsed.files));
506
- const meta: PersistedMeta = {
507
- engineVersion: parsed.engineVersion,
508
- commit: parsed.commit,
509
- graphSha1: parsed.graphSha1,
510
- symbolsSha1: parsed.symbolsSha1,
511
- };
512
- return { cacheMap, meta };
513
- }
514
-
515
- // The T4 freshness guard, applied to a session scan seeded from cache.json:
516
- // contentUnchanged proves this scan's records are the ones that built the
517
- // on-disk artifacts; engineVersion pins the version stamp graph.json embeds and
518
- // commit the HEAD it embeds; the sha checks prove the on-disk bytes ARE that
519
- // build's output. All true ⇒ graph.json/symbols.json are byte-equal to
520
- // buildArtifactsFromScan(scan) run here, so deserialize them instead of
521
- // rebuilding. Graph/SymbolIndex are pure JSON POJOs (no Map/Set/typed fields),
522
- // so JSON.parse is a lossless round-trip — a schemaVersion assert is the only
523
- // reconstruction needed (see the round-trip test). ANY failure — a stale scan,
524
- // a version/commit/sha mismatch, a missing/corrupt/partial artifact, an
525
- // unexpected schemaVersion — returns undefined so the caller rebuilds. NEVER
526
- // throws (a corrupt artifact must degrade, not crash the session).
527
- function preloadArtifacts(repo: string, scan: RepoScan, meta: PersistedMeta): IndexArtifacts | undefined {
528
- if (
529
- !scan.contentUnchanged ||
530
- meta.engineVersion !== ENGINE_VERSION ||
531
- meta.commit !== scan.commit ||
532
- meta.graphSha1 === undefined ||
533
- meta.symbolsSha1 === undefined
534
- ) {
535
- return undefined;
536
- }
537
- const dir = join(repo, ".codeindex");
538
- let graphBytes: Buffer;
539
- let symbolsBytes: Buffer;
540
- try {
541
- graphBytes = readFileSync(join(dir, "graph.json"));
542
- symbolsBytes = readFileSync(join(dir, "symbols.json"));
543
- } catch {
544
- return undefined; // a sha'd artifact went missing since cache.json — rebuild
545
- }
546
- // sha over the raw bytes; sha1(string) hashes the same UTF-8 bytes writeFileSync
547
- // put on disk, so this equals the meta sha the CLI computed over the render.
548
- if (sha1(graphBytes) !== meta.graphSha1 || sha1(symbolsBytes) !== meta.symbolsSha1) {
549
- return undefined; // tampered / partial / corrupt on-disk bytes — rebuild
550
- }
551
- try {
552
- const graph = JSON.parse(graphBytes.toString("utf8")) as Graph;
553
- const symbols = JSON.parse(symbolsBytes.toString("utf8")) as SymbolIndex;
554
- if (graph.schemaVersion !== SCHEMA_VERSION || symbols.schemaVersion !== SCHEMA_VERSION) return undefined;
555
- return { scan, graph, symbols };
556
- } catch {
557
- // Unreachable once the shas matched (the bytes are valid JSON this engine
558
- // wrote), but the contract is "never throw" — degrade to a rebuild.
559
- return undefined;
560
- }
561
- }
562
-
563
- // First-touch preload: seed the session scan from cache.json and, when the guard
564
- // holds, the artifacts from graph.json/symbols.json. undefined ⇒ no persisted
565
- // index ⇒ the caller takes the cold scanRepo path unchanged.
566
- function preloadSession(
567
- repo: string,
568
- opts: SessionScanOptions,
569
- ): { scan: RepoScan; cacheMap: SessionCacheMap; arts?: IndexArtifacts } | undefined {
570
- const persisted = readPersistedIndex(repo);
571
- if (!persisted) return undefined;
572
- // Seed the scan from the persisted records — scan.ts's stat fastpath + exact
573
- // content-hash reuse make this value-identical to a cold scan (T3/T4), only
574
- // cheaper, and it computes the contentUnchanged the artifact guard reads. When
575
- // the on-disk content drifted from cache.json, changed files are re-read/
576
- // extracted here exactly as a cold scan would, so the scan stays correct and
577
- // the guard simply fails (arts undefined → rebuild on demand).
578
- const scan = scanRepo(repo, { ...opts, cache: persisted.cacheMap });
579
- const arts = preloadArtifacts(repo, scan, persisted.meta);
580
- return { scan, cacheMap: toCacheMap(scan), arts };
581
- }
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 };
582
526
 
583
527
  // The memoizing replacement for scanRepo inside callTool. Exported for tests.
584
- export function getScan(repo: string, opts: SessionScanOptions = {}): RepoScan {
528
+ export function getScan(repo: string, opts: SessionScanOptions = {}, walked?: WalkResult): RepoScan {
585
529
  const key = sessionKey(repo, opts);
586
- if (sessionCache && sessionCache.key === key) {
587
- const fresh = scanRepo(repo, { ...opts, cache: sessionCache.cacheMap });
530
+ const hit = sessionGet(key);
531
+ if (hit) {
532
+ const fresh = scanRepo(repo, { ...opts, cache: hit.cacheMap, precomputedWalk: walked });
588
533
  if (fresh.contentUnchanged) {
589
534
  // Content proven identical → return the SAME object (object identity is
590
535
  // what keeps derived.ts's WeakMap and the memoized artifacts warm). A
591
536
  // stat-only drift (e.g. a bare touch) still refreshes the cache map so
592
537
  // the next call's stat fastpath keys on the new (size, mtimeMs).
593
- if (fresh.cacheDirty) sessionCache.cacheMap = toCacheMap(fresh);
538
+ if (fresh.cacheDirty) hit.cacheMap = toCacheMap(fresh);
594
539
  // `commit` (headCommit(root)) is NOT part of the stat/hash freshness
595
540
  // oracle: a git HEAD move that leaves the worktree untouched — commit /
596
541
  // commit --amend / reset --soft / checkout to an identical-tree branch —
@@ -603,35 +548,57 @@ export function getScan(repo: string, opts: SessionScanOptions = {}): RepoScan {
603
548
  // derived.ts WeakMap key on. Safe: no artifact carries commit (graph /
604
549
  // symbols render byte-identically regardless), so nothing memoized here
605
550
  // depends on this field.
606
- if (sessionCache.scan.commit !== fresh.commit) sessionCache.scan.commit = fresh.commit;
607
- return sessionCache.scan;
551
+ if (hit.scan.commit !== fresh.commit) hit.scan.commit = fresh.commit;
552
+ return hit.scan;
608
553
  }
609
- sessionCache = { key, scan: fresh, cacheMap: toCacheMap(fresh) };
554
+ sessionPut({ key, scan: fresh, cacheMap: toCacheMap(fresh) });
610
555
  return fresh;
611
556
  }
612
557
  // First touch of this (repo, opts): try the persisted-index preload before a
613
558
  // cold scan. A present, version-compatible .codeindex/cache.json seeds the
614
559
  // scan (and, when the guard holds, the artifacts); absent it, fall through to
615
560
  // the cold path EXACTLY as before.
616
- const preloaded = preloadSession(repo, opts);
561
+ const preloaded = preloadSession(repo, { ...opts, precomputedWalk: walked });
617
562
  if (preloaded) {
618
- sessionCache = { key, scan: preloaded.scan, cacheMap: preloaded.cacheMap, arts: preloaded.arts };
563
+ sessionPut({ key, scan: preloaded.scan, cacheMap: preloaded.cacheMap, arts: preloaded.arts });
619
564
  return preloaded.scan;
620
565
  }
621
- const scan = scanRepo(repo, opts);
622
- sessionCache = { key, scan, cacheMap: toCacheMap(scan) };
566
+ const scan = scanRepo(repo, { ...opts, precomputedWalk: walked });
567
+ sessionPut({ key, scan, cacheMap: toCacheMap(scan) });
623
568
  return scan;
624
569
  }
625
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
+
626
594
  // Lazy pipeline memoized on scan OBJECT IDENTITY: graph-shaped tools reuse the
627
595
  // artifacts exactly as long as getScan keeps returning the same scan object.
628
596
  // Exported for tests.
629
- export function getArtifacts(repo: string, opts: SessionScanOptions = {}): IndexArtifacts {
630
- const scan = getScan(repo, opts);
631
- if (sessionCache && sessionCache.scan === scan) {
632
- return (sessionCache.arts ??= buildArtifactsFromScan(scan, opts));
633
- }
634
- // Defensive fallback (getScan always leaves sessionCache holding `scan`).
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`).
635
602
  return buildArtifactsFromScan(scan, opts);
636
603
  }
637
604
 
@@ -653,8 +620,19 @@ export function getArtifacts(repo: string, opts: SessionScanOptions = {}): Index
653
620
  // calls are independent, so warming fewer grammars cannot alter the parse of a
654
621
  // loaded one.
655
622
  export async function warmGrammarsForRepo(repo: string): Promise<void> {
656
- const { files } = walk(repo, {});
657
- await ensureGrammars(grammarKeysForExts(files.map((f) => f.ext)));
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)));
658
636
  }
659
637
 
660
638
  // Tools that never scan the file tree (git/grep/memory/embed-status only) — they
@@ -665,6 +643,10 @@ const SCANLESS_TOOLS = new Set([
665
643
  "workspaces", "churn", "coupling", "grep",
666
644
  "write_memory", "read_memory", "list_memories", "delete_memory",
667
645
  "embed_status",
646
+ // scan_summary counts and classifies by path only — it never parses, so the
647
+ // grammar warm (a whole extra walk) would be pure overhead. When a scan is
648
+ // already cached getScanSummary reuses it, warm grammars included.
649
+ "scan_summary",
668
650
  ]);
669
651
 
670
652
  async function callTool(name: string, args: Record<string, unknown>, defaultRepo?: string): Promise<string> {
@@ -676,21 +658,26 @@ async function callTool(name: string, args: Record<string, unknown>, defaultRepo
676
658
  const scanOpts = { scope: str(args.scope), include: strArray(args.include), exclude: strArray(args.exclude) };
677
659
  // Scan-needing tools warm the present-language grammars (re-derived per call)
678
660
  // before any scan so extraction takes the AST tier; scan-less tools skip it.
679
- if (!SCANLESS_TOOLS.has(name)) await warmGrammarsForRepo(repo);
661
+ // ONE walk feeds both the warm and the scan below — see warmGrammarsForWalk.
662
+ let walked: WalkResult | undefined;
663
+ if (!SCANLESS_TOOLS.has(name)) {
664
+ walked = walk(repo, {});
665
+ await warmGrammarsForWalk(walked);
666
+ }
680
667
 
681
668
  if (name === "scan_summary") {
682
- const scan = getScan(repo, scanOpts);
669
+ const s = getScanSummary(repo, scanOpts, walked);
683
670
  return JSON.stringify(
684
- { engineVersion: ENGINE_VERSION, commit: scan.commit, fileCount: scan.files.length, languages: scan.languages, capped: scan.capped },
671
+ { engineVersion: ENGINE_VERSION, commit: s.commit, fileCount: s.fileCount, languages: s.languages, capped: s.capped },
685
672
  null,
686
673
  2,
687
674
  );
688
675
  }
689
676
  if (name === "graph") {
690
- return renderGraphJson(getArtifacts(repo, scanOpts).graph);
677
+ return renderGraphJson(getArtifacts(repo, scanOpts, walked).graph);
691
678
  }
692
679
  if (name === "symbols") {
693
- const { symbols } = getArtifacts(repo, scanOpts);
680
+ const { symbols } = getArtifacts(repo, scanOpts, walked);
694
681
  const lookup = str(args.name);
695
682
  if (lookup) {
696
683
  return JSON.stringify({ name: lookup, defs: symbols.defs[lookup] ?? [], refs: symbols.refs[lookup] ?? [] }, null, 2);
@@ -698,7 +685,13 @@ async function callTool(name: string, args: Record<string, unknown>, defaultRepo
698
685
  return JSON.stringify(symbols, null, 2);
699
686
  }
700
687
  if (name === "callers") {
701
- const index = buildCallerIndex(getScan(repo, scanOpts));
688
+ // callerIndexFor, not buildCallerIndex: the public builder is unmemoized, so
689
+ // this rebuilt the whole index on EVERY request (318ms per call on a 20k-file
690
+ // repo in the project's own benchmark). The memoized one is keyed on scan
691
+ // object identity, which the session cache preserves across calls.
692
+ // Recall mode is option-dependent, so it cannot use the memoized index.
693
+ const scan = getScan(repo, scanOpts, walked);
694
+ const index = args.recall === true ? buildCallerIndex(scan, undefined, { recall: true }) : callerIndexFor(scan);
702
695
  const lookup = str(args.name);
703
696
  if (lookup) {
704
697
  const entry = index.get(lookup);
@@ -721,27 +714,28 @@ async function callTool(name: string, args: Record<string, unknown>, defaultRepo
721
714
  if (name === "symbols_overview") {
722
715
  const file = str(args.file);
723
716
  if (!file) throw new Error("`file` is required");
724
- return JSON.stringify(symbolsOverview(getScan(repo, scanOpts), file), null, 2);
717
+ return JSON.stringify(symbolsOverview(getScan(repo, scanOpts, walked), file), null, 2);
725
718
  }
726
719
  if (name === "find_symbol") {
727
720
  const namePath = str(args.namePath);
728
721
  if (!namePath) throw new Error("`namePath` is required");
729
- const matches = findSymbol(getScan(repo, scanOpts), namePath, {
722
+ const matches = findSymbol(getScan(repo, scanOpts, walked), namePath, {
730
723
  substring: args.substring === true,
731
724
  includeBody: args.includeBody === true,
725
+ maxResults: num(args.maxResults),
732
726
  });
733
727
  return JSON.stringify(matches, null, 2);
734
728
  }
735
729
  if (name === "find_references") {
736
730
  const symName = str(args.name);
737
731
  if (!symName) throw new Error("`name` is required");
738
- return JSON.stringify(findReferences(getScan(repo, scanOpts), symName), null, 2);
732
+ return JSON.stringify(findReferences(getScan(repo, scanOpts, walked), symName), null, 2);
739
733
  }
740
734
  if (name === "replace_symbol_body" || name === "insert_after_symbol" || name === "insert_before_symbol") {
741
735
  const namePath = str(args.namePath);
742
736
  const body = typeof args.body === "string" ? args.body : undefined;
743
737
  if (!namePath || body === undefined) throw new Error("`namePath` and `body` are required");
744
- const scan = getScan(repo, scanOpts);
738
+ const scan = getScan(repo, scanOpts, walked);
745
739
  const fn = name === "replace_symbol_body" ? replaceSymbolBody : name === "insert_after_symbol" ? insertAfterSymbol : insertBeforeSymbol;
746
740
  const result = fn(scan, namePath, body, str(args.file));
747
741
  // A write WE just performed must not be trusted to the stat oracle: an
@@ -750,7 +744,7 @@ async function callTool(name: string, args: Record<string, unknown>, defaultRepo
750
744
  // session entry unconditionally — the next call rescans from scratch.
751
745
  // (write_memory needs no invalidation: .codeindex/ is excluded from the
752
746
  // walk, so memories never enter a scan.)
753
- sessionCache = undefined;
747
+ sessionClear();
754
748
  return JSON.stringify(result, null, 2);
755
749
  }
756
750
  if (name === "write_memory") {
@@ -775,26 +769,31 @@ async function callTool(name: string, args: Record<string, unknown>, defaultRepo
775
769
  return JSON.stringify({ deleted: deleteMemory(repo, memName) }, null, 2);
776
770
  }
777
771
  if (name === "dead_code") {
778
- return JSON.stringify(findDeadCode(getScan(repo, scanOpts)), null, 2);
772
+ const all = findDeadCode(getScan(repo, scanOpts, walked));
773
+ const limit = num(args.limit);
774
+ // Additive: without `limit` the payload is exactly what it always was.
775
+ if (limit === undefined || all.length <= limit) return JSON.stringify(all, null, 2);
776
+ return JSON.stringify({ total: all.length, shown: limit, truncated: true, candidates: all.slice(0, limit) }, null, 2);
779
777
  }
780
778
  if (name === "complexity") {
781
- const scan = getScan(repo, scanOpts);
779
+ const scan = getScan(repo, scanOpts, walked);
782
780
  if (args.risk === true) {
783
- const { churn, ok } = gitChurn(repo);
784
- return JSON.stringify({ churnOk: ok, risks: riskHotspots(scan, churn) }, null, 2);
781
+ // `since` was accepted by the CLI's `risk` but silently dropped here.
782
+ const { churn, ok } = gitChurn(repo, { since: str(args.since) });
783
+ return JSON.stringify({ churnOk: ok, risks: riskHotspots(scan, churn, num(args.top)) }, null, 2);
785
784
  }
786
- return JSON.stringify(symbolComplexity(scan, str(args.file)), null, 2);
785
+ return JSON.stringify(symbolComplexity(scan, str(args.file), num(args.top)), null, 2);
787
786
  }
788
787
  if (name === "mermaid") {
789
- const { graph } = getArtifacts(repo, scanOpts);
790
- return renderMermaid(graph, { module: str(args.module) });
788
+ const { graph } = getArtifacts(repo, scanOpts, walked);
789
+ return renderMermaid(graph, { module: str(args.module), maxEdges: num(args.maxEdges) });
791
790
  }
792
791
  if (name === "repo_map") {
793
- const { scan, graph } = getArtifacts(repo, scanOpts);
792
+ const { scan, graph } = getArtifacts(repo, scanOpts, walked);
794
793
  return renderRepoMap(scan, graph, { budgetTokens: typeof args.budgetTokens === "number" ? args.budgetTokens : undefined });
795
794
  }
796
795
  if (name === "hotspots") {
797
- const scan = getScan(repo, scanOpts);
796
+ const scan = getScan(repo, scanOpts, walked);
798
797
  const { churn, ok } = gitChurn(repo, { since: str(args.since) });
799
798
  return JSON.stringify({ churnOk: ok, hotspots: rankHotspots(scan, churn) }, null, 2);
800
799
  }
@@ -805,8 +804,12 @@ async function callTool(name: string, args: Record<string, unknown>, defaultRepo
805
804
  if (name === "grep") {
806
805
  const pattern = str(args.pattern);
807
806
  if (!pattern) throw new Error("`pattern` is required");
807
+ // `scope` was CLI-only: the a205c34 fix folded it into the CLI's glob list
808
+ // but this handler ignored scanOpts entirely.
809
+ const scope = str(args.scope);
810
+ const globs = strArray(args.globs);
808
811
  const hits = grepRepo(repo, pattern, {
809
- globs: strArray(args.globs),
812
+ globs: scope ? [...(globs ?? []), `${scope.replace(/\/+$/, "")}/**`] : globs,
810
813
  ignoreCase: args.ignoreCase === true,
811
814
  maxHits: typeof args.maxHits === "number" ? args.maxHits : undefined,
812
815
  });
@@ -815,7 +818,7 @@ async function callTool(name: string, args: Record<string, unknown>, defaultRepo
815
818
  if (name === "search") {
816
819
  const query = str(args.query);
817
820
  if (!query) throw new Error("`query` is required");
818
- const scan = getScan(repo, scanOpts);
821
+ const scan = getScan(repo, scanOpts, walked);
819
822
  const limit = typeof args.limit === "number" ? args.limit : undefined;
820
823
  const fuzzy = typeof args.fuzzy === "boolean" ? args.fuzzy : undefined;
821
824
  if (args.semantic === true) {
@@ -882,33 +885,258 @@ async function callTool(name: string, args: Record<string, unknown>, defaultRepo
882
885
  return JSON.stringify(status, null, 2);
883
886
  }
884
887
  if (name === "check_rules") {
885
- const rules = parseRules(args.rules); // throws a descriptive error on a malformed payload
886
- const { graph } = getArtifacts(repo, scanOpts);
888
+ // Inline `rules` stays the primary form; `configPath` is the CLI's --config,
889
+ // which had no MCP equivalent, so a repo with a committed rules file had to
890
+ // have it re-pasted into every call.
891
+ const configPath = str(args.configPath);
892
+ let payload: unknown = args.rules;
893
+ if (payload === undefined && configPath) {
894
+ const abs = isAbsolute(configPath) ? configPath : join(repo, configPath);
895
+ try {
896
+ payload = JSON.parse(readFileSync(abs, "utf8"));
897
+ } catch (e) {
898
+ throw new Error(`cannot read rules from ${abs}: ${errMessage(e)}`);
899
+ }
900
+ }
901
+ if (payload === undefined) throw new Error("`rules` (or `configPath`) is required");
902
+ const rules = parseRules(payload); // throws a descriptive error on a malformed payload
903
+ const { graph } = getArtifacts(repo, scanOpts, walked);
887
904
  return JSON.stringify(checkRules(graph, rules), null, 2);
888
905
  }
889
906
  throw new Error(`unknown tool: ${name}`);
890
907
  }
891
908
 
892
- // The advertised tool list. Without a server-level repo pin this is TOOLS
893
- // verbatim (byte-compat for existing consumers). With one, every tool drops
894
- // `repo` from its `required` set and documents the default — so a client that
895
- // omits it is spec-correct rather than relying on the server being lenient.
896
- // The pin is reflected in the schema, not just honoured at call time.
897
- function toolsFor(defaultRepo?: string): readonly unknown[] {
898
- if (!defaultRepo) return TOOLS;
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;
899
1043
  return TOOLS.map((t) => ({
900
1044
  ...t,
901
- inputSchema: {
902
- ...t.inputSchema,
903
- properties: {
904
- ...t.inputSchema.properties,
905
- repo: { type: "string", description: `Absolute path to the repository root (optional — defaults to ${defaultRepo})` },
906
- },
907
- required: (t.inputSchema.required as readonly string[]).filter((r) => r !== "repo"),
908
- },
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
+ },
909
1060
  }));
910
1061
  }
911
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
+
912
1140
  export interface McpServerOptions {
913
1141
  // Override the serverInfo announced in the initialize response — for
914
1142
  // downstream consumers embedding this server under their own identity.
@@ -919,6 +1147,9 @@ export interface McpServerOptions {
919
1147
  // spawn one server per workspace — `codeindex mcp --repo <dir>` — instead of
920
1148
  // requiring the agent to thread an absolute path through every single call.
921
1149
  defaultRepo?: string;
1150
+ // Cap on a single tool response, in bytes (default DEFAULT_MAX_RESPONSE_BYTES).
1151
+ // Responses under it are untouched; see capResponse for what happens above it.
1152
+ maxResponseBytes?: number;
922
1153
  }
923
1154
 
924
1155
  export async function runMcpServer(opts: McpServerOptions = {}): Promise<void> {
@@ -926,8 +1157,13 @@ export async function runMcpServer(opts: McpServerOptions = {}): Promise<void> {
926
1157
  name: opts.serverInfo?.name ?? "codeindex",
927
1158
  version: opts.serverInfo?.version ?? ENGINE_VERSION,
928
1159
  };
929
- // Derived ONCE: the pin cannot change mid-session, so neither can the list.
930
- const tools = toolsFor(opts.defaultRepo);
1160
+ // The negotiated protocol version, settled by `initialize` and fixed for the
1161
+ // session. Until a client says otherwise we assume the oldest revision, so a
1162
+ // client that skips the handshake sees exactly the pre-negotiation server.
1163
+ let protocolVersion: string = PROTOCOL_VERSIONS[0];
1164
+ // Rebuilt when negotiation lands: the pin cannot change mid-session, but the
1165
+ // fields we are allowed to advertise depend on the version.
1166
+ let tools = toolsFor(opts.defaultRepo, protocolVersion);
931
1167
  // No startup warm: each scan-needing tool warms the present-language grammars
932
1168
  // for its repo before it runs (warmGrammarsForRepo re-derives them per call),
933
1169
  // so a session that never scans — or only touches one language — loads no
@@ -959,10 +1195,12 @@ export async function runMcpServer(opts: McpServerOptions = {}): Promise<void> {
959
1195
 
960
1196
  try {
961
1197
  if (req.method === "initialize") {
1198
+ protocolVersion = negotiateProtocol(req.params?.protocolVersion);
1199
+ tools = toolsFor(opts.defaultRepo, protocolVersion);
962
1200
  send({
963
1201
  id: req.id,
964
1202
  result: {
965
- protocolVersion: "2024-11-05",
1203
+ protocolVersion,
966
1204
  capabilities: { tools: {} },
967
1205
  serverInfo,
968
1206
  },
@@ -976,8 +1214,27 @@ export async function runMcpServer(opts: McpServerOptions = {}): Promise<void> {
976
1214
  const name = str(params.name) ?? "";
977
1215
  const args = (params.arguments ?? {}) as Record<string, unknown>;
978
1216
  try {
979
- const text = await callTool(name, args, opts.defaultRepo);
980
- send({ id: req.id, result: { content: [{ type: "text", text }] } });
1217
+ const decl = (tools as { name: string; inputSchema: { properties?: Record<string, unknown> } }[]).find(
1218
+ (t) => t.name === name,
1219
+ );
1220
+ const invalid = decl ? validateArgs(decl.inputSchema, args) : undefined;
1221
+ if (invalid) throw new Error(invalid);
1222
+ const raw = await callTool(name, args, opts.defaultRepo);
1223
+ const repo = str(args.repo) ?? opts.defaultRepo ?? "";
1224
+ const text = capResponse(raw, name, repo, opts.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES);
1225
+ // A capped whole-repo response points at an artifact already on disk.
1226
+ // From 2025-06-18 the protocol has a content type that says exactly
1227
+ // that, so the client can fetch the bytes instead of re-asking.
1228
+ //
1229
+ // Gated on `text !== raw` — i.e. capResponse actually replaced the
1230
+ // payload. Otherwise a normal 900 KB graph would be JSON.parsed on
1231
+ // every single call just to discover it was not truncated.
1232
+ const link =
1233
+ text !== raw && protocolVersion >= RICH_TOOLS_SINCE ? resourceLinkFor(text, name) : undefined;
1234
+ send({
1235
+ id: req.id,
1236
+ result: { content: link ? [{ type: "text", text }, link] : [{ type: "text", text }] },
1237
+ });
981
1238
  } catch (e) {
982
1239
  send({
983
1240
  id: req.id,