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