ast-search-mcp 0.1.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.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/build/index.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { server } from "./server.js";
4
+ const transport = new StdioServerTransport();
5
+ await server.connect(transport);
@@ -0,0 +1,77 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ export declare function loadPlugins(plugins: string[]): Promise<void>;
4
+ /** Reset plugin tracking — for testing only. */
5
+ export declare function _resetLoadedPlugins(): void;
6
+ type ToolResult = {
7
+ content: Array<{
8
+ type: "text";
9
+ text: string;
10
+ }>;
11
+ isError?: boolean;
12
+ };
13
+ export declare const searchSchema: z.ZodObject<{
14
+ queries: z.ZodArray<z.ZodString, "many">;
15
+ dir: z.ZodOptional<z.ZodString>;
16
+ lang: z.ZodOptional<z.ZodString>;
17
+ exclude: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
18
+ limit: z.ZodOptional<z.ZodNumber>;
19
+ context: z.ZodOptional<z.ZodNumber>;
20
+ showAst: z.ZodOptional<z.ZodBoolean>;
21
+ plugins: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
22
+ }, "strip", z.ZodTypeAny, {
23
+ queries: string[];
24
+ dir?: string | undefined;
25
+ lang?: string | undefined;
26
+ exclude?: string[] | undefined;
27
+ limit?: number | undefined;
28
+ context?: number | undefined;
29
+ showAst?: boolean | undefined;
30
+ plugins?: string[] | undefined;
31
+ }, {
32
+ queries: string[];
33
+ dir?: string | undefined;
34
+ lang?: string | undefined;
35
+ exclude?: string[] | undefined;
36
+ limit?: number | undefined;
37
+ context?: number | undefined;
38
+ showAst?: boolean | undefined;
39
+ plugins?: string[] | undefined;
40
+ }>;
41
+ export declare const validateSchema: z.ZodObject<{
42
+ query: z.ZodString;
43
+ lang: z.ZodOptional<z.ZodString>;
44
+ plugins: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
45
+ }, "strip", z.ZodTypeAny, {
46
+ query: string;
47
+ lang?: string | undefined;
48
+ plugins?: string[] | undefined;
49
+ }, {
50
+ query: string;
51
+ lang?: string | undefined;
52
+ plugins?: string[] | undefined;
53
+ }>;
54
+ export declare const showAstSchema: z.ZodObject<{
55
+ code: z.ZodOptional<z.ZodString>;
56
+ file: z.ZodOptional<z.ZodString>;
57
+ lines: z.ZodOptional<z.ZodString>;
58
+ lang: z.ZodOptional<z.ZodString>;
59
+ plugins: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
60
+ }, "strip", z.ZodTypeAny, {
61
+ code?: string | undefined;
62
+ lang?: string | undefined;
63
+ plugins?: string[] | undefined;
64
+ file?: string | undefined;
65
+ lines?: string | undefined;
66
+ }, {
67
+ code?: string | undefined;
68
+ lang?: string | undefined;
69
+ plugins?: string[] | undefined;
70
+ file?: string | undefined;
71
+ lines?: string | undefined;
72
+ }>;
73
+ export declare function handleSearch(args: z.infer<typeof searchSchema>): Promise<ToolResult>;
74
+ export declare function handleValidateQuery(args: z.infer<typeof validateSchema>): Promise<ToolResult>;
75
+ export declare function handleShowAst(args: z.infer<typeof showAstSchema>): Promise<ToolResult>;
76
+ export declare const server: McpServer;
77
+ export {};
@@ -0,0 +1,206 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { searchRepoWithMeta, defaultRegistry, explainSelector, enrichWithContext, } from "ast-search-js";
4
+ import { LanguageRegistry } from "ast-search-js/plugin";
5
+ import { readFile } from "node:fs/promises";
6
+ import { extname } from "node:path";
7
+ // ---------------------------------------------------------------------------
8
+ // Plugin management
9
+ // ---------------------------------------------------------------------------
10
+ // Track loaded plugins so we never double-register.
11
+ // Node.js module caching makes re-imports free; the Set prevents duplicate register() calls.
12
+ const loadedPlugins = new Set();
13
+ export async function loadPlugins(plugins) {
14
+ for (const pkg of plugins) {
15
+ if (loadedPlugins.has(pkg))
16
+ continue;
17
+ const mod = (await import(pkg));
18
+ const reg = mod.register ?? mod.default?.register;
19
+ if (typeof reg !== "function") {
20
+ throw new Error(`Plugin "${pkg}" has no register() export`);
21
+ }
22
+ reg(defaultRegistry);
23
+ loadedPlugins.add(pkg);
24
+ }
25
+ }
26
+ /** Reset plugin tracking — for testing only. */
27
+ export function _resetLoadedPlugins() {
28
+ loadedPlugins.clear();
29
+ }
30
+ // ---------------------------------------------------------------------------
31
+ // Input schemas (exported so tests can inspect them)
32
+ // ---------------------------------------------------------------------------
33
+ export const searchSchema = z.object({
34
+ queries: z.array(z.string()).min(1).describe("One or more AST selector queries. CSS selectors for JS/TS/Vue; S-expressions for Python."),
35
+ dir: z.string().optional().describe("Root directory to search (default: current working directory)"),
36
+ lang: z.string().optional().describe('Restrict search to one language backend, e.g. "js" or "python"'),
37
+ exclude: z.array(z.string()).optional().describe('Glob patterns to exclude, e.g. ["**/*.test.ts", "dist/**"]'),
38
+ limit: z.number().int().positive().optional().describe("Stop after N matches. Use for exploratory scope checks on large repos."),
39
+ context: z.number().int().nonnegative().optional().describe("Number of lines of source context to include above and below each match"),
40
+ showAst: z.boolean().optional().describe("Include the AST subtree of each matched node. Useful when writing or debugging queries."),
41
+ plugins: z.array(z.string()).optional().describe('Language plugin packages to load, e.g. ["ast-search-python"]. Loaded once per session.'),
42
+ });
43
+ export const validateSchema = z.object({
44
+ query: z.string().describe("AST selector to validate"),
45
+ lang: z.string().optional().describe('Language backend to validate against: "js" (default) or "python"'),
46
+ plugins: z.array(z.string()).optional().describe("Language plugin packages required for the target language"),
47
+ });
48
+ export const showAstSchema = z.object({
49
+ code: z.string().optional().describe("Inline code snippet to parse and print"),
50
+ file: z.string().optional().describe("Path to a source file to parse"),
51
+ lines: z.string().optional().describe('Line range when using file, e.g. "10-20" (1-indexed, inclusive)'),
52
+ lang: z.string().optional().describe('Language to use for parsing: "js" (default) or "python". Inferred from file extension when using file.'),
53
+ plugins: z.array(z.string()).optional().describe("Language plugin packages required for the target language"),
54
+ });
55
+ // ---------------------------------------------------------------------------
56
+ // Handler: search
57
+ // ---------------------------------------------------------------------------
58
+ export async function handleSearch(args) {
59
+ const { queries, dir, lang, exclude, limit, context, showAst, plugins } = args;
60
+ try {
61
+ await loadPlugins(plugins ?? []);
62
+ let registry = defaultRegistry;
63
+ if (lang) {
64
+ const backend = defaultRegistry.getByLangId(lang);
65
+ if (!backend) {
66
+ const available = defaultRegistry.allBackends.map((b) => b.langId).join(", ");
67
+ throw new Error(`Unknown language "${lang}". Available: ${available}`);
68
+ }
69
+ registry = new LanguageRegistry();
70
+ registry.register(backend);
71
+ }
72
+ const startMs = Date.now();
73
+ const { matches: rawMatches, filesSearched, truncated } = await searchRepoWithMeta(queries, dir ?? process.cwd(), registry, exclude ?? [], { showAst, limit });
74
+ const wallMs = Date.now() - startMs;
75
+ const matches = (context ?? 0) > 0
76
+ ? await enrichWithContext(rawMatches, context)
77
+ : rawMatches;
78
+ const output = {
79
+ matches,
80
+ _meta: { matchCount: matches.length, filesSearched, wallMs, queries, truncated },
81
+ };
82
+ return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
83
+ }
84
+ catch (err) {
85
+ const msg = err instanceof Error ? err.message : String(err);
86
+ return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
87
+ }
88
+ }
89
+ // ---------------------------------------------------------------------------
90
+ // Handler: validate_query
91
+ // ---------------------------------------------------------------------------
92
+ export async function handleValidateQuery(args) {
93
+ const { query, lang, plugins } = args;
94
+ try {
95
+ await loadPlugins(plugins ?? []);
96
+ const backend = lang
97
+ ? defaultRegistry.getByLangId(lang)
98
+ : defaultRegistry.getByLangId("js");
99
+ if (!backend) {
100
+ const available = defaultRegistry.allBackends.map((b) => b.langId).join(", ");
101
+ throw new Error(`Unknown language "${lang}". Available: ${available}`);
102
+ }
103
+ await backend.validateSelector(query);
104
+ const explanation = backend.langId === "js" ? explainSelector(query) : undefined;
105
+ const result = { valid: true, lang: backend.langId };
106
+ if (explanation)
107
+ result.explanation = explanation;
108
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
109
+ }
110
+ catch (err) {
111
+ const msg = err instanceof Error ? err.message : String(err);
112
+ const isConfigError = msg.startsWith("Unknown language");
113
+ if (isConfigError) {
114
+ return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
115
+ }
116
+ return {
117
+ content: [
118
+ { type: "text", text: JSON.stringify({ valid: false, error: msg }, null, 2) },
119
+ ],
120
+ };
121
+ }
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // Handler: show_ast
125
+ // ---------------------------------------------------------------------------
126
+ export async function handleShowAst(args) {
127
+ const { code, file, lines, lang, plugins } = args;
128
+ try {
129
+ await loadPlugins(plugins ?? []);
130
+ let source;
131
+ let filePath;
132
+ if (file) {
133
+ source = await readFile(file, "utf8");
134
+ if (lines) {
135
+ const parts = lines.split("-").map(Number);
136
+ const start = parts[0];
137
+ const end = parts[1] ?? start;
138
+ if (isNaN(start) || isNaN(end) || start < 1 || end < start) {
139
+ throw new Error(`Invalid lines value "${lines}". Expected format: N or N-M (e.g. "10-20")`);
140
+ }
141
+ source = source.split("\n").slice(start - 1, end).join("\n");
142
+ }
143
+ filePath = file;
144
+ }
145
+ else if (code) {
146
+ source = code;
147
+ filePath = lang === "python" ? "snippet.py" : "snippet.ts";
148
+ }
149
+ else {
150
+ throw new Error("Provide either code (inline snippet) or file (path to source file)");
151
+ }
152
+ const backend = lang
153
+ ? defaultRegistry.getByLangId(lang)
154
+ : file
155
+ ? defaultRegistry.getByExtension(extname(file))
156
+ : defaultRegistry.getByLangId("js");
157
+ if (!backend) {
158
+ const available = defaultRegistry.allBackends.map((b) => b.langId).join(", ");
159
+ const hint = lang ? ` Did you forget to include it in plugins?` : "";
160
+ throw new Error(`No backend for "${lang ?? extname(file ?? "")}".${hint} Available: ${available}`);
161
+ }
162
+ if (!backend.printAst) {
163
+ throw new Error(`Backend "${backend.langId}" does not support show_ast`);
164
+ }
165
+ const ast = await backend.parse(source, filePath);
166
+ const text = backend.printAst(ast, source, "text");
167
+ return { content: [{ type: "text", text }] };
168
+ }
169
+ catch (err) {
170
+ const msg = err instanceof Error ? err.message : String(err);
171
+ return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
172
+ }
173
+ }
174
+ // ---------------------------------------------------------------------------
175
+ // Server (configured but not connected — connection happens in index.ts)
176
+ // ---------------------------------------------------------------------------
177
+ export const server = new McpServer({ name: "ast-search", version: "0.1.0" });
178
+ server.registerTool("search", {
179
+ title: "Search code by AST pattern",
180
+ description: `Search source code using AST structural patterns (CSS selectors for JS/TS/Vue; S-expressions for Python).
181
+ Returns precise match locations with file path, line, column, source snippet, and scope metadata.
182
+ Prefer this over grep/ripgrep when the query is about code structure rather than text.
183
+
184
+ Query examples:
185
+ ["FunctionDeclaration[async=true]"] — async function declarations
186
+ ["call[callee.property.name='log']"] — calls to any .log() method
187
+ ["await"] — all await expressions
188
+ ["ImportDeclaration[source.value='react']"] — files importing react
189
+
190
+ Workflow tips:
191
+ - Use limit (e.g. 10) for scope checks before committing to a large refactor.
192
+ - Pass multiple queries to search for several patterns in a single repo walk.
193
+ - Use showAst: true when you need to refine a query — it prints the AST subtree of each match.
194
+ - For Python files, pass plugins: ["ast-search-python"] and use tree-sitter S-expressions.`,
195
+ inputSchema: searchSchema,
196
+ }, handleSearch);
197
+ server.registerTool("validate_query", {
198
+ title: "Validate an AST query",
199
+ description: "Validate an AST selector without running a search. Returns whether the syntax is valid and, for JS queries, a plain-English explanation of what nodes the query matches. Use this before running an unfamiliar query on a large repo.",
200
+ inputSchema: validateSchema,
201
+ }, handleValidateQuery);
202
+ server.registerTool("show_ast", {
203
+ title: "Show AST structure for code",
204
+ description: "Print the AST structure of a code snippet or file. Use this to discover node types and property paths when writing queries. Pass a short code snippet to see its structure, or specify a file with an optional line range.",
205
+ inputSchema: showAstSchema,
206
+ }, handleShowAst);
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "ast-search-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for ast-search — structural code search for AI agents",
5
+ "type": "module",
6
+ "bin": {
7
+ "ast-search-mcp": "./build/index.js"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "import": "./build/index.js",
12
+ "types": "./build/index.d.ts"
13
+ }
14
+ },
15
+ "author": "shiplet",
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/willey-shiplet/ast-search.git",
20
+ "directory": "packages/ast-search-mcp"
21
+ },
22
+ "files": [
23
+ "build/*.js",
24
+ "build/*.d.ts",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "provenance": true
31
+ },
32
+ "scripts": {
33
+ "build": "npx tsc",
34
+ "test": "jest --testPathPattern='src/__tests__'",
35
+ "prepublishOnly": "pnpm build"
36
+ },
37
+ "dependencies": {
38
+ "@modelcontextprotocol/sdk": "^1.0.0",
39
+ "ast-search-js": "workspace:^",
40
+ "zod": "^3.25.0"
41
+ },
42
+ "devDependencies": {
43
+ "@jest/globals": "^29.7.0",
44
+ "@types/jest": "^29.5.12",
45
+ "@types/node": "^20.12.7",
46
+ "jest": "^29.7.0",
47
+ "ts-jest": "^29.1.2",
48
+ "typescript": "^5.0.0"
49
+ }
50
+ }