@maxgfr/codeindex 2.17.0 → 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/README.md +26 -3
- package/docs/MIGRATION.md +107 -0
- package/package.json +1 -1
- package/scripts/engine.d.mts +243 -19
- package/scripts/engine.mjs +1768 -723
- package/src/ast/extract.ts +180 -142
- package/src/coupling.ts +0 -0
- package/src/delta.ts +417 -0
- package/src/derived.ts +17 -0
- package/src/engine-cli.ts +201 -26
- package/src/engine.ts +31 -4
- package/src/extract/code.ts +4 -1
- package/src/graph.ts +0 -0
- package/src/mcp.ts +469 -212
- package/src/pool.ts +271 -0
- package/src/preload.ts +159 -0
- package/src/render/scip.ts +80 -20
- package/src/render/symbols-json.ts +3 -2
- package/src/scan.ts +142 -17
- package/src/traverse.ts +164 -0
- package/src/types.ts +1 -1
- package/src/viz.ts +91 -1
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
|
|
5
|
-
//
|
|
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:
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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 {
|
|
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: {
|
|
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: {
|
|
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"
|
|
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 =
|
|
423
|
-
type SessionCacheMap =
|
|
459
|
+
type SessionCacheEntry = PersistedCacheEntry;
|
|
460
|
+
type SessionCacheMap = PersistedCacheMap;
|
|
424
461
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
//
|
|
452
|
-
//
|
|
453
|
-
|
|
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
|
-
|
|
587
|
-
|
|
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)
|
|
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 (
|
|
607
|
-
return
|
|
551
|
+
if (hit.scan.commit !== fresh.commit) hit.scan.commit = fresh.commit;
|
|
552
|
+
return hit.scan;
|
|
608
553
|
}
|
|
609
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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
|
-
|
|
657
|
-
|
|
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
|
-
|
|
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
|
|
669
|
+
const s = getScanSummary(repo, scanOpts, walked);
|
|
683
670
|
return JSON.stringify(
|
|
684
|
-
{ engineVersion: ENGINE_VERSION, commit:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
784
|
-
|
|
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:
|
|
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
|
-
|
|
886
|
-
|
|
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
|
-
//
|
|
893
|
-
//
|
|
894
|
-
//
|
|
895
|
-
//
|
|
896
|
-
//
|
|
897
|
-
|
|
898
|
-
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
//
|
|
930
|
-
|
|
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
|
|
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
|
|
980
|
-
|
|
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,
|