ast-mcp-server 0.5.1 → 0.6.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/CHANGELOG.md +9 -0
- package/README.md +20 -3
- package/dist/server.js +4 -0
- package/dist/services/context-builder.js +234 -0
- package/dist/services/file-snapshot.js +96 -0
- package/dist/services/references.js +48 -0
- package/dist/services/symbols.js +59 -0
- package/dist/tools/explore.js +174 -0
- package/dist/tools/find_references.js +7 -44
- package/dist/tools/get_file.js +95 -0
- package/dist/tools/search_symbols.js +5 -54
- package/package.json +3 -2
- package/skills/structural-code-editing/SKILL.md +12 -5
- package/benchmark/README.md +0 -129
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.6.0] - 2026-08-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Read-only `ast_get_file` with bounded exact source lines, SHA-256 byte hashes, compiler snapshot state, and `symbols_only` body-free output.
|
|
12
|
+
- Project-scoped file snapshot validation with UTF-8 checks, traversal rejection, symlink containment checks, and ambiguous-path failures.
|
|
13
|
+
- Read-only `ast_explore` with query/file/symbol routing, progressive source and compiler-reference evidence, freshness/completeness metadata, unresolved selectors, and serialized byte budgets.
|
|
14
|
+
- A deterministic context workflow benchmark comparing full-file, primitive, and composed exploration workflows.
|
|
15
|
+
|
|
7
16
|
## [0.5.1] - 2026-08-05
|
|
8
17
|
|
|
9
18
|
### Added
|
package/README.md
CHANGED
|
@@ -23,13 +23,25 @@ This MCP server gives the agent structural code tools in addition to generic fil
|
|
|
23
23
|
|
|
24
24
|
| Need | Structural operation | Returned scope |
|
|
25
25
|
| ------------------------------ | ------------------------- | ----------------------------------------------------- |
|
|
26
|
+
| Read a bounded file | `ast_get_file` | Exact selected source lines, hash, and snapshot state |
|
|
27
|
+
| Explore bounded context | `ast_explore` | Ranked selectors plus optional source and references |
|
|
26
28
|
| Understand a file | `ast_get_outline` | Signatures without implementation bodies |
|
|
27
29
|
| Inspect one declaration | `ast_get_symbol_source` | Exact source for one function, method, class, or type |
|
|
28
30
|
| Find usages across the project | `ast_find_references` | Compiler-resolved reference locations |
|
|
29
31
|
| Rename a symbol everywhere | `ast_rename_symbol` | A reviewed project-wide rename plan |
|
|
30
32
|
| Change one implementation | `ast_replace_symbol_body` | A body-only plan that preserves the declaration |
|
|
31
33
|
|
|
32
|
-
Reads can start with a compact outline
|
|
34
|
+
Reads can start with a bounded file slice, a compact outline, or exact source only for the declaration that needs inspection. Mutations are prepared in memory first, compared against baseline diagnostics, and returned as immutable, hash-bound plans. Nothing is written until the caller reviews and explicitly applies the plan.
|
|
35
|
+
|
|
36
|
+
### Choosing a read tool
|
|
37
|
+
|
|
38
|
+
- Use `ast_get_file` when the file path is known and the agent needs exact source lines. It is read-only, uses zero-based `offset` and bounded `limit`, returns one-based line records, a SHA-256 byte hash, and `snapshot_state`.
|
|
39
|
+
- Use `ast_get_file` with `symbols_only: true` when only selectors and body-free signatures are needed from one known file.
|
|
40
|
+
- Use `ast_explore` when the question spans discovery and evidence. Its default summary is bounded; use `detail: "context"` for selected source and `detail: "full"` for source plus compiler references.
|
|
41
|
+
- Use `ast_get_outline` for a compact body-free view of a known file without source lines.
|
|
42
|
+
- Use `ast_get_symbol_source` when one declaration or implementation is the required evidence.
|
|
43
|
+
|
|
44
|
+
`snapshot_state: "fresh"` means that the returned file bytes match the synchronized compiler snapshot. It does not mean that the project has zero TypeScript diagnostics; use `ast_get_diagnostics` for compiler errors and warnings.
|
|
33
45
|
|
|
34
46
|
## Why this helps
|
|
35
47
|
|
|
@@ -39,7 +51,7 @@ Reads can start with a compact outline and fetch exact source only when needed.
|
|
|
39
51
|
|
|
40
52
|
AST-aware editing is not a proof that a change is semantically correct. The safety comes from combining structural selection with diagnostics, exact previews, reviewed hashes, freshness checks, and fail-closed apply semantics.
|
|
41
53
|
|
|
42
|
-
The included batch benchmark records a 50% reduction in model round-trips and a 94.67% reduction in serialized context for its search-to-source scenario. The result-shaping corpus records a 68.80% reduction in aggregate model-facing TOON tokens while preserving declared selectors/reference coordinates with the same six logical calls. The separate format benchmark records 25.87% across its eligible collection corpus. These are reproducible local `o200k_base` estimates, not universal token, billing, cache, or latency claims.
|
|
54
|
+
The included batch benchmark records a 50% reduction in model round-trips and a 94.67% reduction in serialized context for its search-to-source scenario. The result-shaping corpus records a 68.80% reduction in aggregate model-facing TOON tokens while preserving declared selectors/reference coordinates with the same six logical calls. The separate format benchmark records 25.87% across its eligible collection corpus. The context workflow benchmark verifies evidence preservation and call bounds for full-file, primitive, and `ast_explore` workflows. These are reproducible local `o200k_base` estimates, not universal token, billing, cache, or latency claims.
|
|
43
55
|
|
|
44
56
|
## Requirements
|
|
45
57
|
|
|
@@ -217,6 +229,9 @@ Project-scoped tools accept `project_root`, either the project directory or an e
|
|
|
217
229
|
| Tool | Purpose | Mutates files |
|
|
218
230
|
| --------------------------- | -------------------------------------------------------------------- | ------------- |
|
|
219
231
|
| `ast_list_files` | Paginated, project-relative source file inventory | No |
|
|
232
|
+
| `ast_get_project_status` | Read-only compiler, freshness, index, and operation status | No |
|
|
233
|
+
| `ast_explore` | Bounded composed selectors, source evidence, and references | No |
|
|
234
|
+
| `ast_get_file` | Bounded exact source lines, byte hash, and snapshot state | No |
|
|
220
235
|
| `ast_get_outline` | Body-free declaration signatures; detailed symbol metadata is opt-in | No |
|
|
221
236
|
| `ast_get_symbol_source` | Exact source for one declaration | No |
|
|
222
237
|
| `ast_search_symbols` | Paginated structural symbol discovery | No |
|
|
@@ -230,6 +245,8 @@ Project-scoped tools accept `project_root`, either the project directory or an e
|
|
|
230
245
|
|
|
231
246
|
Read results use project-relative paths, deterministic ordering, structured MCP output, and pagination where result sets can grow with the project.
|
|
232
247
|
|
|
248
|
+
`ast_explore` supports query, exact file, and exact symbol routes. Its default `summary` profile returns bounded reusable selectors; `context` adds selected source and `full` adds compiler references. Every response reports freshness, completeness, truncation, unresolved selectors, record limits, and a serialized byte budget. Use the primitive tools when a single exact operation is clearer or when preparing a mutation.
|
|
249
|
+
|
|
233
250
|
Symbol search is relevance-ranked and defaults to at most 20 `summary` records containing `file`, a directly reusable `selector`, `kind`, and body-free `signature`. Request `detail: "selectors"` for routing coordinates only, or `detail: "full", limit: 100` for the v0.4.0 fields/page. References default to `detail: "locations"`; request `detail: "context"` only when the bounded source line is needed.
|
|
234
251
|
|
|
235
252
|
### Optional TOON results
|
|
@@ -392,7 +409,7 @@ The batch benchmark compares two separate client calls with one batch invocation
|
|
|
392
409
|
|
|
393
410
|
The format benchmark runs real tools against this repository plus deterministic reference/diagnostic fixtures. It checks JSON→TOON→value equality, UTF-8 bytes, `gpt-tokenizer` `o200k_base` estimates, encode/decode latency, the actual MCP envelope, tool metadata, and negative controls. Its checked result is `benchmark/results/self-formats.json`; local tokenizer estimates do not establish provider-side billing or cache savings. See `benchmark/README.md` for methodology and limitations.
|
|
394
411
|
|
|
395
|
-
The result-shaping benchmark compares the v0.4.0-compatible `full/100/context` profiles with the new public defaults across exact-name, exact-path, prefix, broad-substring, and multi-file-reference tasks. It fails on missing evidence, extra
|
|
412
|
+
The result-shaping benchmark compares the v0.4.0-compatible `full/100/context` profiles with the new public defaults across exact-name, exact-path, prefix, broad-substring, and multi-file-reference tasks. It fails on missing evidence, extra required calls, fewer than the benchmark's required minimum tool surface, or less than 35% aggregate TOON token reduction. Its checked result is `benchmark/results/self-result-shapes.json`.
|
|
396
413
|
|
|
397
414
|
## Scope
|
|
398
415
|
|
package/dist/server.js
CHANGED
|
@@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { registerApplyOperation } from "./tools/apply_operation.js";
|
|
4
4
|
import { registerFindReferences } from "./tools/find_references.js";
|
|
5
5
|
import { registerGetDiagnostics } from "./tools/get_diagnostics.js";
|
|
6
|
+
import { registerGetFile } from "./tools/get_file.js";
|
|
6
7
|
import { registerGetOperationPreview } from "./tools/get_operation_preview.js";
|
|
7
8
|
import { registerGetOutline } from "./tools/get_outline.js";
|
|
8
9
|
import { registerGetProjectStatus } from "./tools/get_project_status.js";
|
|
@@ -12,6 +13,7 @@ import { registerRenameSymbol } from "./tools/rename_symbol.js";
|
|
|
12
13
|
import { registerReplaceSymbolBody } from "./tools/replace_symbol_body.js";
|
|
13
14
|
import { registerScaffoldClass } from "./tools/scaffold_class.js";
|
|
14
15
|
import { registerSearchSymbols } from "./tools/search_symbols.js";
|
|
16
|
+
import { registerExplore } from "./tools/explore.js";
|
|
15
17
|
const packageMetadata = createRequire(import.meta.url)("../package.json");
|
|
16
18
|
export const PACKAGE_VERSION = packageMetadata.version;
|
|
17
19
|
export function createServer() {
|
|
@@ -21,11 +23,13 @@ export function createServer() {
|
|
|
21
23
|
});
|
|
22
24
|
registerListFiles(server);
|
|
23
25
|
registerGetProjectStatus(server);
|
|
26
|
+
registerExplore(server);
|
|
24
27
|
registerGetOutline(server);
|
|
25
28
|
registerGetSymbolSource(server);
|
|
26
29
|
registerSearchSymbols(server);
|
|
27
30
|
registerFindReferences(server);
|
|
28
31
|
registerGetDiagnostics(server);
|
|
32
|
+
registerGetFile(server);
|
|
29
33
|
registerRenameSymbol(server);
|
|
30
34
|
registerReplaceSymbolBody(server);
|
|
31
35
|
registerScaffoldClass(server);
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { buildFileOutline, nodeSourceWithLocation } from "./outline.js";
|
|
4
|
+
import { paginate } from "./pagination.js";
|
|
5
|
+
import { findDeclarationByName, getSourceFileOrThrow } from "./project.js";
|
|
6
|
+
import { collectSymbolReferences } from "./references.js";
|
|
7
|
+
import { searchProjectSymbols, sourceFileSymbols, symbolMatchRank, } from "./symbols.js";
|
|
8
|
+
export const EXPLORE_DEFAULT_LIMIT = 20;
|
|
9
|
+
export const EXPLORE_DEFAULT_REFERENCE_LIMIT = 20;
|
|
10
|
+
export const EXPLORE_DEFAULT_MAX_BYTES = 64 * 1024;
|
|
11
|
+
export const EXPLORE_MAX_BYTES = 1024 * 1024;
|
|
12
|
+
function matchesQuery(record, query) {
|
|
13
|
+
const normalizedQuery = query.toLowerCase();
|
|
14
|
+
return (record.name.toLowerCase().includes(normalizedQuery) ||
|
|
15
|
+
record.symbol_path.toLowerCase().includes(normalizedQuery) ||
|
|
16
|
+
record.selector.toLowerCase().includes(normalizedQuery));
|
|
17
|
+
}
|
|
18
|
+
function sortRecords(records, query) {
|
|
19
|
+
return records.sort((left, right) => {
|
|
20
|
+
if (query) {
|
|
21
|
+
const rank = symbolMatchRank(query, {
|
|
22
|
+
symbolPath: left.symbol_path,
|
|
23
|
+
name: left.name,
|
|
24
|
+
line: left.line,
|
|
25
|
+
}) -
|
|
26
|
+
symbolMatchRank(query, {
|
|
27
|
+
symbolPath: right.symbol_path,
|
|
28
|
+
name: right.name,
|
|
29
|
+
line: right.line,
|
|
30
|
+
});
|
|
31
|
+
if (rank !== 0)
|
|
32
|
+
return rank;
|
|
33
|
+
}
|
|
34
|
+
return (left.file.localeCompare(right.file) ||
|
|
35
|
+
left.line - right.line ||
|
|
36
|
+
left.symbol_path.localeCompare(right.symbol_path) ||
|
|
37
|
+
left.kind.localeCompare(right.kind));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function recordFromOutlineSymbol(sourceFile, projectRoot, symbol) {
|
|
41
|
+
return {
|
|
42
|
+
file: path.relative(projectRoot, sourceFile.getFilePath()).replaceAll("\\", "/"),
|
|
43
|
+
symbol_path: symbol.symbolPath,
|
|
44
|
+
selector: `${symbol.symbolPath}@${symbol.startLine}`,
|
|
45
|
+
name: symbol.name,
|
|
46
|
+
kind: symbol.kind,
|
|
47
|
+
line: symbol.startLine,
|
|
48
|
+
signature: symbol.signature,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function fileRecords(sourceFile, projectRoot) {
|
|
52
|
+
const outline = buildFileOutline(sourceFile);
|
|
53
|
+
if (outline.symbols.length > 0) {
|
|
54
|
+
return outline.symbols.map((symbol) => recordFromOutlineSymbol(sourceFile, projectRoot, symbol));
|
|
55
|
+
}
|
|
56
|
+
return sourceFileSymbols(sourceFile, projectRoot);
|
|
57
|
+
}
|
|
58
|
+
function projectRecords(context, request) {
|
|
59
|
+
const { project, projectRoot } = context;
|
|
60
|
+
if (request.filePath) {
|
|
61
|
+
const sourceFile = getSourceFileOrThrow(project, request.filePath);
|
|
62
|
+
const file = path.relative(projectRoot, sourceFile.getFilePath()).replaceAll("\\", "/");
|
|
63
|
+
const requestedSymbolPath = request.symbolPath;
|
|
64
|
+
if (requestedSymbolPath) {
|
|
65
|
+
const node = findDeclarationByName(sourceFile, requestedSymbolPath);
|
|
66
|
+
const record = fileRecords(sourceFile, projectRoot).find((candidate) => candidate.symbol_path === requestedSymbolPath ||
|
|
67
|
+
candidate.selector === requestedSymbolPath ||
|
|
68
|
+
(candidate.symbol_path === requestedSymbolPath.split("@")[0] &&
|
|
69
|
+
candidate.line === node.getStartLineNumber()));
|
|
70
|
+
if (!record) {
|
|
71
|
+
throw new Error(`Symbol "${requestedSymbolPath}" could not be projected from ${file}.`);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
route: "symbol",
|
|
75
|
+
file,
|
|
76
|
+
symbol: record.selector,
|
|
77
|
+
records: [record],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const records = fileRecords(sourceFile, projectRoot)
|
|
81
|
+
.filter((record) => !request.query || matchesQuery(record, request.query))
|
|
82
|
+
.filter((record) => !request.kinds || request.kinds.includes(record.kind));
|
|
83
|
+
return { route: "file", file, symbol: null, records: sortRecords(records, request.query) };
|
|
84
|
+
}
|
|
85
|
+
if (!request.query) {
|
|
86
|
+
throw new Error("ast_explore requires query, file_path, or both file_path and symbol_path.");
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
route: "query",
|
|
90
|
+
file: null,
|
|
91
|
+
symbol: null,
|
|
92
|
+
records: searchProjectSymbols(project, projectRoot, {
|
|
93
|
+
query: request.query,
|
|
94
|
+
kinds: request.kinds,
|
|
95
|
+
fileFilter: request.fileFilter,
|
|
96
|
+
}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function projectSymbol(detail, record) {
|
|
100
|
+
const base = { file: record.file, selector: record.selector, kind: record.kind };
|
|
101
|
+
if (detail === "selectors")
|
|
102
|
+
return base;
|
|
103
|
+
if (detail === "summary" || detail === "context") {
|
|
104
|
+
return { ...base, signature: record.signature };
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
...base,
|
|
108
|
+
symbol_path: record.symbol_path,
|
|
109
|
+
name: record.name,
|
|
110
|
+
line: record.line,
|
|
111
|
+
signature: record.signature,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function jsonBytes(value) {
|
|
115
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
116
|
+
}
|
|
117
|
+
function effectiveExpansion(request) {
|
|
118
|
+
return {
|
|
119
|
+
source: request.includeSource ?? (request.detail === "context" || request.detail === "full"),
|
|
120
|
+
references: request.includeReferences ?? request.detail === "full",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function buildExploreContext(context, request) {
|
|
124
|
+
const routed = projectRecords(context, request);
|
|
125
|
+
const page = paginate(routed.records, request.offset, request.limit);
|
|
126
|
+
const expansion = effectiveExpansion(request);
|
|
127
|
+
const unresolved = [];
|
|
128
|
+
const evidence = [];
|
|
129
|
+
for (const record of page.items) {
|
|
130
|
+
let sourceFile;
|
|
131
|
+
try {
|
|
132
|
+
sourceFile = getSourceFileOrThrow(context.project, record.file);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
unresolved.push({ selector: record.selector, reason: "source_unresolved" });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
let node;
|
|
139
|
+
try {
|
|
140
|
+
node = findDeclarationByName(sourceFile, record.selector);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
unresolved.push({ selector: record.selector, reason: "source_unresolved" });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
let source;
|
|
147
|
+
let references;
|
|
148
|
+
if (expansion.source)
|
|
149
|
+
source = nodeSourceWithLocation(node, context.projectRoot);
|
|
150
|
+
if (expansion.references) {
|
|
151
|
+
try {
|
|
152
|
+
references = collectSymbolReferences(node, context.projectRoot, record.selector, true, request.referenceDetail, 0, request.referenceLimit);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
unresolved.push({ selector: record.selector, reason: "references_unresolved" });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (source || references) {
|
|
159
|
+
evidence.push({
|
|
160
|
+
selector: record.selector,
|
|
161
|
+
...(source ? { source } : {}),
|
|
162
|
+
...(references ? { references } : {}),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const symbols = page.items.map((record) => projectSymbol(request.detail, record));
|
|
167
|
+
let returnedSymbols = [...symbols];
|
|
168
|
+
let returnedEvidence = [...evidence];
|
|
169
|
+
let reason = page.has_more ? "record_limit" : null;
|
|
170
|
+
let result = createResult(context, request, routed.route, routed.file, routed.symbol, returnedSymbols, returnedEvidence, page.total, unresolved, reason);
|
|
171
|
+
while (jsonBytes(result) > request.maxBytes &&
|
|
172
|
+
(returnedEvidence.length > 0 || returnedSymbols.length > 0)) {
|
|
173
|
+
reason = "byte_limit";
|
|
174
|
+
if (returnedEvidence.length > 0) {
|
|
175
|
+
returnedEvidence = returnedEvidence.slice(0, -1);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
returnedSymbols = returnedSymbols.slice(0, -1);
|
|
179
|
+
const selectors = new Set(returnedSymbols.map((symbol) => symbol.selector));
|
|
180
|
+
returnedEvidence = returnedEvidence.filter((item) => selectors.has(item.selector));
|
|
181
|
+
}
|
|
182
|
+
result = createResult(context, request, routed.route, routed.file, routed.symbol, returnedSymbols, returnedEvidence, page.total, unresolved, reason);
|
|
183
|
+
}
|
|
184
|
+
const usedBytes = jsonBytes(result);
|
|
185
|
+
return {
|
|
186
|
+
...result,
|
|
187
|
+
budget: { ...result.budget, used_bytes: usedBytes },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function createResult(context, request, route, file, symbol, symbols, evidence, total, unresolved, reason) {
|
|
191
|
+
const hasMore = request.offset + symbols.length < total;
|
|
192
|
+
const nextOffset = hasMore ? request.offset + symbols.length : null;
|
|
193
|
+
const expansion = effectiveExpansion(request);
|
|
194
|
+
const evidenceComplete = unresolved.length === 0 &&
|
|
195
|
+
evidence.length >= (expansion.source || expansion.references ? symbols.length : 0);
|
|
196
|
+
const symbolsComplete = !hasMore;
|
|
197
|
+
return {
|
|
198
|
+
route,
|
|
199
|
+
query: request.query ?? null,
|
|
200
|
+
file,
|
|
201
|
+
symbol,
|
|
202
|
+
detail: request.detail,
|
|
203
|
+
symbols,
|
|
204
|
+
evidence,
|
|
205
|
+
offset: request.offset,
|
|
206
|
+
limit: request.limit,
|
|
207
|
+
total,
|
|
208
|
+
has_more: hasMore,
|
|
209
|
+
next_offset: nextOffset,
|
|
210
|
+
freshness: {
|
|
211
|
+
state: context.status.state,
|
|
212
|
+
causes: context.status.causes,
|
|
213
|
+
checked_at: context.status.lastSuccessfulSyncAt,
|
|
214
|
+
},
|
|
215
|
+
completeness: {
|
|
216
|
+
complete: symbolsComplete && evidenceComplete,
|
|
217
|
+
symbols_complete: symbolsComplete,
|
|
218
|
+
evidence_complete: evidenceComplete,
|
|
219
|
+
unresolved,
|
|
220
|
+
},
|
|
221
|
+
budget: {
|
|
222
|
+
max_records: request.limit,
|
|
223
|
+
max_bytes: request.maxBytes,
|
|
224
|
+
max_depth: null,
|
|
225
|
+
max_edges: null,
|
|
226
|
+
max_invocations: 1,
|
|
227
|
+
used_bytes: 0,
|
|
228
|
+
},
|
|
229
|
+
truncation: {
|
|
230
|
+
truncated: reason !== null || hasMore,
|
|
231
|
+
reason: reason ?? (hasMore ? "record_limit" : null),
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { TextDecoder } from "node:util";
|
|
2
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getSourceFileOrThrow } from "./project.js";
|
|
5
|
+
import { hashBytes } from "./workspace.js";
|
|
6
|
+
export const DEFAULT_FILE_LINE_LIMIT = 200;
|
|
7
|
+
export const MAX_FILE_LINE_LIMIT = 500;
|
|
8
|
+
function assertBoundedRange({ offset, limit }) {
|
|
9
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
10
|
+
throw new Error("File offset must be a non-negative integer.");
|
|
11
|
+
}
|
|
12
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_FILE_LINE_LIMIT) {
|
|
13
|
+
throw new Error(`File limit must be an integer between 1 and ${MAX_FILE_LINE_LIMIT}.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function rejectTraversalPath(filePath) {
|
|
17
|
+
const normalized = filePath.replaceAll("\\", "/");
|
|
18
|
+
const segments = normalized.split("/");
|
|
19
|
+
const isWindowsAbsolute = /^[A-Za-z]:\//.test(normalized) || normalized.startsWith("//");
|
|
20
|
+
if (segments.includes("..")) {
|
|
21
|
+
throw new Error("File path traversal is not allowed.");
|
|
22
|
+
}
|
|
23
|
+
if (isWindowsAbsolute && !path.isAbsolute(filePath)) {
|
|
24
|
+
throw new Error("Windows absolute paths are not valid on this host.");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function isContained(root, candidate) {
|
|
28
|
+
const relative = path.relative(root, candidate);
|
|
29
|
+
return (relative.length > 0 &&
|
|
30
|
+
relative !== ".." &&
|
|
31
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
32
|
+
!path.isAbsolute(relative));
|
|
33
|
+
}
|
|
34
|
+
function projectRelative(root, filePath) {
|
|
35
|
+
return path.relative(root, filePath).split(path.sep).join("/");
|
|
36
|
+
}
|
|
37
|
+
function decodeUtf8(bytes, filePath) {
|
|
38
|
+
try {
|
|
39
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error(`Source file "${filePath}" is not valid UTF-8.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function splitLines(source) {
|
|
46
|
+
if (source.length === 0)
|
|
47
|
+
return [];
|
|
48
|
+
const lines = source.split(/\r\n|\n|\r/);
|
|
49
|
+
if (source.endsWith("\n") || source.endsWith("\r"))
|
|
50
|
+
lines.pop();
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
function compilerSnapshotHashes(sourceText) {
|
|
54
|
+
const sourceBytes = Buffer.from(sourceText, "utf8");
|
|
55
|
+
const hashes = [hashBytes(sourceBytes)];
|
|
56
|
+
if (sourceText.charCodeAt(0) !== 0xfeff) {
|
|
57
|
+
hashes.push(hashBytes(Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), sourceBytes])));
|
|
58
|
+
}
|
|
59
|
+
return hashes;
|
|
60
|
+
}
|
|
61
|
+
export async function readFileSnapshot(project, projectRoot, filePath, options) {
|
|
62
|
+
assertBoundedRange(options);
|
|
63
|
+
rejectTraversalPath(filePath);
|
|
64
|
+
const sourceFile = getSourceFileOrThrow(project, filePath);
|
|
65
|
+
const canonicalRoot = await realpath(projectRoot);
|
|
66
|
+
const sourcePath = sourceFile.getFilePath();
|
|
67
|
+
const canonicalFile = await realpath(sourcePath);
|
|
68
|
+
if (!isContained(canonicalRoot, canonicalFile)) {
|
|
69
|
+
throw new Error("Source file resolves outside the configured project root.");
|
|
70
|
+
}
|
|
71
|
+
const stat = await lstat(canonicalFile);
|
|
72
|
+
if (!stat.isFile()) {
|
|
73
|
+
throw new Error("Source file is not a regular file.");
|
|
74
|
+
}
|
|
75
|
+
const bytes = await readFile(canonicalFile);
|
|
76
|
+
const source = decodeUtf8(bytes, projectRelative(canonicalRoot, canonicalFile));
|
|
77
|
+
const fileHash = hashBytes(bytes);
|
|
78
|
+
const compilerHashes = compilerSnapshotHashes(sourceFile.getFullText());
|
|
79
|
+
const snapshotState = compilerHashes.includes(fileHash) ? "fresh" : "stale";
|
|
80
|
+
const allLines = splitLines(source);
|
|
81
|
+
const selectedLines = allLines.slice(options.offset, options.offset + options.limit);
|
|
82
|
+
return {
|
|
83
|
+
file: projectRelative(canonicalRoot, canonicalFile),
|
|
84
|
+
range: {
|
|
85
|
+
offset: options.offset,
|
|
86
|
+
limit: options.limit,
|
|
87
|
+
total_lines: allLines.length,
|
|
88
|
+
},
|
|
89
|
+
lines: selectedLines.map((text, index) => ({
|
|
90
|
+
line: options.offset + index + 1,
|
|
91
|
+
text,
|
|
92
|
+
})),
|
|
93
|
+
file_hash: fileHash,
|
|
94
|
+
snapshot_state: snapshotState,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Node } from "ts-morph";
|
|
3
|
+
import { paginate } from "./pagination.js";
|
|
4
|
+
export function collectSymbolReferences(node, projectRoot, symbolPath, includeDeclaration, detail, offset, limit) {
|
|
5
|
+
if (!Node.isReferenceFindable(node)) {
|
|
6
|
+
throw new Error(`Symbol "${symbolPath}" (${node.getKindName()}) does not support semantic reference search.`);
|
|
7
|
+
}
|
|
8
|
+
const lineCache = new Map();
|
|
9
|
+
const location = (referenceNode, isDeclaration) => {
|
|
10
|
+
const referenceSource = referenceNode.getSourceFile();
|
|
11
|
+
const absoluteFile = referenceSource.getFilePath();
|
|
12
|
+
let lines = lineCache.get(absoluteFile);
|
|
13
|
+
if (!lines) {
|
|
14
|
+
lines = referenceSource.getFullText().split(/\r?\n/);
|
|
15
|
+
lineCache.set(absoluteFile, lines);
|
|
16
|
+
}
|
|
17
|
+
const position = referenceSource.getLineAndColumnAtPos(referenceNode.getStart());
|
|
18
|
+
return {
|
|
19
|
+
file: path.relative(projectRoot, absoluteFile),
|
|
20
|
+
line: position.line,
|
|
21
|
+
column: position.column,
|
|
22
|
+
kind: referenceNode.getParent()?.getKindName() ?? referenceNode.getKindName(),
|
|
23
|
+
is_declaration: isDeclaration,
|
|
24
|
+
...(detail === "context"
|
|
25
|
+
? { context: (lines[position.line - 1] ?? "").trim().slice(0, 500) }
|
|
26
|
+
: {}),
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
const references = node.findReferencesAsNodes().map((reference) => location(reference, false));
|
|
30
|
+
const declaration = location(node, true);
|
|
31
|
+
const allLocations = includeDeclaration ? [declaration, ...references] : references;
|
|
32
|
+
allLocations.sort((left, right) => `${left.file}:${left.line}:${left.column}:${left.is_declaration ? 0 : 1}`.localeCompare(`${right.file}:${right.line}:${right.column}:${right.is_declaration ? 0 : 1}`));
|
|
33
|
+
const affectedFiles = [...new Set([declaration, ...references].map((item) => item.file))].sort();
|
|
34
|
+
const page = paginate(allLocations, offset, limit);
|
|
35
|
+
return {
|
|
36
|
+
symbol: symbolPath,
|
|
37
|
+
include_declaration: includeDeclaration,
|
|
38
|
+
declaration_count: 1,
|
|
39
|
+
reference_count: references.length,
|
|
40
|
+
affected_files: affectedFiles,
|
|
41
|
+
references: page.items,
|
|
42
|
+
offset: page.offset,
|
|
43
|
+
limit: page.limit,
|
|
44
|
+
total: page.total,
|
|
45
|
+
has_more: page.has_more,
|
|
46
|
+
next_offset: page.next_offset,
|
|
47
|
+
};
|
|
48
|
+
}
|
package/dist/services/symbols.js
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { Node, } from "ts-morph";
|
|
3
|
+
import { buildFileOutline } from "./outline.js";
|
|
4
|
+
export function sourceFileSymbols(sourceFile, projectRoot) {
|
|
5
|
+
const file = path.relative(projectRoot, sourceFile.getFilePath());
|
|
6
|
+
const signatures = new Map(buildFileOutline(sourceFile).symbols.map((symbol) => [
|
|
7
|
+
`${symbol.symbolPath}@${symbol.startLine}`,
|
|
8
|
+
symbol.signature,
|
|
9
|
+
]));
|
|
10
|
+
return collectSymbols(sourceFile).map((symbol) => ({
|
|
11
|
+
file,
|
|
12
|
+
symbol_path: symbol.symbolPath,
|
|
13
|
+
selector: `${symbol.symbolPath}@${symbol.line}`,
|
|
14
|
+
name: symbol.name,
|
|
15
|
+
kind: symbol.kind,
|
|
16
|
+
line: symbol.line,
|
|
17
|
+
signature: signatures.get(`${symbol.symbolPath}@${symbol.line}`) ?? symbol.node.getText(),
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
2
20
|
export function symbolMatchRank(query, symbol) {
|
|
3
21
|
const normalizedQuery = query.toLowerCase();
|
|
4
22
|
const normalizedPath = symbol.symbolPath.toLowerCase();
|
|
@@ -15,6 +33,47 @@ export function symbolMatchRank(query, symbol) {
|
|
|
15
33
|
}
|
|
16
34
|
return 4;
|
|
17
35
|
}
|
|
36
|
+
export function searchProjectSymbols(project, projectRoot, options) {
|
|
37
|
+
const normalizedQuery = options.query.toLowerCase();
|
|
38
|
+
const normalizedFileFilter = options.fileFilter?.toLowerCase();
|
|
39
|
+
const kindSet = options.kinds ? new Set(options.kinds) : undefined;
|
|
40
|
+
const matches = [];
|
|
41
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
42
|
+
const file = path.relative(projectRoot, sourceFile.getFilePath());
|
|
43
|
+
if (normalizedFileFilter && !file.toLowerCase().includes(normalizedFileFilter))
|
|
44
|
+
continue;
|
|
45
|
+
const matchingSymbols = sourceFileSymbols(sourceFile, projectRoot).filter((symbol) => {
|
|
46
|
+
return ((!kindSet || kindSet.has(symbol.kind)) &&
|
|
47
|
+
(symbol.name.toLowerCase().includes(normalizedQuery) ||
|
|
48
|
+
symbol.symbol_path.toLowerCase().includes(normalizedQuery) ||
|
|
49
|
+
symbol.selector.toLowerCase().includes(normalizedQuery)));
|
|
50
|
+
});
|
|
51
|
+
if (matchingSymbols.length === 0)
|
|
52
|
+
continue;
|
|
53
|
+
for (const symbol of matchingSymbols) {
|
|
54
|
+
matches.push(symbol);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
matches.sort((left, right) => {
|
|
58
|
+
const rank = symbolMatchRank(options.query, {
|
|
59
|
+
symbolPath: left.symbol_path,
|
|
60
|
+
name: left.name,
|
|
61
|
+
line: left.line,
|
|
62
|
+
}) -
|
|
63
|
+
symbolMatchRank(options.query, {
|
|
64
|
+
symbolPath: right.symbol_path,
|
|
65
|
+
name: right.name,
|
|
66
|
+
line: right.line,
|
|
67
|
+
});
|
|
68
|
+
if (rank !== 0)
|
|
69
|
+
return rank;
|
|
70
|
+
return (left.file.localeCompare(right.file) ||
|
|
71
|
+
left.line - right.line ||
|
|
72
|
+
left.symbol_path.localeCompare(right.symbol_path) ||
|
|
73
|
+
left.kind.localeCompare(right.kind));
|
|
74
|
+
});
|
|
75
|
+
return matches;
|
|
76
|
+
}
|
|
18
77
|
function namedNode(node) {
|
|
19
78
|
if (Node.isConstructorDeclaration(node))
|
|
20
79
|
return "constructor";
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { buildExploreContext, EXPLORE_DEFAULT_LIMIT, EXPLORE_DEFAULT_MAX_BYTES, EXPLORE_DEFAULT_REFERENCE_LIMIT, EXPLORE_MAX_BYTES, } from "../services/context-builder.js";
|
|
3
|
+
import { createPaginationInputSchema } from "../services/pagination.js";
|
|
4
|
+
import { withProject } from "../services/project.js";
|
|
5
|
+
import { FRESHNESS_CAUSES, SNAPSHOT_STATES, TRUNCATION_REASONS, } from "../services/read-contracts.js";
|
|
6
|
+
import { errorResult, structuredResult } from "./result.js";
|
|
7
|
+
const ExploreDetailSchema = z.enum(["selectors", "summary", "context", "full"]).default("summary");
|
|
8
|
+
const ReferenceDetailSchema = z.enum(["locations", "context"]).default("locations");
|
|
9
|
+
const ExploreInputSchema = z
|
|
10
|
+
.object({
|
|
11
|
+
project_root: z
|
|
12
|
+
.string()
|
|
13
|
+
.describe("Absolute project directory containing tsconfig.json, or the config path."),
|
|
14
|
+
query: z.string().min(1).optional().describe("Bounded case-insensitive symbol query."),
|
|
15
|
+
file_path: z.string().optional().describe("Known project-relative or unambiguous source file."),
|
|
16
|
+
symbol_path: z
|
|
17
|
+
.string()
|
|
18
|
+
.min(1)
|
|
19
|
+
.optional()
|
|
20
|
+
.describe("Exact symbol path or selector, valid only with file_path."),
|
|
21
|
+
kinds: z.array(z.string()).max(32).optional().describe("Optional exact syntax-kind filters."),
|
|
22
|
+
file_filter: z.string().optional().describe("Optional query-route file path substring filter."),
|
|
23
|
+
detail: ExploreDetailSchema.describe("selectors returns routing coordinates; summary adds signatures; context adds source; full adds source and references."),
|
|
24
|
+
include_source: z
|
|
25
|
+
.boolean()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("Override detail-based source expansion for selected symbols."),
|
|
28
|
+
include_references: z
|
|
29
|
+
.boolean()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("Override detail-based compiler reference expansion for selected symbols."),
|
|
32
|
+
reference_detail: ReferenceDetailSchema.describe("Reference locations or bounded source-line context."),
|
|
33
|
+
reference_limit: z
|
|
34
|
+
.number()
|
|
35
|
+
.int()
|
|
36
|
+
.min(1)
|
|
37
|
+
.max(100)
|
|
38
|
+
.default(EXPLORE_DEFAULT_REFERENCE_LIMIT)
|
|
39
|
+
.describe("Maximum compiler references per selected symbol."),
|
|
40
|
+
...createPaginationInputSchema(EXPLORE_DEFAULT_LIMIT),
|
|
41
|
+
max_bytes: z
|
|
42
|
+
.number()
|
|
43
|
+
.int()
|
|
44
|
+
.min(1024)
|
|
45
|
+
.max(EXPLORE_MAX_BYTES)
|
|
46
|
+
.default(EXPLORE_DEFAULT_MAX_BYTES)
|
|
47
|
+
.describe("Maximum serialized logical result size in UTF-8 bytes."),
|
|
48
|
+
})
|
|
49
|
+
.refine((input) => Boolean(input.query || input.file_path), {
|
|
50
|
+
message: "Provide query, file_path, or both file_path and symbol_path.",
|
|
51
|
+
path: ["query"],
|
|
52
|
+
})
|
|
53
|
+
.refine((input) => !input.symbol_path || Boolean(input.file_path), {
|
|
54
|
+
message: "symbol_path requires file_path so the exact declaration can be resolved.",
|
|
55
|
+
path: ["symbol_path"],
|
|
56
|
+
});
|
|
57
|
+
const ExploreSymbolSchema = z.object({
|
|
58
|
+
file: z.string(),
|
|
59
|
+
selector: z.string(),
|
|
60
|
+
kind: z.string(),
|
|
61
|
+
symbol_path: z.string().optional(),
|
|
62
|
+
name: z.string().optional(),
|
|
63
|
+
line: z.number().int().positive().optional(),
|
|
64
|
+
signature: z.string().optional(),
|
|
65
|
+
});
|
|
66
|
+
const ExploreSourceSchema = z.object({
|
|
67
|
+
file: z.string(),
|
|
68
|
+
startLine: z.number().int().positive(),
|
|
69
|
+
endLine: z.number().int().positive(),
|
|
70
|
+
text: z.string(),
|
|
71
|
+
});
|
|
72
|
+
const ExploreReferenceSchema = z.object({
|
|
73
|
+
file: z.string(),
|
|
74
|
+
line: z.number().int().positive(),
|
|
75
|
+
column: z.number().int().positive(),
|
|
76
|
+
kind: z.string(),
|
|
77
|
+
is_declaration: z.boolean(),
|
|
78
|
+
context: z.string().optional(),
|
|
79
|
+
});
|
|
80
|
+
const ExploreEvidenceSchema = z.object({
|
|
81
|
+
selector: z.string(),
|
|
82
|
+
source: ExploreSourceSchema.optional(),
|
|
83
|
+
references: z
|
|
84
|
+
.object({
|
|
85
|
+
symbol: z.string(),
|
|
86
|
+
include_declaration: z.boolean(),
|
|
87
|
+
declaration_count: z.number().int().min(0),
|
|
88
|
+
reference_count: z.number().int().min(0),
|
|
89
|
+
affected_files: z.array(z.string()),
|
|
90
|
+
references: z.array(ExploreReferenceSchema),
|
|
91
|
+
offset: z.number().int().min(0),
|
|
92
|
+
limit: z.number().int().positive(),
|
|
93
|
+
total: z.number().int().min(0),
|
|
94
|
+
has_more: z.boolean(),
|
|
95
|
+
next_offset: z.number().int().min(0).nullable(),
|
|
96
|
+
})
|
|
97
|
+
.optional(),
|
|
98
|
+
});
|
|
99
|
+
const ExploreOutputSchema = z.object({
|
|
100
|
+
route: z.enum(["query", "file", "symbol"]),
|
|
101
|
+
query: z.string().nullable(),
|
|
102
|
+
file: z.string().nullable(),
|
|
103
|
+
symbol: z.string().nullable(),
|
|
104
|
+
detail: ExploreDetailSchema,
|
|
105
|
+
symbols: z.array(ExploreSymbolSchema),
|
|
106
|
+
evidence: z.array(ExploreEvidenceSchema),
|
|
107
|
+
offset: z.number().int().min(0),
|
|
108
|
+
limit: z.number().int().positive(),
|
|
109
|
+
total: z.number().int().min(0),
|
|
110
|
+
has_more: z.boolean(),
|
|
111
|
+
next_offset: z.number().int().min(0).nullable(),
|
|
112
|
+
freshness: z.object({
|
|
113
|
+
state: z.enum(SNAPSHOT_STATES),
|
|
114
|
+
causes: z.array(z.enum(FRESHNESS_CAUSES)),
|
|
115
|
+
checked_at: z.string().nullable(),
|
|
116
|
+
}),
|
|
117
|
+
completeness: z.object({
|
|
118
|
+
complete: z.boolean(),
|
|
119
|
+
symbols_complete: z.boolean(),
|
|
120
|
+
evidence_complete: z.boolean(),
|
|
121
|
+
unresolved: z.array(z.object({
|
|
122
|
+
selector: z.string(),
|
|
123
|
+
reason: z.enum(["source_unresolved", "references_unresolved"]),
|
|
124
|
+
})),
|
|
125
|
+
}),
|
|
126
|
+
budget: z.object({
|
|
127
|
+
max_records: z.number().int().positive(),
|
|
128
|
+
max_bytes: z.number().int().positive(),
|
|
129
|
+
max_depth: z.null(),
|
|
130
|
+
max_edges: z.null(),
|
|
131
|
+
max_invocations: z.number().int().positive(),
|
|
132
|
+
used_bytes: z.number().int().nonnegative(),
|
|
133
|
+
}),
|
|
134
|
+
truncation: z.object({
|
|
135
|
+
truncated: z.boolean(),
|
|
136
|
+
reason: z.enum(TRUNCATION_REASONS).nullable(),
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
export function registerExplore(server) {
|
|
140
|
+
server.registerTool("ast_explore", {
|
|
141
|
+
title: "Explore project context",
|
|
142
|
+
description: "Composes bounded structural search, exact selectors, source evidence and compiler references without weakening the primitive AST tools.",
|
|
143
|
+
inputSchema: ExploreInputSchema,
|
|
144
|
+
outputSchema: ExploreOutputSchema,
|
|
145
|
+
annotations: {
|
|
146
|
+
readOnlyHint: true,
|
|
147
|
+
destructiveHint: false,
|
|
148
|
+
idempotentHint: true,
|
|
149
|
+
openWorldHint: false,
|
|
150
|
+
},
|
|
151
|
+
}, async ({ project_root, query, file_path, symbol_path, kinds, file_filter, detail, include_source, include_references, reference_detail, reference_limit, offset, limit, max_bytes, }) => {
|
|
152
|
+
try {
|
|
153
|
+
const output = await withProject(project_root, (context) => buildExploreContext(context, {
|
|
154
|
+
query,
|
|
155
|
+
filePath: file_path,
|
|
156
|
+
symbolPath: symbol_path,
|
|
157
|
+
kinds,
|
|
158
|
+
fileFilter: file_filter,
|
|
159
|
+
detail,
|
|
160
|
+
includeSource: include_source,
|
|
161
|
+
includeReferences: include_references,
|
|
162
|
+
referenceDetail: reference_detail,
|
|
163
|
+
offset,
|
|
164
|
+
limit,
|
|
165
|
+
referenceLimit: reference_limit,
|
|
166
|
+
maxBytes: max_bytes,
|
|
167
|
+
}));
|
|
168
|
+
return structuredResult(ExploreOutputSchema.parse(output));
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
return errorResult(error);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
import { Node } from "ts-morph";
|
|
3
1
|
import { z } from "zod";
|
|
4
|
-
import { PaginationInputSchema, PaginationOutputSchema
|
|
2
|
+
import { PaginationInputSchema, PaginationOutputSchema } from "../services/pagination.js";
|
|
5
3
|
import { findDeclarationByName, getSourceFileOrThrow, withProject } from "../services/project.js";
|
|
4
|
+
import { collectSymbolReferences } from "../services/references.js";
|
|
6
5
|
import { errorResult, formattedResult, ToolOutputFormatInputSchema } from "./result.js";
|
|
7
6
|
const ReferenceDetailSchema = z.enum(["locations", "context"]).default("locations");
|
|
8
7
|
const AstFindReferencesInputSchema = z.object({
|
|
@@ -59,55 +58,19 @@ export function registerFindReferences(server) {
|
|
|
59
58
|
const structuredContent = await withProject(project_root, ({ project, projectRoot }) => {
|
|
60
59
|
const sourceFile = getSourceFileOrThrow(project, file_path);
|
|
61
60
|
const node = findDeclarationByName(sourceFile, symbol_path);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
const lineCache = new Map();
|
|
66
|
-
const location = (referenceNode, isDeclaration) => {
|
|
67
|
-
const referenceSource = referenceNode.getSourceFile();
|
|
68
|
-
const absoluteFile = referenceSource.getFilePath();
|
|
69
|
-
let lines = lineCache.get(absoluteFile);
|
|
70
|
-
if (!lines) {
|
|
71
|
-
lines = referenceSource.getFullText().split(/\r?\n/);
|
|
72
|
-
lineCache.set(absoluteFile, lines);
|
|
73
|
-
}
|
|
74
|
-
const position = referenceSource.getLineAndColumnAtPos(referenceNode.getStart());
|
|
75
|
-
return {
|
|
76
|
-
file: path.relative(projectRoot, absoluteFile),
|
|
77
|
-
line: position.line,
|
|
78
|
-
column: position.column,
|
|
79
|
-
kind: referenceNode.getParent()?.getKindName() ?? referenceNode.getKindName(),
|
|
80
|
-
is_declaration: isDeclaration,
|
|
81
|
-
context: (lines[position.line - 1] ?? "").trim().slice(0, 500),
|
|
82
|
-
};
|
|
83
|
-
};
|
|
84
|
-
const references = node
|
|
85
|
-
.findReferencesAsNodes()
|
|
86
|
-
.map((reference) => location(reference, false));
|
|
87
|
-
const declaration = location(node, true);
|
|
88
|
-
const allLocations = include_declaration ? [declaration, ...references] : references;
|
|
89
|
-
allLocations.sort((left, right) => `${left.file}:${left.line}:${left.column}:${left.is_declaration ? 0 : 1}`.localeCompare(`${right.file}:${right.line}:${right.column}:${right.is_declaration ? 0 : 1}`));
|
|
90
|
-
const affectedFiles = [
|
|
91
|
-
...new Set([declaration, ...references].map((item) => item.file)),
|
|
92
|
-
].sort();
|
|
93
|
-
const page = paginate(allLocations, offset, limit);
|
|
94
|
-
const { items, ...metadata } = page;
|
|
61
|
+
const collected = collectSymbolReferences(node, projectRoot, symbol_path, include_declaration, detail, offset, limit);
|
|
62
|
+
const { references, ...metadata } = collected;
|
|
95
63
|
return {
|
|
96
|
-
|
|
97
|
-
include_declaration,
|
|
98
|
-
declaration_count: 1,
|
|
99
|
-
reference_count: references.length,
|
|
100
|
-
affected_files: affectedFiles,
|
|
64
|
+
...metadata,
|
|
101
65
|
references: detail === "context"
|
|
102
|
-
?
|
|
103
|
-
:
|
|
66
|
+
? references
|
|
67
|
+
: references.map(({ file, line, column, kind, is_declaration }) => ({
|
|
104
68
|
file,
|
|
105
69
|
line,
|
|
106
70
|
column,
|
|
107
71
|
kind,
|
|
108
72
|
is_declaration,
|
|
109
73
|
})),
|
|
110
|
-
...metadata,
|
|
111
74
|
};
|
|
112
75
|
});
|
|
113
76
|
const outputSchema = ReferenceOutputSchemas[detail];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DEFAULT_FILE_LINE_LIMIT, MAX_FILE_LINE_LIMIT, readFileSnapshot, } from "../services/file-snapshot.js";
|
|
3
|
+
import { buildFileOutline } from "../services/outline.js";
|
|
4
|
+
import { getSourceFileOrThrow, withProject } from "../services/project.js";
|
|
5
|
+
import { errorResult, structuredResult } from "./result.js";
|
|
6
|
+
const AstGetFileInputSchema = z.object({
|
|
7
|
+
project_root: z
|
|
8
|
+
.string()
|
|
9
|
+
.describe("Absolute project directory containing tsconfig.json, or the config path."),
|
|
10
|
+
file_path: z
|
|
11
|
+
.string()
|
|
12
|
+
.describe("Project-relative or unambiguous source file path included by the active tsconfig."),
|
|
13
|
+
offset: z.number().int().min(0).default(0).describe("Zero-based line offset for source mode."),
|
|
14
|
+
limit: z
|
|
15
|
+
.number()
|
|
16
|
+
.int()
|
|
17
|
+
.min(1)
|
|
18
|
+
.max(MAX_FILE_LINE_LIMIT)
|
|
19
|
+
.default(DEFAULT_FILE_LINE_LIMIT)
|
|
20
|
+
.describe(`Maximum source lines to return, capped at ${MAX_FILE_LINE_LIMIT}.`),
|
|
21
|
+
symbols_only: z
|
|
22
|
+
.boolean()
|
|
23
|
+
.default(false)
|
|
24
|
+
.describe("Return body-free symbol selectors and signatures instead of source lines."),
|
|
25
|
+
});
|
|
26
|
+
const FileRangeSchema = z.object({
|
|
27
|
+
offset: z.number().int().min(0),
|
|
28
|
+
limit: z.number().int().min(1).max(MAX_FILE_LINE_LIMIT),
|
|
29
|
+
total_lines: z.number().int().min(0),
|
|
30
|
+
});
|
|
31
|
+
const FileLineSchema = z.object({
|
|
32
|
+
line: z.number().int().positive(),
|
|
33
|
+
text: z.string(),
|
|
34
|
+
});
|
|
35
|
+
const OutlineSymbolSchema = z.object({
|
|
36
|
+
symbolPath: z.string(),
|
|
37
|
+
name: z.string(),
|
|
38
|
+
kind: z.string(),
|
|
39
|
+
signature: z.string(),
|
|
40
|
+
startLine: z.number().int().positive(),
|
|
41
|
+
endLine: z.number().int().positive(),
|
|
42
|
+
});
|
|
43
|
+
const FileSnapshotBaseSchema = {
|
|
44
|
+
file: z.string(),
|
|
45
|
+
file_hash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
46
|
+
snapshot_state: z.enum(["fresh", "stale"]),
|
|
47
|
+
};
|
|
48
|
+
const AstGetFileOutputSchema = z.object({
|
|
49
|
+
mode: z.enum(["source", "symbols_only"]),
|
|
50
|
+
...FileSnapshotBaseSchema,
|
|
51
|
+
range: FileRangeSchema.optional(),
|
|
52
|
+
lines: z.array(FileLineSchema).optional(),
|
|
53
|
+
symbols: z.array(OutlineSymbolSchema).optional(),
|
|
54
|
+
});
|
|
55
|
+
export function registerGetFile(server) {
|
|
56
|
+
server.registerTool("ast_get_file", {
|
|
57
|
+
title: "Get bounded source file",
|
|
58
|
+
description: "Returns exact bounded source lines for one TypeScript/JavaScript file, or body-free symbols when symbols_only is true. Read-only; freshness is reported separately from compiler diagnostics.",
|
|
59
|
+
inputSchema: AstGetFileInputSchema,
|
|
60
|
+
outputSchema: AstGetFileOutputSchema,
|
|
61
|
+
annotations: {
|
|
62
|
+
readOnlyHint: true,
|
|
63
|
+
destructiveHint: false,
|
|
64
|
+
idempotentHint: true,
|
|
65
|
+
openWorldHint: false,
|
|
66
|
+
},
|
|
67
|
+
}, async ({ project_root, file_path, offset, limit, symbols_only }) => {
|
|
68
|
+
try {
|
|
69
|
+
const structuredContent = await withProject(project_root, async ({ project, projectRoot }) => {
|
|
70
|
+
const snapshot = await readFileSnapshot(project, projectRoot, file_path, {
|
|
71
|
+
offset,
|
|
72
|
+
limit,
|
|
73
|
+
});
|
|
74
|
+
if (!symbols_only) {
|
|
75
|
+
return {
|
|
76
|
+
mode: "source",
|
|
77
|
+
...snapshot,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const sourceFile = getSourceFileOrThrow(project, file_path);
|
|
81
|
+
return {
|
|
82
|
+
mode: "symbols_only",
|
|
83
|
+
file: snapshot.file,
|
|
84
|
+
file_hash: snapshot.file_hash,
|
|
85
|
+
snapshot_state: snapshot.snapshot_state,
|
|
86
|
+
symbols: buildFileOutline(sourceFile).symbols,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
return structuredResult(structuredContent);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
return errorResult(error);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
1
|
import { z } from "zod";
|
|
3
|
-
import { buildFileOutline } from "../services/outline.js";
|
|
4
2
|
import { createPaginationInputSchema, PaginationOutputSchema, paginate, } from "../services/pagination.js";
|
|
5
3
|
import { withProject } from "../services/project.js";
|
|
6
|
-
import {
|
|
4
|
+
import { searchProjectSymbols } from "../services/symbols.js";
|
|
7
5
|
import { errorResult, formattedResult, ToolOutputFormatInputSchema } from "./result.js";
|
|
8
6
|
const SEARCH_DEFAULT_LIMIT = 20;
|
|
9
7
|
const SearchDetailSchema = z.enum(["selectors", "summary", "full"]).default("summary");
|
|
@@ -92,57 +90,10 @@ export function registerSearchSymbols(server) {
|
|
|
92
90
|
try {
|
|
93
91
|
const structuredContent = await withProject(project_root, ({ project, projectRoot }) => {
|
|
94
92
|
const startedAt = performance.now();
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
for (const sourceFile of project.getSourceFiles()) {
|
|
100
|
-
const file = path.relative(projectRoot, sourceFile.getFilePath());
|
|
101
|
-
if (normalizedFileFilter && !file.toLowerCase().includes(normalizedFileFilter)) {
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
const matchingSymbols = collectSymbols(sourceFile).filter((symbol) => {
|
|
105
|
-
const selector = `${symbol.symbolPath}@${symbol.line}`.toLowerCase();
|
|
106
|
-
return ((!kindSet || kindSet.has(symbol.kind)) &&
|
|
107
|
-
(symbol.name.toLowerCase().includes(normalizedQuery) ||
|
|
108
|
-
symbol.symbolPath.toLowerCase().includes(normalizedQuery) ||
|
|
109
|
-
selector.includes(normalizedQuery)));
|
|
110
|
-
});
|
|
111
|
-
if (matchingSymbols.length === 0)
|
|
112
|
-
continue;
|
|
113
|
-
const signatures = new Map(buildFileOutline(sourceFile).symbols.map((symbol) => [
|
|
114
|
-
`${symbol.symbolPath}@${symbol.startLine}`,
|
|
115
|
-
symbol.signature,
|
|
116
|
-
]));
|
|
117
|
-
for (const symbol of matchingSymbols) {
|
|
118
|
-
matches.push({
|
|
119
|
-
file,
|
|
120
|
-
symbol_path: symbol.symbolPath,
|
|
121
|
-
selector: `${symbol.symbolPath}@${symbol.line}`,
|
|
122
|
-
name: symbol.name,
|
|
123
|
-
kind: symbol.kind,
|
|
124
|
-
line: symbol.line,
|
|
125
|
-
signature: signatures.get(`${symbol.symbolPath}@${symbol.line}`) ?? symbol.node.getText(),
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
matches.sort((left, right) => {
|
|
130
|
-
const rank = symbolMatchRank(query, {
|
|
131
|
-
symbolPath: left.symbol_path,
|
|
132
|
-
name: left.name,
|
|
133
|
-
line: left.line,
|
|
134
|
-
}) -
|
|
135
|
-
symbolMatchRank(query, {
|
|
136
|
-
symbolPath: right.symbol_path,
|
|
137
|
-
name: right.name,
|
|
138
|
-
line: right.line,
|
|
139
|
-
});
|
|
140
|
-
if (rank !== 0)
|
|
141
|
-
return rank;
|
|
142
|
-
return (left.file.localeCompare(right.file) ||
|
|
143
|
-
left.line - right.line ||
|
|
144
|
-
left.symbol_path.localeCompare(right.symbol_path) ||
|
|
145
|
-
left.kind.localeCompare(right.kind));
|
|
93
|
+
const matches = searchProjectSymbols(project, projectRoot, {
|
|
94
|
+
query,
|
|
95
|
+
kinds,
|
|
96
|
+
fileFilter: file_filter,
|
|
146
97
|
});
|
|
147
98
|
const page = paginate(matches, offset, limit);
|
|
148
99
|
const { items, ...metadata } = page;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ast-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Structural code MCP server and batch CLI for compact TypeScript/JavaScript reads and hash-bound AST edits.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"benchmark:batch": "yarn build && node scripts/benchmark-batch.mjs",
|
|
57
57
|
"benchmark:formats": "yarn build && node scripts/benchmark-formats.mjs",
|
|
58
58
|
"benchmark:shapes": "yarn build && node scripts/benchmark-result-shapes.mjs",
|
|
59
|
+
"benchmark:agent-workflows": "yarn build && node scripts/benchmark-agent-workflows.mjs",
|
|
59
60
|
"lint": "eslint src test scripts vitest.config.ts eslint.config.js",
|
|
60
61
|
"format": "prettier --write .",
|
|
61
62
|
"format:check": "prettier --check .",
|
|
@@ -82,4 +83,4 @@
|
|
|
82
83
|
"typescript-eslint": "^8.65.0",
|
|
83
84
|
"vitest": "^4.1.10"
|
|
84
85
|
}
|
|
85
|
-
}
|
|
86
|
+
}
|
|
@@ -17,6 +17,7 @@ metadata:
|
|
|
17
17
|
Usar las tools del servidor MCP `ast` en proyectos TypeScript/JavaScript con `tsconfig.json` cuando haya que:
|
|
18
18
|
|
|
19
19
|
- orientarse en archivos o módulos grandes/desconocidos;
|
|
20
|
+
- leer rangos acotados del source exacto de un archivo conocido;
|
|
20
21
|
- encontrar declaraciones o referencias reales;
|
|
21
22
|
- medir el radio de impacto de un rename;
|
|
22
23
|
- renombrar un símbolo en varios archivos;
|
|
@@ -40,16 +41,22 @@ El setup falla cerrado ante un registro `ast` conflictivo. No eliminar ni reempl
|
|
|
40
41
|
## Flujo compacto de lectura
|
|
41
42
|
|
|
42
43
|
1. `ast_list_files` para descubrir archivos. Paginar; no pedir todo un monorepo si alcanza con un filtro.
|
|
43
|
-
2. `
|
|
44
|
-
3. `
|
|
45
|
-
4. `
|
|
46
|
-
5. `
|
|
47
|
-
6. `
|
|
44
|
+
2. `ast_explore` para una pregunta de lectura que combine descubrimiento y evidencia. Usar `summary` por defecto, `context` para source seleccionado y `full` para source más referencias.
|
|
45
|
+
3. `ast_get_file` si ya se conoce el archivo y hace falta source exacto. Usar `offset`/`limit`; no cargar el archivo completo por defecto.
|
|
46
|
+
4. `ast_search_symbols` si todavía no se conoce el archivo o el selector exacto.
|
|
47
|
+
5. `ast_get_outline` para ver contratos sin cuerpos. No pedir `include_symbols` salvo que haga falta la metadata detallada.
|
|
48
|
+
6. `ast_get_symbol_source` solo para las declaraciones cuya implementación haya que inspeccionar.
|
|
49
|
+
7. `ast_find_references` antes de renames o cambios con impacto cross-file.
|
|
50
|
+
8. `ast_get_diagnostics` para establecer y verificar el estado del proyecto.
|
|
48
51
|
|
|
49
52
|
`ast_search_symbols` devuelve por default hasta 20 records `summary` rankeados. Su campo `selector` es el valor que se pasa como `symbol_path` a la siguiente tool; pedir `detail: "selectors"` para routing puro o `detail: "full", limit: 100` para el perfil v0.4.0. `ast_find_references` devuelve `detail: "locations"` por default; expandir a `detail: "context"` únicamente cuando la línea fuente aporte evidencia necesaria.
|
|
50
53
|
|
|
51
54
|
`file_path` debe ser preferentemente relativo al proyecto. Si un suffix coincide con varios archivos, la tool falla y devuelve candidatos en vez de elegir uno silenciosamente.
|
|
52
55
|
|
|
56
|
+
`ast_get_file` es read-only y solo acepta archivos incluidos por el tsconfig activo. Su modo normal devuelve líneas exactas, paginadas y numeradas desde 1, junto con el hash SHA-256 de los bytes actuales. `symbols_only: true` devuelve selectors y signatures sin cuerpos. `snapshot_state: "fresh"` describe sincronización con el snapshot del compilador, no ausencia de diagnostics; consultar `ast_get_diagnostics` por separado.
|
|
57
|
+
|
|
58
|
+
`ast_explore` es read-only y no prepara ni aplica operaciones. Sus selectors son directamente reutilizables por las tools primitivas y de mutación. La respuesta declara `freshness`, `completeness`, `truncation`, `unresolved` y `budget`; si el límite de bytes impide incluir todo el contexto, el resultado queda marcado como incompleto.
|
|
59
|
+
|
|
53
60
|
Los clientes pueden prefijar los nombres publicados (`ast_*`) según su convención MCP; elegir por el nombre base y el schema, no adivinar el prefijo.
|
|
54
61
|
|
|
55
62
|
## Formato de resultado para el modelo
|
package/benchmark/README.md
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
# Benchmark methodology
|
|
2
|
-
|
|
3
|
-
The benchmark suite measures structural payload reduction, batch orchestration, and model-facing result formats. The first two benchmarks deliberately avoid converting characters into tokens. The format benchmark pins and names one tokenizer so its estimates are reproducible rather than universal.
|
|
4
|
-
|
|
5
|
-
## Project payload and latency
|
|
6
|
-
|
|
7
|
-
`scripts/benchmark.mjs`:
|
|
8
|
-
|
|
9
|
-
1. Loads a real TypeScript project from disk.
|
|
10
|
-
2. Selects the lexicographically first non-declaration source files after an optional filter.
|
|
11
|
-
3. Serializes the default compact outline payload (`file` plus `outline`).
|
|
12
|
-
4. Also records the larger opt-in payload containing detailed symbol metadata.
|
|
13
|
-
5. Reports fresh project load, cold cached-session, warm cached-session, and per-file outline latency.
|
|
14
|
-
|
|
15
|
-
Run:
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
yarn benchmark /absolute/project/path --sample 20 --output benchmark/results/project.json
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
The selection rule is deterministic but not statistically representative of every repository. Use `--filter` and multiple samples when comparing particular modules.
|
|
22
|
-
|
|
23
|
-
## Compact-first task corpus
|
|
24
|
-
|
|
25
|
-
`task-corpus.json` defines twelve questions over this repository. Each task declares:
|
|
26
|
-
|
|
27
|
-
- files a full-file workflow would load;
|
|
28
|
-
- declarations a compact-first workflow would request after inspecting outlines;
|
|
29
|
-
- evidence strings necessary to answer that task.
|
|
30
|
-
|
|
31
|
-
`scripts/benchmark-corpus.mjs` compares:
|
|
32
|
-
|
|
33
|
-
- baseline: complete text of every declared file;
|
|
34
|
-
- compact: default outline payloads plus only the selected declaration sources.
|
|
35
|
-
|
|
36
|
-
It fails unless every predefined evidence string is present in the compact payload. This makes the check reproducible, but it does not prove that every possible reviewer would reach an equivalent conclusion.
|
|
37
|
-
|
|
38
|
-
Run:
|
|
39
|
-
|
|
40
|
-
```bash
|
|
41
|
-
yarn benchmark:corpus benchmark/task-corpus.json --output benchmark/results/self-corpus.json
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
## Interpreting results
|
|
45
|
-
|
|
46
|
-
- Character reduction measures serialized payload size, not billed model tokens.
|
|
47
|
-
- Negative reduction on a tiny file is valid: protocol structure can cost more than reading that file directly.
|
|
48
|
-
- Project-load latency and warm-session latency should be considered separately.
|
|
49
|
-
- Detailed symbol metadata is opt-in because it repeats signature text and can dominate small files.
|
|
50
|
-
- Benchmark scripts record absolute local paths and timestamps. Checked reports replace local paths with descriptive placeholders; regenerate them when comparing another machine or revision.
|
|
51
|
-
|
|
52
|
-
## Batch orchestration
|
|
53
|
-
|
|
54
|
-
`scripts/benchmark-batch.mjs` compares a two-operation search-to-source workflow in fresh Node processes:
|
|
55
|
-
|
|
56
|
-
- separate: the client receives the broad symbol-search payload, selects the first match, then requests its source;
|
|
57
|
-
- batch: `ast-tool` performs the same two MCP tool invocations in process and emits only the final source result.
|
|
58
|
-
|
|
59
|
-
Run:
|
|
60
|
-
|
|
61
|
-
```bash
|
|
62
|
-
yarn benchmark:batch --iterations 5 --output benchmark/results/self-batch.json
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
The report records logical model round-trips, actual tool invocations, median wall time, process maximum RSS, final-result characters, and total characters exposed to the model. It does not include model inference latency, so the practical wall-time benefit of removing a model round-trip will be larger than this local process-only comparison. Maximum RSS includes the Node runtime and TypeScript project in each fresh worker.
|
|
66
|
-
|
|
67
|
-
## Model-facing JSON and TOON
|
|
68
|
-
|
|
69
|
-
`scripts/benchmark-formats.mjs` compares compact JSON with TOON for actual MCP logical results. It uses this repository for broad symbol search and deterministic temporary TypeScript fixtures for repeated references and non-empty diagnostics. File lists, outlines, exact source, and a prepared rename are retained as negative controls.
|
|
70
|
-
|
|
71
|
-
For each payload it records:
|
|
72
|
-
|
|
73
|
-
- exact JSON→TOON→value round-trip equality;
|
|
74
|
-
- UTF-8 bytes;
|
|
75
|
-
- `gpt-tokenizer@3.4.0` `o200k_base` token estimates;
|
|
76
|
-
- plain TOON for CLI output and the real MCP `{format,data}` envelope;
|
|
77
|
-
- median encode/decode time over 50 local iterations;
|
|
78
|
-
- serialized `tools/list` metadata against the checked v0.3.0 baseline.
|
|
79
|
-
|
|
80
|
-
Run:
|
|
81
|
-
|
|
82
|
-
```bash
|
|
83
|
-
yarn benchmark:formats
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
The script fails unless every round trip is exact, the broad-search MCP envelope reduces estimated tokens by at least 20%, and the aggregate eligible corpus reduces them by at least 15%. Tokenizer estimates are not provider usage records and do not establish billed-token or prompt-cache savings.
|
|
87
|
-
|
|
88
|
-
## Checked results
|
|
89
|
-
|
|
90
|
-
Results generated on 2026-08-03 with Node.js v24.16.0:
|
|
91
|
-
|
|
92
|
-
| Workload | Sample | Full source chars | Compact chars | Reduction | Fresh load | Warm session |
|
|
93
|
-
| --------------- | -------: | ----------------: | ------------: | --------: | ---------: | -----------: |
|
|
94
|
-
| This repository | 20 files | 77,982 | 12,512 | 83.96% | 543 ms | 1.86 ms |
|
|
95
|
-
| `x-scraper` | 20 files | 178,110 | 17,036 | 90.44% | 3,951 ms | 180.16 ms |
|
|
96
|
-
| Task corpus | 12 tasks | 83,289 | 28,372 | 65.94% | n/a | n/a |
|
|
97
|
-
|
|
98
|
-
The task corpus passed all 12 predefined evidence checks. Its compact count includes the selected declaration bodies, not just outlines. Exact raw reports are under `benchmark/results/`.
|
|
99
|
-
|
|
100
|
-
The checked broad search-to-source batch result used five fresh-process samples:
|
|
101
|
-
|
|
102
|
-
| Mode | Model round-trips | Tool invocations | Context chars | Median wall time | Median max RSS |
|
|
103
|
-
| -------- | ----------------: | ---------------: | ------------: | ---------------: | -------------: |
|
|
104
|
-
| Separate | 2 | 2 | 7,818 | 587.03 ms | 380.68 MB |
|
|
105
|
-
| Batch | 1 | 2 | 417 | 607.95 ms | 379.15 MB |
|
|
106
|
-
|
|
107
|
-
That run reduced model round-trips by 50% and serialized context by 94.67%. Local execution latency increased by 3.56% and RSS decreased by 0.40%, both small enough to treat as noise rather than a performance claim. The batch value is orchestration/context reduction; it does not make TypeScript analysis free.
|
|
108
|
-
|
|
109
|
-
The checked model-facing format corpus used the complete MCP envelope for TOON:
|
|
110
|
-
|
|
111
|
-
| Payload | Records | JSON tokens | MCP TOON tokens | Reduction |
|
|
112
|
-
| ------------------- | ------: | ----------: | --------------: | --------: |
|
|
113
|
-
| Broad symbol search | 100 | 5,382 | 4,061 | 24.54% |
|
|
114
|
-
| References | 71 | 3,094 | 2,159 | 30.22% |
|
|
115
|
-
| Diagnostics | 30 | 1,309 | 1,034 | 21.01% |
|
|
116
|
-
| Eligible aggregate | 201 | 9,785 | 7,254 | 25.87% |
|
|
117
|
-
|
|
118
|
-
All seven positive and negative payloads round-tripped exactly. File list, outline, source, and prepare envelopes were 5.13% to 13.56% worse in estimated tokens, which is why they remain JSON-only. The complete eleven-tool metadata is 3,372 serialized characters larger than the retained v0.3.0 baseline; this static protocol cost is reported separately from dynamic result savings.
|
|
119
|
-
|
|
120
|
-
The checked result-shaping corpus compares the v0.4.0-compatible `full/100/context` profiles with the new public defaults across exact-name, exact-path, prefix, broad-substring, and multi-file-reference workflows:
|
|
121
|
-
|
|
122
|
-
| Profile | Logical calls | MCP TOON tokens |
|
|
123
|
-
| --------- | ------------: | --------------: |
|
|
124
|
-
| Baseline | 6 | 3,910 |
|
|
125
|
-
| Candidate | 6 | 1,220 |
|
|
126
|
-
|
|
127
|
-
Every declared selector and reference coordinate remained present, and the aggregate reduction was 68.80%, above the checked 35% gate. `duration_ms` is omitted from both measured representations to make token counts deterministic; real JSON/TOON outputs are still decode-compared before measurement. These are local `o200k_base` estimates, not provider billing, cache, or latency evidence.
|
|
128
|
-
|
|
129
|
-
The complete v0.5.0 `tools/list` metadata is 22,473 serialized characters versus the retained v0.4.0 value of 16,650 (`+5,823`). Removing only `ast_scaffold_class` from the current list reduces it by 5,411 characters and 1,295 local `o200k_base` tokens. No v0.4.0 metadata token count was retained, so the report intentionally leaves the historical token delta unset.
|