ast-mcp-server 0.5.0 → 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 +28 -0
- package/README.md +20 -3
- package/dist/server.js +6 -0
- package/dist/services/context-builder.js +234 -0
- package/dist/services/file-snapshot.js +96 -0
- package/dist/services/project-status.js +736 -0
- package/dist/services/project.js +87 -13
- package/dist/services/read-contracts.js +159 -0
- package/dist/services/references.js +48 -0
- package/dist/services/symbols.js +59 -0
- package/dist/services/workspace.js +12 -6
- 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/get_project_status.js +68 -0
- package/dist/tools/search_symbols.js +5 -54
- package/package.json +2 -1
- package/skills/structural-code-editing/SKILL.md +12 -5
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { FRESHNESS_CAUSES, SNAPSHOT_STATES, TRUNCATION_REASONS, } from "../services/read-contracts.js";
|
|
3
|
+
import { getProjectStatus } from "../services/project.js";
|
|
4
|
+
import { errorResult, structuredResult } from "./result.js";
|
|
5
|
+
const AstGetProjectStatusInputSchema = z.object({
|
|
6
|
+
project_root: z
|
|
7
|
+
.string()
|
|
8
|
+
.describe("Absolute project directory containing tsconfig.json, or the config path."),
|
|
9
|
+
});
|
|
10
|
+
const TruncationMetadataSchema = z.object({
|
|
11
|
+
truncated: z.boolean(),
|
|
12
|
+
reason: z.enum(TRUNCATION_REASONS).nullable(),
|
|
13
|
+
});
|
|
14
|
+
const ProjectStatusOutputSchema = z.object({
|
|
15
|
+
project: z.object({
|
|
16
|
+
project_id: z.string().regex(/^project_[0-9a-f]{20}$/),
|
|
17
|
+
config_id: z
|
|
18
|
+
.string()
|
|
19
|
+
.regex(/^config_[0-9a-f]{20}$/)
|
|
20
|
+
.nullable(),
|
|
21
|
+
}),
|
|
22
|
+
state: z.enum(SNAPSHOT_STATES),
|
|
23
|
+
causes: z.array(z.enum(FRESHNESS_CAUSES)),
|
|
24
|
+
source_count: z.number().int().min(0),
|
|
25
|
+
indexed_count: z.number().int().min(0),
|
|
26
|
+
pending_files: z.array(z.string()),
|
|
27
|
+
pending_files_truncated: z.boolean(),
|
|
28
|
+
pending_files_truncation: TruncationMetadataSchema,
|
|
29
|
+
pending_files_filename_truncation: TruncationMetadataSchema,
|
|
30
|
+
compiler: z.object({ state: z.enum(["ready", "rebuilding", "failed"]) }),
|
|
31
|
+
index: z.object({ state: z.literal("disabled") }),
|
|
32
|
+
operation_queue: z.object({
|
|
33
|
+
state: z.enum(["idle", "queued", "running"]),
|
|
34
|
+
active_operations: z.number().int().min(0),
|
|
35
|
+
queued_operations: z.number().int().min(0),
|
|
36
|
+
}),
|
|
37
|
+
watcher: z.object({ state: z.enum(["disabled", "ready", "failed"]) }),
|
|
38
|
+
last_successful_sync_at: z.string().nullable(),
|
|
39
|
+
last_successful_index_at: z.null(),
|
|
40
|
+
source_snapshot_fingerprint: z.string().nullable(),
|
|
41
|
+
config_snapshot_fingerprint: z.string().nullable(),
|
|
42
|
+
canonical_snapshot_fingerprint: z.string().nullable(),
|
|
43
|
+
degraded_errors: z.array(z.string()),
|
|
44
|
+
degraded_errors_truncation: TruncationMetadataSchema,
|
|
45
|
+
degraded_errors_text_truncation: TruncationMetadataSchema,
|
|
46
|
+
});
|
|
47
|
+
export function registerGetProjectStatus(server) {
|
|
48
|
+
server.registerTool("ast_get_project_status", {
|
|
49
|
+
title: "Get project freshness status",
|
|
50
|
+
description: "Returns bounded, read-only compiler freshness and project status without exposing filesystem identities or credentials.",
|
|
51
|
+
inputSchema: AstGetProjectStatusInputSchema,
|
|
52
|
+
outputSchema: ProjectStatusOutputSchema,
|
|
53
|
+
annotations: {
|
|
54
|
+
readOnlyHint: true,
|
|
55
|
+
destructiveHint: false,
|
|
56
|
+
idempotentHint: true,
|
|
57
|
+
openWorldHint: false,
|
|
58
|
+
},
|
|
59
|
+
}, async ({ project_root }) => {
|
|
60
|
+
try {
|
|
61
|
+
const output = await getProjectStatus(project_root);
|
|
62
|
+
return structuredResult({ ...output });
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
return errorResult(error);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -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 .",
|
|
@@ -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
|