clast-ai 0.2.4 → 0.3.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.
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import { resolve } from 'node:path';
3
+ import { existsSync, mkdirSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
4
5
  import { pathToFileURL } from 'node:url';
5
6
  import { z } from 'zod';
6
7
  import { Clast } from '../engine.js';
@@ -17,7 +18,7 @@ import { stats } from '../tools/stats.js';
17
18
  import { VERSION } from '../version.js';
18
19
  const INSTRUCTIONS = `Clast is a pre-built, always-fresh code index for this repository. One call answers what otherwise takes several grep + read round trips, with exact file:line locations. Use it first for code navigation:
19
20
  - Unfamiliar repo or directory -> clast_overview, then clast_file_summary.
20
- - Where is X / what is it called -> clast_search (names, partial names, Class.method, or words describing behaviour).
21
+ - Where is X / what is it called -> clast_search (names, partial names, Class.method, words describing behaviour, or a common name plus context: "write socket").
21
22
  - Show me X, or before editing X -> clast_get_context (source of that symbol plus callers and callees).
22
23
  - Who calls X -> clast_call_graph (depth N), clast_references (every use, including types and imports).
23
24
  - Before changing or deleting X: what else must change, and which tests to run -> clast_impact.
@@ -29,6 +30,7 @@ const INSTRUCTIONS = `Clast is a pre-built, always-fresh code index for this rep
29
30
  When adding something new (a subclass, a handler, a provider): find the siblings with clast_hierarchy or clast_search and read one with clast_get_context.
30
31
  When renaming a symbol or changing a signature: if the name is distinctive, do NOT use clast - one Grep for the name lists every site, then edit. Use clast_impact first only when the name is common (get, run, close) so that Grep would drown in unrelated hits, when overrides or interface implementations must change with it, or to choose the tests to run.
31
32
  Most tools take several names at once (arrays). Symbols: name, Container.name, or path/to/file.ext:name. "?" marks a name-based guess. The index follows file changes, including your own edits.`;
33
+ const DORMANT_INSTRUCTIONS = 'Clast (code index) is installed but off for this project: there is no .clast folder in the project root. Do not mention it. If the user asks to use clast, call clast_enable.';
32
34
  const text = (t) => ({ content: [{ type: 'text', text: t }] });
33
35
  const READ_ONLY = { readOnlyHint: true, idempotentHint: true, openWorldHint: false };
34
36
  // Claude Code defers MCP tools behind its tool search by default. Measured on vscode and OnAir
@@ -42,271 +44,336 @@ export async function startServer() {
42
44
  console.error(`[clast] ${msg}`);
43
45
  };
44
46
  log(`v${VERSION} project root: ${projectRoot}`);
45
- const clast = new Clast(projectRoot);
46
- const ctx = new ToolContext(clast);
47
- const watcher = new FileWatcher(clast.indexer, clast.config.watch.debounceMs);
48
- const commentGen = new CommentGenerator(clast.db, clast.config, ctx.lines);
49
- const server = new McpServer({ name: 'clast', version: VERSION }, { instructions: INSTRUCTIONS });
50
- const names = z.union([z.string(), z.array(z.string()).max(20)]);
51
- const guard = (fn, gated = true) => async (args) => {
52
- try {
53
- if (gated) {
54
- const building = await ctx.gate();
55
- if (building)
56
- return text(building);
47
+ // Opt-in per project. The client starts this process in every project, code or not. It indexes and
48
+ // offers its tools only where a `.clast` folder exists in the project root (`/clast:use` or the
49
+ // `clast_enable` tool creates it; the index lives there). Anywhere else it stays dormant: one small
50
+ // tool, no index, no watcher. CLAST_ALWAYS=1 or an explicit CLAST_DB_PATH switches it on regardless.
51
+ const marker = join(projectRoot, '.clast');
52
+ const wanted = () => process.env.CLAST_ALWAYS === '1' || !!process.env.CLAST_DB_PATH || existsSync(marker);
53
+ const startActive = wanted();
54
+ const server = new McpServer({ name: 'clast', version: VERSION }, { instructions: startActive ? INSTRUCTIONS : DORMANT_INSTRUCTIONS });
55
+ let active = null;
56
+ let connected = false;
57
+ let begin = null;
58
+ const activate = () => {
59
+ if (active)
60
+ return;
61
+ mkdirSync(marker, { recursive: true });
62
+ const clast = new Clast(projectRoot);
63
+ const ctx = new ToolContext(clast);
64
+ const watcher = new FileWatcher(clast.indexer, clast.config.watch.debounceMs);
65
+ const commentGen = new CommentGenerator(clast.db, clast.config, ctx.lines);
66
+ active = { clast, watcher };
67
+ const names = z.union([z.string(), z.array(z.string()).max(20)]);
68
+ const guard = (fn, gated = true) => async (args) => {
69
+ try {
70
+ if (gated) {
71
+ const building = await ctx.gate();
72
+ if (building)
73
+ return text(building);
74
+ }
75
+ return text(await fn(args));
57
76
  }
58
- return text(await fn(args));
59
- }
60
- catch (err) {
61
- log(`tool error: ${err instanceof Error ? err.stack ?? err.message : err}`);
62
- return { ...text(`error: ${err instanceof Error ? err.message : String(err)}`), isError: true };
63
- }
64
- };
65
- server.registerTool('clast_search', {
66
- title: 'Search symbols',
67
- description: 'Ranked symbol search. Query by exact, partial or abbreviated name ("parseConf", "pcfg"), typo, "Class.method", or plain words describing behaviour (matched against names, signatures, docs and the identifiers used in function bodies). Returns kind, qualified name, file:line, signature and doc summary. Prefer over grep for finding definitions. Array = several searches.',
68
- inputSchema: {
69
- query: names.describe('Name, partial name, Container.name, or descriptive words. Array = several searches.'),
70
- kind: z.enum(SYMBOL_KINDS).optional().describe('Only this kind of symbol'),
71
- path: z.string().optional().describe('Only symbols in files under / matching this path (e.g. "src/db" or "parser.ts")'),
72
- lang: z.string().optional().describe('Only this language id (typescript, python, go, ...)'),
73
- exported: z.boolean().optional().describe('Only exported / public symbols'),
74
- limit: z.number().int().min(1).max(100).default(10).describe('Max results per query'),
75
- },
76
- annotations: READ_ONLY,
77
- _meta: ALWAYS_LOAD,
78
- }, guard(args => search(ctx, args)));
79
- server.registerTool('clast_get_context', {
80
- title: 'Symbol source and context',
81
- description: 'One symbol in one call: its exact source with line numbers plus callers, callees, overloads, overrides and, for classes, members and sub/supertypes. Use INSTEAD of reading a whole file, and before editing a symbol. Address as "name", "Class.method", "path/file.ext:name" or "path/file.ext:<line>". Array = several symbols.',
82
- inputSchema: {
83
- name: names.describe('Symbol name, Container.name, or path:name. Array = several symbols.'),
84
- path: z.string().optional().describe('Disambiguate by file or directory path'),
85
- includeBody: z.boolean().default(true).describe('Include source code (false = signature + docs only)'),
86
- maxLines: z.number().int().min(5).max(2000).default(120).describe('Max source lines per symbol; large classes fall back to a member outline'),
87
- includeCalls: z.boolean().default(true).describe('Include callers and callees'),
88
- },
89
- annotations: READ_ONLY,
90
- _meta: ALWAYS_LOAD,
91
- }, guard(args => getContext(ctx, args)));
92
- server.registerTool('clast_call_graph', {
93
- title: 'Call graph',
94
- description: 'Callers and/or callees as a tree with file:line per edge; depth > 1 follows the chain. Use for the blast radius before a change (every caller to update) or to trace a path. Resolved by receiver type, imports and inheritance; "?" marks name-only matches. Test callers are summarised. Library names list their call sites.',
95
- inputSchema: {
96
- name: z.string().describe('Symbol name, Container.name, or path:name'),
97
- path: z.string().optional().describe('Disambiguate by file or directory path'),
98
- direction: z.enum(['callers', 'callees', 'both']).default('both'),
99
- depth: z.number().int().min(1).max(6).default(1).describe('Levels to traverse (1 = direct only; 2-3 to follow a chain)'),
100
- limit: z.number().int().min(1).max(2000).default(50).describe('Max nodes per direction'),
101
- includeTests: z.boolean().optional().describe('true = list test callers in full, false = drop them; default: one summary line naming the test files'),
102
- within: z.string().optional().describe('Only callers located under this path (e.g. "src/server")'),
103
- uncertain: z.boolean().default(false).describe('List every unconfirmed caller instead of a short summary'),
104
- },
105
- annotations: READ_ONLY,
106
- _meta: ALWAYS_LOAD,
107
- }, guard(args => callGraph(ctx, args)));
108
- server.registerTool('clast_hierarchy', {
109
- title: 'Type hierarchy',
110
- description: 'Supertypes and all direct and indirect subtypes of a class, interface, struct or trait as a tree (including Go interface satisfaction). For a method: the same method on related types (declaration, overrides, implementations). Use for "what implements X", "which classes override Y", "which base class do the others extend".',
111
- inputSchema: {
112
- name: z.string().describe('Type or method: name, Container.name, or path:name'),
113
- path: z.string().optional().describe('Disambiguate by file or directory path'),
114
- direction: z.enum(['supertypes', 'subtypes', 'both']).default('both'),
115
- depth: z.number().int().min(1).max(8).default(3).describe('Levels to expand'),
116
- limit: z.number().int().min(1).max(1000).default(80).describe('Max lines per direction'),
117
- within: z.string().optional().describe('Only subtypes / overrides located under this path'),
118
- },
119
- annotations: READ_ONLY,
120
- _meta: ALWAYS_LOAD,
121
- }, guard(args => hierarchy(ctx, args)));
122
- server.registerTool('clast_references', {
123
- title: 'Find references',
124
- description: 'Every use of an identifier (calls, type annotations, imports, assignments) grouped by file, each line with its enclosing function/class. Matches real identifiers only, not substrings, strings or comments. Use for variables, types, fields, constants and enum members, or when clast_call_graph is too narrow.',
125
- inputSchema: {
126
- name: z.string().describe('Identifier (or Container.name; matching is by the last name segment)'),
127
- path: z.string().optional().describe('Only references in files under / matching this path'),
128
- limit: z.number().int().min(1).max(500).default(40).describe('Max reference lines'),
129
- includeTests: z.boolean().default(true),
130
- },
131
- annotations: READ_ONLY,
132
- _meta: ALWAYS_LOAD,
133
- }, guard(args => references(ctx, args)));
134
- server.registerTool('clast_file_summary', {
135
- title: 'File or directory outline',
136
- description: 'Outline of a file: symbols as a tree of signatures with line ranges and doc summaries, plus its imports and importers. Use INSTEAD of reading a file to learn its structure. For a directory: its files with main symbols and sub-directories with sizes. Partial paths work ("db/queries").',
137
- inputSchema: {
138
- path: z.string().describe('File or directory path (relative; partial paths are matched)'),
139
- members: z
140
- .enum(['auto', 'all', 'callable'])
141
- .default('auto')
142
- .describe('Whether to list fields/properties/constants inside classes (auto hides them in very large files)'),
143
- limit: z.number().int().min(20).max(5000).default(300).describe('Max outline lines for a file'),
144
- },
145
- annotations: READ_ONLY,
146
- _meta: ALWAYS_LOAD,
147
- }, guard(args => fileSummary(ctx, args)));
148
- server.registerTool('clast_overview', {
149
- title: 'Repository map',
150
- description: 'Map of the repository or a sub-directory: languages, layout with sizes, most referenced classes/functions, hub files, entry points. Call first in an unfamiliar codebase.',
151
- inputSchema: {
152
- path: z.string().optional().describe('Sub-directory to map (default: whole repository)'),
153
- },
154
- annotations: READ_ONLY,
155
- _meta: ALWAYS_LOAD,
156
- }, guard(args => overview(ctx, args)));
157
- server.registerTool('clast_grep', {
158
- title: 'Text search with symbol context',
159
- description: 'Text search over all text files (literal or regex, smart-case); each hit is labelled with its enclosing function/class. Use for strings, comments, error messages and config keys; for identifiers prefer clast_search / clast_references.',
160
- inputSchema: {
161
- pattern: z.string().describe('Text to find (literal unless regex=true)'),
162
- regex: z.boolean().default(false).describe('Interpret pattern as a JavaScript regular expression'),
163
- path: z.string().optional().describe('Only files under / matching this path'),
164
- lang: z.string().optional().describe('Only this language id'),
165
- caseSensitive: z.boolean().optional().describe('Default: case-insensitive unless the pattern contains uppercase'),
166
- limit: z.number().int().min(1).max(500).default(50).describe('Max matching lines'),
167
- includeTests: z.boolean().default(true),
168
- },
169
- annotations: READ_ONLY,
170
- _meta: ALWAYS_LOAD,
171
- }, guard(args => grep(ctx, args)));
172
- server.registerTool('clast_stats', {
173
- title: 'Count and aggregate',
174
- description: 'Exact counts for "how many ..." questions, grouped by directory, file, kind or name; do not tally search results by hand. what = inventory (size, languages, production vs tests, symbols by kind, longest functions), symbols (name pattern "*Receiver|*Service" and/or kind), subtypes (direct and indirect subclasses / implementers), calls (call sites of "query|execute", optionally on a receiver), text (regex), imports (external dependencies), endpoints (route declarations of common web frameworks), database (SQL, schema, ORM models, query calls; heuristic).',
175
- inputSchema: {
176
- what: z.enum(['inventory', 'symbols', 'subtypes', 'calls', 'text', 'imports', 'endpoints', 'database']).optional()
177
- .describe('What to count (default inventory, or symbols when name is given)'),
178
- name: z.string().optional().describe('symbols: name pattern with * and | ; subtypes: base type; calls: called name(s); text: regular expression'),
179
- kind: z.string().optional().describe('symbols: only these kinds, comma separated (class, interface, function, method, ...)'),
180
- on: z.string().optional().describe('calls: only on receivers / receiver types matching this pattern'),
181
- path: z.string().optional().describe('Only files under / matching this path'),
182
- lang: z.string().optional().describe('Only this language id'),
183
- groupBy: z.enum(['dir', 'file', 'kind', 'lang', 'name', 'container', 'callee', 'caller', 'receiver', 'match']).optional()
184
- .describe('Grouping of the main table (default dir)'),
185
- depth: z.number().int().min(1).max(12).optional().describe('Directory depth for groupBy=dir (default 2)'),
186
- limit: z.number().int().min(1).max(500).optional().describe('Rows per table (default 25)'),
187
- includeTests: z.boolean().optional().describe('Include tests/examples in the tables (default: counted separately)'),
188
- },
189
- annotations: READ_ONLY,
190
- _meta: ALWAYS_LOAD,
191
- }, guard(args => stats(ctx, args)));
192
- server.registerTool('clast_impact', {
193
- title: 'Impact of changing a symbol',
194
- description: 'Before changing a signature, behaviour or deleting a symbol: one answer with the declarations / overrides / implementations that must change together, every direct caller by file and line, how far the change reaches through indirect callers, and the test files to run (those that call it directly or through its callers).',
195
- inputSchema: {
196
- name: z.string().describe('Symbol: name, Container.name, or path:name'),
197
- path: z.string().optional().describe('Disambiguate by file or directory path'),
198
- depth: z.number().int().min(1).max(6).optional().describe('Caller levels to follow (default 3)'),
199
- limit: z.number().int().min(1).max(500).optional().describe('Max lines per section (default 30)'),
200
- },
201
- annotations: READ_ONLY,
202
- _meta: ALWAYS_LOAD,
203
- }, guard(args => impact(ctx, args)));
204
- server.registerTool('clast_unused', {
205
- title: 'Unused code',
206
- description: 'Dead code: functions, methods and types that nothing references (or that only other unused code references), grouped by file with line ranges; plus code used only by tests, and names that only appear in strings or comments. Entry points, decorated / attribute-marked symbols and overrides of library types are left out. Use after a refactor or to clean up a directory.',
207
- inputSchema: {
208
- path: z.string().optional().describe('Only judge symbols under / matching this path'),
209
- lang: z.string().optional().describe('Only this language id'),
210
- kind: z.string().optional().describe('Kinds to judge, comma separated (default function, method, class, struct, interface, trait, enum, type)'),
211
- exported: z.boolean().optional().describe('false: leave out exported / public symbols'),
212
- limit: z.number().int().min(1).max(1000).optional().describe('Max symbols listed (default 60)'),
213
- },
214
- annotations: READ_ONLY,
215
- _meta: ALWAYS_LOAD,
216
- }, guard(args => unused(ctx, args)));
217
- server.registerTool('clast_changes', {
218
- title: 'Changed symbols and impact',
219
- description: 'Map the current git diff to code structure: which functions/classes were touched (uncommitted changes by default, or relative to a base ref such as "main"), which callers outside the change may be affected, and which test files exercise the changed symbols. Use when reviewing a change, before committing, or to decide which tests to run.',
220
- inputSchema: {
221
- base: z.string().optional().describe('Git ref to diff against (default HEAD = uncommitted changes; e.g. "main", "HEAD~3")'),
222
- },
223
- annotations: { readOnlyHint: true, openWorldHint: false },
224
- }, guard(args => changes(ctx, args)));
225
- server.registerTool('clast_status', {
226
- title: 'Index status',
227
- description: 'Index statistics and health: files, symbols, languages, whether indexing is in progress, watcher state.',
228
- inputSchema: {},
229
- annotations: READ_ONLY,
230
- }, guard(() => status(ctx, watcher), false));
231
- server.registerTool('clast_reindex', {
232
- title: 'Re-index',
233
- description: 'Re-scan the repository (incremental: only changed files are re-parsed) or force re-index one file. Rarely needed - the index follows file changes automatically and re-checks files when they are queried. Use force=true to rebuild from scratch.',
234
- inputSchema: {
235
- path: z.string().optional().describe('Single file to re-index (default: whole repository)'),
236
- force: z.boolean().default(false).describe('Re-parse every file even if unchanged'),
237
- },
238
- annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false },
239
- }, guard(args => reindex(ctx, args), false));
240
- server.registerTool('clast_comment', {
241
- title: 'Get or generate documentation',
242
- description: 'Return the documentation comment of a symbol. With generate=true and a local LLM configured (llm.model in clast.config.json), generates and stores a doc comment for undocumented symbols.',
243
- inputSchema: {
244
- name: z.string().describe('Symbol name, Container.name, or path:name'),
245
- generate: z.boolean().default(false).describe('Generate with the configured LLM if no comment exists'),
246
- },
247
- annotations: { readOnlyHint: false, openWorldHint: true },
248
- }, guard(async ({ name, generate }) => {
249
- await ctx.ready();
250
- const matches = findSymbols(ctx.db, name);
251
- if (matches.length === 0)
252
- return `"${name}": no symbol with this name.`;
253
- const out = [];
254
- for (const sym of matches.slice(0, 5)) {
255
- out.push(symLine(sym));
256
- const stored = commentGen.stored(sym);
257
- if (sym.doc)
258
- out.push(sym.doc);
259
- else if (stored)
260
- out.push(`[generated] ${stored}`);
261
- else if (generate) {
262
- if (!commentGen.isAvailable)
263
- out.push('(no comment; LLM not configured - set llm.model in clast.config.json)');
77
+ catch (err) {
78
+ log(`tool error: ${err instanceof Error ? err.stack ?? err.message : err}`);
79
+ return { ...text(`error: ${err instanceof Error ? err.message : String(err)}`), isError: true };
80
+ }
81
+ };
82
+ server.registerTool('clast_search', {
83
+ title: 'Search symbols',
84
+ description: 'Ranked symbol search. Query by exact, partial or abbreviated name ("parseConf", "pcfg"), typo, "Class.method", or plain words describing behaviour (matched against names, signatures, docs and the identifiers used in function bodies). Returns kind, qualified name, file:line, signature and doc summary. Prefer over grep for finding definitions. Array = several searches.',
85
+ inputSchema: {
86
+ query: names.describe('Name, partial name, Container.name, or descriptive words. Array = several searches.'),
87
+ kind: z.enum(SYMBOL_KINDS).optional().describe('Only this kind of symbol'),
88
+ path: z.string().optional().describe('Only symbols in files under / matching this path (e.g. "src/db" or "parser.ts")'),
89
+ lang: z.string().optional().describe('Only this language id (typescript, python, go, ...)'),
90
+ exported: z.boolean().optional().describe('Only exported / public symbols'),
91
+ limit: z.number().int().min(1).max(100).default(10).describe('Max results per query'),
92
+ },
93
+ annotations: READ_ONLY,
94
+ _meta: ALWAYS_LOAD,
95
+ }, guard(args => search(ctx, args)));
96
+ server.registerTool('clast_get_context', {
97
+ title: 'Symbol source and context',
98
+ description: 'One symbol in one call: its exact source with line numbers plus callers, callees, overloads, overrides and, for classes, members and sub/supertypes. Use INSTEAD of reading a whole file, and before editing a symbol. Address as "name", "Class.method", "path/file.ext:name" or "path/file.ext:<line>". Array = several symbols.',
99
+ inputSchema: {
100
+ name: names.describe('Symbol name, Container.name, or path:name. Array = several symbols.'),
101
+ path: z.string().optional().describe('Disambiguate by file or directory path'),
102
+ includeBody: z.boolean().default(true).describe('Include source code (false = signature + docs only)'),
103
+ maxLines: z.number().int().min(5).max(2000).default(120).describe('Max source lines per symbol; large classes fall back to a member outline'),
104
+ includeCalls: z.boolean().default(true).describe('Include callers and callees'),
105
+ },
106
+ annotations: READ_ONLY,
107
+ _meta: ALWAYS_LOAD,
108
+ }, guard(args => getContext(ctx, args)));
109
+ server.registerTool('clast_call_graph', {
110
+ title: 'Call graph',
111
+ description: 'Callers and/or callees as a tree with file:line per edge; depth > 1 follows the chain. Use for the blast radius before a change (every caller to update) or to trace a path. Resolved by receiver type, imports and inheritance; "?" marks name-only matches. Test callers are summarised. Library names list their call sites.',
112
+ inputSchema: {
113
+ name: z.string().describe('Symbol name, Container.name, or path:name'),
114
+ path: z.string().optional().describe('Disambiguate by file or directory path'),
115
+ direction: z.enum(['callers', 'callees', 'both']).default('both'),
116
+ depth: z.number().int().min(1).max(6).default(1).describe('Levels to traverse (1 = direct only; 2-3 to follow a chain)'),
117
+ limit: z.number().int().min(1).max(2000).default(50).describe('Max nodes per direction'),
118
+ includeTests: z.boolean().optional().describe('true = list test callers in full, false = drop them; default: one summary line naming the test files'),
119
+ within: z.string().optional().describe('Only callers located under this path (e.g. "src/server")'),
120
+ uncertain: z.boolean().default(false).describe('List every unconfirmed caller instead of a short summary'),
121
+ },
122
+ annotations: READ_ONLY,
123
+ _meta: ALWAYS_LOAD,
124
+ }, guard(args => callGraph(ctx, args)));
125
+ server.registerTool('clast_hierarchy', {
126
+ title: 'Type hierarchy',
127
+ description: 'Supertypes and all direct and indirect subtypes of a class, interface, struct or trait as a tree (including Go interface satisfaction). For a method: the same method on related types (declaration, overrides, implementations). Use for "what implements X", "which classes override Y", "which base class do the others extend".',
128
+ inputSchema: {
129
+ name: z.string().describe('Type or method: name, Container.name, or path:name'),
130
+ path: z.string().optional().describe('Disambiguate by file or directory path'),
131
+ direction: z.enum(['supertypes', 'subtypes', 'both']).default('both'),
132
+ depth: z.number().int().min(1).max(8).default(3).describe('Levels to expand'),
133
+ limit: z.number().int().min(1).max(1000).default(80).describe('Max lines per direction'),
134
+ within: z.string().optional().describe('Only subtypes / overrides located under this path'),
135
+ },
136
+ annotations: READ_ONLY,
137
+ _meta: ALWAYS_LOAD,
138
+ }, guard(args => hierarchy(ctx, args)));
139
+ server.registerTool('clast_references', {
140
+ title: 'Find references',
141
+ description: 'Every use of an identifier (calls, type annotations, imports, assignments) grouped by file, each line with its enclosing function/class. Matches real identifiers only, not substrings, strings or comments. Use for variables, types, fields, constants and enum members, or when clast_call_graph is too narrow.',
142
+ inputSchema: {
143
+ name: z.string().describe('Identifier (or Container.name; matching is by the last name segment)'),
144
+ path: z.string().optional().describe('Only references in files under / matching this path'),
145
+ limit: z.number().int().min(1).max(500).default(40).describe('Max reference lines'),
146
+ includeTests: z.boolean().default(true),
147
+ },
148
+ annotations: READ_ONLY,
149
+ _meta: ALWAYS_LOAD,
150
+ }, guard(args => references(ctx, args)));
151
+ server.registerTool('clast_file_summary', {
152
+ title: 'File or directory outline',
153
+ description: 'Outline of a file: symbols as a tree of signatures with line ranges and doc summaries, plus its imports and importers. Use INSTEAD of reading a file to learn its structure. For a directory: its files with main symbols and sub-directories with sizes. Partial paths work ("db/queries").',
154
+ inputSchema: {
155
+ path: z.string().describe('File or directory path (relative; partial paths are matched)'),
156
+ members: z
157
+ .enum(['auto', 'all', 'callable'])
158
+ .default('auto')
159
+ .describe('Whether to list fields/properties/constants inside classes (auto hides them in very large files)'),
160
+ limit: z.number().int().min(20).max(5000).default(300).describe('Max outline lines for a file'),
161
+ },
162
+ annotations: READ_ONLY,
163
+ _meta: ALWAYS_LOAD,
164
+ }, guard(args => fileSummary(ctx, args)));
165
+ server.registerTool('clast_overview', {
166
+ title: 'Repository map',
167
+ description: 'Map of the repository or a sub-directory: languages, layout with sizes, most referenced classes/functions, hub files, entry points. Call first in an unfamiliar codebase.',
168
+ inputSchema: {
169
+ path: z.string().optional().describe('Sub-directory to map (default: whole repository)'),
170
+ },
171
+ annotations: READ_ONLY,
172
+ _meta: ALWAYS_LOAD,
173
+ }, guard(args => overview(ctx, args)));
174
+ server.registerTool('clast_grep', {
175
+ title: 'Text search with symbol context',
176
+ description: 'Text search over all text files (literal or regex, smart-case); each hit is labelled with its enclosing function/class. Use for strings, comments, error messages and config keys; for identifiers prefer clast_search / clast_references.',
177
+ inputSchema: {
178
+ pattern: z.string().describe('Text to find (literal unless regex=true)'),
179
+ regex: z.boolean().default(false).describe('Interpret pattern as a JavaScript regular expression'),
180
+ path: z.string().optional().describe('Only files under / matching this path'),
181
+ lang: z.string().optional().describe('Only this language id'),
182
+ caseSensitive: z.boolean().optional().describe('Default: case-insensitive unless the pattern contains uppercase'),
183
+ limit: z.number().int().min(1).max(500).default(50).describe('Max matching lines'),
184
+ includeTests: z.boolean().default(true),
185
+ },
186
+ annotations: READ_ONLY,
187
+ _meta: ALWAYS_LOAD,
188
+ }, guard(args => grep(ctx, args)));
189
+ server.registerTool('clast_stats', {
190
+ title: 'Count and aggregate',
191
+ description: 'Exact counts for "how many ..." questions, grouped by directory, file, kind or name; do not tally search results by hand. what = inventory (size, languages, production vs tests, symbols by kind, longest functions), symbols (name pattern "*Receiver|*Service" and/or kind), subtypes (direct and indirect subclasses / implementers), calls (call sites of "query|execute", optionally on a receiver), text (regex), imports (external dependencies), endpoints (route declarations of common web frameworks), database (SQL, schema, ORM models, query calls; heuristic).',
192
+ inputSchema: {
193
+ what: z.enum(['inventory', 'symbols', 'subtypes', 'calls', 'text', 'imports', 'endpoints', 'database']).optional()
194
+ .describe('What to count (default inventory, or symbols when name is given)'),
195
+ name: z.string().optional().describe('symbols: name pattern with * and | ; subtypes: base type; calls: called name(s); text: regular expression'),
196
+ kind: z.string().optional().describe('symbols: only these kinds, comma separated (class, interface, function, method, ...)'),
197
+ on: z.string().optional().describe('calls: only on receivers / receiver types matching this pattern'),
198
+ path: z.string().optional().describe('Only files under / matching this path'),
199
+ lang: z.string().optional().describe('Only this language id'),
200
+ groupBy: z.enum(['dir', 'file', 'kind', 'lang', 'name', 'container', 'callee', 'caller', 'receiver', 'match']).optional()
201
+ .describe('Grouping of the main table (default dir)'),
202
+ depth: z.number().int().min(1).max(12).optional().describe('Directory depth for groupBy=dir (default 2)'),
203
+ limit: z.number().int().min(1).max(500).optional().describe('Rows per table (default 25)'),
204
+ includeTests: z.boolean().optional().describe('Include tests/examples in the tables (default: counted separately)'),
205
+ },
206
+ annotations: READ_ONLY,
207
+ _meta: ALWAYS_LOAD,
208
+ }, guard(args => stats(ctx, args)));
209
+ server.registerTool('clast_impact', {
210
+ title: 'Impact of changing a symbol',
211
+ description: 'Before changing a signature, behaviour or deleting a symbol: one answer with the declarations / overrides / implementations that must change together, every direct caller by file and line, how far the change reaches through indirect callers, and the test files to run (those that call it directly or through its callers).',
212
+ inputSchema: {
213
+ name: z.string().describe('Symbol: name, Container.name, or path:name'),
214
+ path: z.string().optional().describe('Disambiguate by file or directory path'),
215
+ depth: z.number().int().min(1).max(6).optional().describe('Caller levels to follow (default 3)'),
216
+ limit: z.number().int().min(1).max(500).optional().describe('Max lines per section (default 30)'),
217
+ },
218
+ annotations: READ_ONLY,
219
+ _meta: ALWAYS_LOAD,
220
+ }, guard(args => impact(ctx, args)));
221
+ server.registerTool('clast_unused', {
222
+ title: 'Unused code',
223
+ description: 'Dead code: functions, methods and types that nothing references (or that only other unused code references), grouped by file with line ranges; plus code used only by tests, and names that only appear in strings or comments. Entry points, decorated / attribute-marked symbols and overrides of library types are left out. Use after a refactor or to clean up a directory.',
224
+ inputSchema: {
225
+ path: z.string().optional().describe('Only judge symbols under / matching this path'),
226
+ lang: z.string().optional().describe('Only this language id'),
227
+ kind: z.string().optional().describe('Kinds to judge, comma separated (default function, method, class, struct, interface, trait, enum, type)'),
228
+ exported: z.boolean().optional().describe('false: leave out exported / public symbols'),
229
+ limit: z.number().int().min(1).max(1000).optional().describe('Max symbols listed (default 60)'),
230
+ },
231
+ annotations: READ_ONLY,
232
+ _meta: ALWAYS_LOAD,
233
+ }, guard(args => unused(ctx, args)));
234
+ server.registerTool('clast_changes', {
235
+ title: 'Changed symbols and impact',
236
+ description: 'Map the current git diff to code structure: which functions/classes were touched (uncommitted changes by default, or relative to a base ref such as "main"), which callers outside the change may be affected, and which test files exercise the changed symbols. Use when reviewing a change, before committing, or to decide which tests to run.',
237
+ inputSchema: {
238
+ base: z.string().optional().describe('Git ref to diff against (default HEAD = uncommitted changes; e.g. "main", "HEAD~3")'),
239
+ },
240
+ annotations: { readOnlyHint: true, openWorldHint: false },
241
+ }, guard(args => changes(ctx, args)));
242
+ server.registerTool('clast_status', {
243
+ title: 'Index status',
244
+ description: 'Index statistics and health: files, symbols, languages, whether indexing is in progress, watcher state.',
245
+ inputSchema: {},
246
+ annotations: READ_ONLY,
247
+ }, guard(() => status(ctx, watcher), false));
248
+ server.registerTool('clast_reindex', {
249
+ title: 'Re-index',
250
+ description: 'Re-scan the repository (incremental: only changed files are re-parsed) or force re-index one file. Rarely needed - the index follows file changes automatically and re-checks files when they are queried. Use force=true to rebuild from scratch.',
251
+ inputSchema: {
252
+ path: z.string().optional().describe('Single file to re-index (default: whole repository)'),
253
+ force: z.boolean().default(false).describe('Re-parse every file even if unchanged'),
254
+ },
255
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false },
256
+ }, guard(args => reindex(ctx, args), false));
257
+ server.registerTool('clast_comment', {
258
+ title: 'Get or generate documentation',
259
+ description: 'Return the documentation comment of a symbol. With generate=true and a local LLM configured (llm.model in clast.config.json), generates and stores a doc comment for undocumented symbols.',
260
+ inputSchema: {
261
+ name: z.string().describe('Symbol name, Container.name, or path:name'),
262
+ generate: z.boolean().default(false).describe('Generate with the configured LLM if no comment exists'),
263
+ },
264
+ annotations: { readOnlyHint: false, openWorldHint: true },
265
+ }, guard(async ({ name, generate }) => {
266
+ await ctx.ready();
267
+ const matches = findSymbols(ctx.db, name);
268
+ if (matches.length === 0)
269
+ return `"${name}": no symbol with this name.`;
270
+ const out = [];
271
+ for (const sym of matches.slice(0, 5)) {
272
+ out.push(symLine(sym));
273
+ const stored = commentGen.stored(sym);
274
+ if (sym.doc)
275
+ out.push(sym.doc);
276
+ else if (stored)
277
+ out.push(`[generated] ${stored}`);
278
+ else if (generate) {
279
+ if (!commentGen.isAvailable)
280
+ out.push('(no comment; LLM not configured - set llm.model in clast.config.json)');
281
+ else
282
+ out.push((await commentGen.generate(sym)) ?? '(generation failed; see server log)');
283
+ }
264
284
  else
265
- out.push((await commentGen.generate(sym)) ?? '(generation failed; see server log)');
285
+ out.push('(no comment)');
266
286
  }
267
- else
268
- out.push('(no comment)');
269
- }
270
- return out.join('\n');
271
- }));
272
- // Connect first so the client never waits on indexing; the index builds
273
- // (or revalidates) in the background and tools report progress meanwhile.
274
- const transport = new StdioServerTransport();
275
- await server.connect(transport);
276
- log('MCP server running on stdio');
277
- clast.indexer
278
- .indexAll()
279
- .then(r => {
280
- const s = clast.db.stats();
281
- log(`index ready: scanned ${r.scanned}, parsed ${r.parsed}, removed ${r.removed} in ${r.durationMs}ms ` +
282
- `(${s.files} files, ${s.symbols} symbols)`);
283
- })
284
- .catch(err => log(`indexing failed: ${err instanceof Error ? err.stack : err}`))
285
- .finally(() => {
286
- if (clast.config.watch.enabled) {
287
+ return out.join('\n');
288
+ }));
289
+ // The index builds (or revalidates) in the background and tools report progress meanwhile.
290
+ begin = () => {
291
+ clast.indexer
292
+ .indexAll()
293
+ .then(r => {
294
+ const s = clast.db.stats();
295
+ log(`index ready: scanned ${r.scanned}, parsed ${r.parsed}, removed ${r.removed} in ${r.durationMs}ms ` +
296
+ `(${s.files} files, ${s.symbols} symbols)`);
297
+ })
298
+ .catch(err => log(`indexing failed: ${err instanceof Error ? err.stack : err}`))
299
+ .finally(() => {
300
+ if (clast.config.watch.enabled) {
301
+ try {
302
+ watcher.start();
303
+ log('file watcher active');
304
+ }
305
+ catch (err) {
306
+ log(`watcher failed to start: ${err instanceof Error ? err.message : err}`);
307
+ }
308
+ }
309
+ });
310
+ watcher.on('error', ({ filePath, error }) => {
311
+ log(`watch error ${filePath}: ${error.message}`);
312
+ });
313
+ };
314
+ if (connected)
315
+ begin();
316
+ }; // activate
317
+ if (startActive) {
318
+ activate();
319
+ }
320
+ else {
321
+ log('dormant: no .clast folder in the project root');
322
+ const enableTool = server.registerTool('clast_enable', {
323
+ title: 'Turn clast on for this project',
324
+ description: 'Clast (code index) is off for this project. Call this only when the user asks to use clast: it creates the .clast folder in the project root, builds the index and adds the clast tools.',
325
+ inputSchema: {},
326
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false },
327
+ }, async () => {
287
328
  try {
288
- watcher.start();
289
- log('file watcher active');
329
+ activate();
330
+ enableTool.remove();
331
+ return text(`clast is on for ${projectRoot}. The index is building; the clast tools are available now ` +
332
+ `(reconnect the server or restart the session if they do not appear). ` +
333
+ `To turn it off again, delete the .clast folder.\n\n${INSTRUCTIONS}`);
290
334
  }
291
335
  catch (err) {
292
- log(`watcher failed to start: ${err instanceof Error ? err.message : err}`);
336
+ return { ...text(`error: ${err instanceof Error ? err.message : String(err)}`), isError: true };
293
337
  }
294
- }
295
- });
296
- watcher.on('error', ({ filePath, error }) => {
297
- log(`watch error ${filePath}: ${error.message}`);
298
- });
338
+ });
339
+ // The folder can also appear without the tool (a skill, the CLI, the user): pick that up.
340
+ const poll = setInterval(() => {
341
+ if (active)
342
+ return void clearInterval(poll);
343
+ if (!existsSync(marker))
344
+ return;
345
+ clearInterval(poll);
346
+ try {
347
+ activate();
348
+ enableTool.remove();
349
+ log('.clast folder found: active');
350
+ }
351
+ catch (err) {
352
+ log(`could not activate: ${err instanceof Error ? err.message : err}`);
353
+ }
354
+ }, 2000);
355
+ poll.unref();
356
+ }
357
+ // Connect first so the client never waits on indexing.
358
+ const transport = new StdioServerTransport();
359
+ await server.connect(transport);
360
+ connected = true;
361
+ log('MCP server running on stdio');
362
+ begin?.();
299
363
  let closing = false;
300
364
  const shutdown = () => {
301
365
  if (closing)
302
366
  return;
303
367
  closing = true;
304
- watcher
368
+ const live = active;
369
+ if (!live)
370
+ return void process.exit(0);
371
+ live.watcher
305
372
  .stop()
306
373
  .catch(() => undefined)
307
374
  .finally(() => {
308
375
  try {
309
- clast.close();
376
+ live.clast.close();
310
377
  }
311
378
  catch { /* already closed */ }
312
379
  process.exit(0);