feat(mcp): add package-backed ast-grep MCP

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-05-18 21:19:15 +09:00
parent 4ea29e2c94
commit 499aff011a
19 changed files with 1372 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@oh-my-opencode/ast-grep-mcp",
"version": "0.0.0",
"type": "module",
"private": true,
"bin": {
"ast-grep-mcp": "dist/cli.js"
},
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"build": "bun build src/cli.ts --outdir dist --target node --format esm",
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test": "bun test src/*.test.ts"
},
"dependencies": {
"@ast-grep/cli": "^0.41.1"
},
"devDependencies": {
"bun-types": "1.3.12"
}
}
+180
View File
@@ -0,0 +1,180 @@
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from "node:child_process";
import { Writable } from "node:stream";
type StdioMode = "pipe" | "inherit" | "ignore";
type StdioTuple = [StdioMode, StdioMode, StdioMode];
export interface SpawnOptions {
readonly cmd?: readonly string[];
readonly cwd?: string;
readonly env?: NodeJS.ProcessEnv;
readonly stdin?: StdioMode;
readonly stdout?: StdioMode;
readonly stderr?: StdioMode;
readonly stdio?: StdioTuple;
readonly detached?: boolean;
readonly signal?: AbortSignal;
}
export interface SpawnedProcess {
readonly exitCode: number | null;
readonly exited: Promise<number>;
readonly stdout: ReadableStream<Uint8Array<ArrayBuffer>>;
readonly stderr: ReadableStream<Uint8Array<ArrayBuffer>>;
readonly stdin: NodeJS.WritableStream;
readonly pid: number | undefined;
kill(signal?: NodeJS.Signals): void;
ref(): void;
unref(): void;
}
export interface SpawnSyncResult {
readonly exitCode: number;
readonly stdout: Buffer | undefined;
readonly stderr: Buffer | undefined;
readonly success: boolean;
readonly pid: number;
}
type BunSpawnRuntime = {
spawn(command: readonly string[], options?: SpawnOptions): BunSpawnedProcess;
spawn(options: SpawnOptions & { readonly cmd: readonly string[] }): BunSpawnedProcess;
spawnSync(command: readonly string[], options?: SpawnOptions): SpawnSyncResult;
spawnSync(options: SpawnOptions & { readonly cmd: readonly string[] }): SpawnSyncResult;
};
type BunSpawnedProcess = Omit<SpawnedProcess, "stdout" | "stderr"> & {
readonly stdout?: ReadableStream<Uint8Array<ArrayBuffer>>;
readonly stderr?: ReadableStream<Uint8Array<ArrayBuffer>>;
};
const runtime = globalThis as typeof globalThis & { readonly Bun?: BunSpawnRuntime };
const IS_BUN = typeof runtime.Bun !== "undefined";
function emptyReadableStream(): ReadableStream<Uint8Array<ArrayBuffer>> {
return new ReadableStream<Uint8Array<ArrayBuffer>>({
start(controller) {
controller.close();
},
});
}
function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream<Uint8Array<ArrayBuffer>> {
if (!stream) return emptyReadableStream();
return new ReadableStream<Uint8Array<ArrayBuffer>>({
async start(controller) {
try {
for await (const chunk of stream) {
controller.enqueue(toUint8Array(chunk));
}
controller.close();
} catch (error) {
controller.error(error);
}
},
});
}
function toUint8Array(chunk: unknown): Uint8Array<ArrayBuffer> {
if (chunk instanceof Uint8Array) return new Uint8Array(chunk);
return new TextEncoder().encode(String(chunk));
}
function emptyWritableStream(): Writable {
return new Writable({
write(_chunk, _encoding, callback) {
callback();
},
});
}
function isOptionsWithCommand(value: unknown): value is SpawnOptions & { readonly cmd: readonly string[] } {
return typeof value === "object" && value !== null && "cmd" in value && Array.isArray(value.cmd);
}
function resolveCommand(cmdOrOpts: readonly string[] | (SpawnOptions & { readonly cmd: readonly string[] }), optsArg?: SpawnOptions): { readonly cmd: readonly string[]; readonly opts: SpawnOptions } {
if (isOptionsWithCommand(cmdOrOpts)) return { cmd: cmdOrOpts.cmd, opts: cmdOrOpts };
return { cmd: cmdOrOpts, opts: optsArg ?? {} };
}
function resolveStdio(options: SpawnOptions): StdioTuple {
if (options.stdio) return options.stdio;
return [options.stdin ?? "ignore", options.stdout ?? "pipe", options.stderr ?? "inherit"];
}
function wrapNodeProcess(proc: ReturnType<typeof nodeSpawn>): SpawnedProcess {
let exitCode: number | null = null;
const exited = new Promise<number>((resolve, reject) => {
proc.on("exit", (code) => {
exitCode = code ?? 1;
resolve(exitCode);
});
proc.on("error", (error) => {
if (exitCode === null) {
exitCode = 1;
reject(error);
}
});
});
return {
get exitCode() {
return exitCode;
},
exited,
stdout: toReadableStream(proc.stdout),
stderr: toReadableStream(proc.stderr),
stdin: proc.stdin ?? emptyWritableStream(),
pid: proc.pid,
kill(signal?: NodeJS.Signals) {
if (proc.killed || exitCode !== null) return;
proc.kill(signal);
},
ref() {
proc.ref();
},
unref() {
proc.unref();
},
};
}
function wrapBunProcess(proc: BunSpawnedProcess): SpawnedProcess {
return {
...proc,
stdout: proc.stdout ?? emptyReadableStream(),
stderr: proc.stderr ?? emptyReadableStream(),
};
}
export function spawn(command: readonly string[], options?: SpawnOptions): SpawnedProcess;
export function spawn(options: SpawnOptions & { readonly cmd: readonly string[] }): SpawnedProcess;
export function spawn(cmdOrOpts: readonly string[] | (SpawnOptions & { readonly cmd: readonly string[] }), opts?: SpawnOptions): SpawnedProcess {
const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts);
if (IS_BUN) return wrapBunProcess(runtime.Bun.spawn(cmd, options));
const [bin, ...args] = cmd;
if (!bin) throw new Error("spawn requires a command");
return wrapNodeProcess(nodeSpawn(bin, args, {
cwd: options.cwd,
env: options.env,
stdio: resolveStdio(options),
detached: options.detached,
signal: options.signal,
}));
}
export function spawnSync(command: readonly string[], options?: SpawnOptions): SpawnSyncResult;
export function spawnSync(options: SpawnOptions & { readonly cmd: readonly string[] }): SpawnSyncResult;
export function spawnSync(cmdOrOpts: readonly string[] | (SpawnOptions & { readonly cmd: readonly string[] }), opts?: SpawnOptions): SpawnSyncResult {
const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts);
if (IS_BUN) return runtime.Bun.spawnSync(cmd, options);
const [bin, ...args] = cmd;
if (!bin) throw new Error("spawnSync requires a command");
const result = nodeSpawnSync(bin, args, { cwd: options.cwd, env: options.env, stdio: resolveStdio(options) });
return {
exitCode: result.status ?? 1,
stdout: result.stdout ?? undefined,
stderr: result.stderr ?? undefined,
success: (result.status ?? 1) === 0,
pid: result.pid ?? -1,
};
}
@@ -0,0 +1,52 @@
import { existsSync } from "fs"
import { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./constants"
let resolvedCliPath: string | null = null
let initPromise: Promise<string | null> | null = null
export async function getAstGrepPath(): Promise<string | null> {
if (resolvedCliPath !== null && existsSync(resolvedCliPath)) {
return resolvedCliPath
}
if (initPromise) {
return initPromise
}
initPromise = (async () => {
const syncPath = findSgCliPathSync()
if (syncPath && existsSync(syncPath)) {
resolvedCliPath = syncPath
setSgCliPath(syncPath)
return syncPath
}
return null
})()
return initPromise
}
export function startBackgroundInit(): void {
if (!initPromise) {
initPromise = getAstGrepPath()
initPromise.catch(() => {})
}
}
export function isCliAvailable(): boolean {
const path = findSgCliPathSync()
return path !== null && existsSync(path)
}
export async function ensureCliAvailable(): Promise<boolean> {
const path = await getAstGrepPath()
return path !== null && existsSync(path)
}
export function getResolvedSgCliPath(): string | null {
const path = getSgCliPath()
if (path && existsSync(path)) return path
return null
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env node
import { argv, stderr } from "node:process";
import { runMcpStdioServer } from "./mcp";
async function main(): Promise<void> {
const [command = "mcp"] = argv.slice(2);
if (command === "mcp") {
await runMcpStdioServer();
return;
}
stderr.write("Usage: ast-grep-mcp [mcp]\n");
process.exitCode = 2;
}
main().catch((error: unknown) => {
stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
process.exitCode = 1;
});
+2
View File
@@ -0,0 +1,2 @@
export { CLI_LANGUAGES, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_MATCHES } from "./language-support"
export { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./sg-cli-path"
+4
View File
@@ -0,0 +1,4 @@
export { handleAstGrepMcpRequest, runMcpStdioServer } from "./mcp";
export type { AstGrepMcpOptions, JsonRpcId, JsonRpcResponse, JsonRpcResult, McpToolDescriptor, TextContent } from "./mcp";
export { runSg } from "./runner";
export type { RunOptions } from "./runner";
@@ -0,0 +1,31 @@
export const CLI_LANGUAGES = [
"bash",
"c",
"cpp",
"csharp",
"css",
"elixir",
"go",
"haskell",
"html",
"java",
"javascript",
"json",
"kotlin",
"lua",
"nix",
"php",
"python",
"ruby",
"rust",
"scala",
"solidity",
"swift",
"typescript",
"tsx",
"yaml",
] as const
export const DEFAULT_TIMEOUT_MS = 300_000
export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024
export const DEFAULT_MAX_MATCHES = 500
+170
View File
@@ -0,0 +1,170 @@
import { afterEach, describe, expect, it } from "bun:test";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { handleAstGrepMcpRequest } from "./mcp";
import type { RunOptions } from "./runner";
import type { SgResult } from "./types";
const emptyResult: SgResult = {
matches: [],
totalMatches: 0,
truncated: false,
};
const temporaryDirectories: string[] = [];
function createTemporaryDirectory(prefix: string): string {
const directory = mkdtempSync(join(tmpdir(), prefix));
temporaryDirectories.push(directory);
return directory;
}
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("ast-grep MCP", () => {
it("#given initialize request #when handled #then advertises tools capability", async () => {
const response = await handleAstGrepMcpRequest({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: { protocolVersion: "2024-11-05" },
});
expect(response).toEqual({
jsonrpc: "2.0",
id: 1,
result: {
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "ast_grep", version: "0.1.0" },
protocolVersion: "2024-11-05",
},
});
});
it("#given tools list request #when handled #then exposes search and replace tools", async () => {
const response = await handleAstGrepMcpRequest({ jsonrpc: "2.0", id: "tools", method: "tools/list" });
expect(response?.result?.tools?.map((tool) => tool.name)).toEqual(["search", "replace"]);
});
it("#given search call without paths #when handled #then defaults paths to workspace directory", async () => {
const captured: { value?: RunOptions } = {};
const workspaceDirectory = createTemporaryDirectory("omo-ast-grep-workspace-");
const response = await handleAstGrepMcpRequest(
{
jsonrpc: "2.0",
id: "search",
method: "tools/call",
params: { name: "search", arguments: { pattern: "console.log($$$)", lang: "typescript" } },
},
{
workspaceDirectory,
runSg: async (options) => {
captured.value = options;
return emptyResult;
},
},
);
expect(captured.value).toEqual({ pattern: "console.log($$$)", lang: "typescript", cwd: realpathSync(workspaceDirectory), paths: ["."], globs: undefined, context: undefined });
expect(response?.result?.content?.[0]?.text).toContain("No matches found");
});
it("#given replace call without dryRun #when handled #then keeps dry-run default", async () => {
const captured: { value?: RunOptions } = {};
const workspaceDirectory = createTemporaryDirectory("omo-ast-grep-replace-workspace-");
mkdirSync(join(workspaceDirectory, "src"));
await handleAstGrepMcpRequest(
{
jsonrpc: "2.0",
id: "replace",
method: "tools/call",
params: {
name: "replace",
arguments: { pattern: "console.log($MSG)", rewrite: "logger.info($MSG)", lang: "typescript", paths: ["src"] },
},
},
{
workspaceDirectory,
runSg: async (options) => {
captured.value = options;
return emptyResult;
},
},
);
expect(captured.value).toEqual({
pattern: "console.log($MSG)",
rewrite: "logger.info($MSG)",
lang: "typescript",
cwd: realpathSync(workspaceDirectory),
paths: ["src"],
globs: undefined,
updateAll: false,
});
});
it("#given disabled replace tool #when listed and called #then hides and rejects it", async () => {
const listResponse = await handleAstGrepMcpRequest({ jsonrpc: "2.0", id: "tools", method: "tools/list" }, { disabledTools: ["replace"] });
expect(listResponse?.result?.tools?.map((tool) => tool.name)).toEqual(["search"]);
const callResponse = await handleAstGrepMcpRequest(
{
jsonrpc: "2.0",
id: "replace",
method: "tools/call",
params: {
name: "replace",
arguments: { pattern: "console.log($MSG)", rewrite: "logger.info($MSG)", lang: "typescript", paths: ["src"] },
},
},
{ disabledTools: ["replace"] },
);
expect(callResponse?.result?.isError).toBe(true);
expect(callResponse?.result?.content?.[0]?.text).toContain("ast-grep tool is disabled: replace");
});
it("#given unsafe paths #when search is called #then rejects before running ast-grep", async () => {
const workspaceDirectory = createTemporaryDirectory("omo-ast-grep-sandbox-");
const outsideDirectory = createTemporaryDirectory("omo-ast-grep-outside-");
symlinkSync(outsideDirectory, join(workspaceDirectory, "outside-link"));
let didRun = false;
for (const path of ["../outside", "/tmp", "--update-all", "outside-link"]) {
const response = await handleAstGrepMcpRequest(
{
jsonrpc: "2.0",
id: path,
method: "tools/call",
params: { name: "search", arguments: { pattern: "console.log($$$)", lang: "typescript", paths: [path] } },
},
{
workspaceDirectory,
runSg: async () => {
didRun = true;
return emptyResult;
},
},
);
expect(response?.result?.isError).toBe(true);
}
expect(didRun).toBe(false);
});
it("#given tools list request #when handled #then preserves detailed ast-grep guidance", async () => {
const response = await handleAstGrepMcpRequest({ jsonrpc: "2.0", id: "tools", method: "tools/list" });
const searchTool = response?.result?.tools?.find((tool) => tool.name === "search");
expect(searchTool?.description).toContain("This is NOT regex");
expect(searchTool?.description).toContain("Meta-variables");
});
});
+275
View File
@@ -0,0 +1,275 @@
import { createInterface } from "node:readline";
import { CLI_LANGUAGES } from "./constants";
import { getPatternHint } from "./pattern-hints";
import { formatReplaceResult, formatSearchResult } from "./result-formatter";
import { runSg, type RunOptions } from "./runner";
import { AST_GREP_REPLACE_DESCRIPTION, AST_GREP_SEARCH_DESCRIPTION, AST_GREP_SEARCH_PATTERN_PARAM } from "./tool-descriptions";
import type { CliLanguage, SgResult } from "./types";
import { normalizeWorkspaceDirectory, resolveWorkspacePaths } from "./workspace-paths";
export type JsonRpcId = string | number | null;
export interface TextContent {
readonly type: "text";
readonly text: string;
}
export interface McpToolDescriptor {
readonly name: string;
readonly title: string;
readonly description: string;
readonly inputSchema: unknown;
}
export interface JsonRpcError {
readonly code: number;
readonly message: string;
readonly data?: unknown;
}
export interface JsonRpcResult {
readonly capabilities?: Record<string, unknown>;
readonly serverInfo?: Record<string, unknown>;
readonly protocolVersion?: string;
readonly tools?: readonly McpToolDescriptor[];
readonly content?: readonly TextContent[];
readonly isError?: boolean;
readonly [key: string]: unknown;
}
export interface JsonRpcResponse {
readonly jsonrpc: "2.0";
readonly id: JsonRpcId;
readonly result?: JsonRpcResult;
readonly error?: JsonRpcError;
}
export interface AstGrepMcpOptions {
readonly workspaceDirectory?: string;
readonly disabledTools?: readonly string[];
readonly runSg?: (options: RunOptions) => Promise<SgResult>;
}
type ToolCallResult = {
readonly content: readonly TextContent[];
readonly isError?: boolean;
};
const SERVER_NAME = "ast_grep";
const SERVER_VERSION = "0.1.0";
const LANGUAGE_VALUES: readonly string[] = CLI_LANGUAGES;
const DISABLED_TOOLS_ENV = "OMO_AST_GREP_DISABLED_TOOLS";
const AST_GREP_MCP_TOOLS = [
{
name: "search",
title: "AST grep search",
description: AST_GREP_SEARCH_DESCRIPTION,
inputSchema: {
type: "object",
properties: {
pattern: { type: "string", description: AST_GREP_SEARCH_PATTERN_PARAM },
lang: { type: "string", enum: CLI_LANGUAGES, description: "Target language" },
paths: { type: "array", items: { type: "string" }, description: "Paths to search" },
globs: { type: "array", items: { type: "string" }, description: "Include/exclude globs" },
context: { type: "number", description: "Context lines around each match" },
},
required: ["pattern", "lang"],
additionalProperties: false,
},
},
{
name: "replace",
title: "AST grep replace",
description: AST_GREP_REPLACE_DESCRIPTION,
inputSchema: {
type: "object",
properties: {
pattern: { type: "string", description: "AST pattern to match" },
rewrite: { type: "string", description: "Replacement pattern" },
lang: { type: "string", enum: CLI_LANGUAGES, description: "Target language" },
paths: { type: "array", items: { type: "string" }, description: "Paths to search" },
globs: { type: "array", items: { type: "string" }, description: "Include/exclude globs" },
dryRun: { type: "boolean", description: "Preview changes without applying. Defaults to true." },
},
required: ["pattern", "rewrite", "lang"],
additionalProperties: false,
},
},
] as const satisfies readonly McpToolDescriptor[];
export async function handleAstGrepMcpRequest(input: unknown, options: AstGrepMcpOptions = {}): Promise<JsonRpcResponse | undefined> {
if (!isRecord(input)) return errorResponse(null, -32600, "Invalid Request");
const id = jsonRpcId(input.id);
if (input.method === "notifications/initialized") return undefined;
if (input.method === "ping") return successResponse(id, {});
if (input.method === "initialize") {
return successResponse(id, {
capabilities: { tools: { listChanged: false } },
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
protocolVersion: requestedProtocolVersion(input.params),
});
}
if (input.method === "tools/list") return successResponse(id, { tools: enabledTools(options) });
if (input.method === "tools/call") return handleToolCall(id, input.params, options);
return errorResponse(id, -32601, `Method not found: ${String(input.method)}`);
}
export async function runMcpStdioServer(
input: NodeJS.ReadableStream = process.stdin,
output: NodeJS.WritableStream = process.stdout,
options: AstGrepMcpOptions = {},
): Promise<void> {
const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
for await (const line of lines) {
if (!line.trim()) continue;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
output.write(`${JSON.stringify(errorResponse(null, -32700, "Parse error", messageFromError(error)))}\n`);
continue;
}
const response = await handleAstGrepMcpRequest(parsed, options);
if (response) output.write(`${JSON.stringify(response)}\n`);
}
}
async function handleToolCall(id: JsonRpcId, params: unknown, options: AstGrepMcpOptions): Promise<JsonRpcResponse> {
if (!isRecord(params) || typeof params.name !== "string") return errorResponse(id, -32602, "tools/call requires params.name");
try {
const result = await executeAstGrepTool(params.name, params.arguments, options);
return successResponse(id, { content: result.content, isError: result.isError ?? false });
} catch (error) {
return successResponse(id, { content: [{ type: "text", text: messageFromError(error) }], isError: true });
}
}
async function executeAstGrepTool(name: string, args: unknown, options: AstGrepMcpOptions): Promise<ToolCallResult> {
if (disabledToolNames(options).has(name)) throw new Error(`ast-grep tool is disabled: ${name}`);
const runner = options.runSg ?? runSg;
const workspaceDirectory = normalizeWorkspaceDirectory(options.workspaceDirectory ?? process.env.OMO_AST_GREP_WORKSPACE ?? process.cwd());
if (name === "search") {
const input = parseSearchArgs(args, workspaceDirectory);
const result = await runner(input);
let output = formatSearchResult(result);
if (result.matches.length === 0 && !result.error) {
const hint = getPatternHint(input.pattern, input.lang);
if (hint) output += `\n\n${hint}`;
}
return { content: [{ type: "text", text: output }], isError: Boolean(result.error) };
}
if (name === "replace") {
const input = parseReplaceArgs(args, workspaceDirectory);
const result = await runner(input.options);
return { content: [{ type: "text", text: formatReplaceResult(result, input.dryRun) }], isError: Boolean(result.error) };
}
throw new Error(`Unknown ast-grep tool: ${name}`);
}
function parseSearchArgs(args: unknown, workspaceDirectory: string): RunOptions {
const input = requireRecord(args);
return {
pattern: requireString(input, "pattern"),
lang: requireLanguage(input, "lang"),
cwd: workspaceDirectory,
paths: resolveWorkspacePaths(optionalStringArray(input, "paths"), workspaceDirectory),
globs: optionalStringArray(input, "globs"),
context: optionalNumber(input, "context"),
};
}
function parseReplaceArgs(args: unknown, workspaceDirectory: string): { readonly options: RunOptions; readonly dryRun: boolean } {
const input = requireRecord(args);
const dryRun = optionalBoolean(input, "dryRun") ?? true;
return {
dryRun,
options: {
pattern: requireString(input, "pattern"),
rewrite: requireString(input, "rewrite"),
lang: requireLanguage(input, "lang"),
cwd: workspaceDirectory,
paths: resolveWorkspacePaths(optionalStringArray(input, "paths"), workspaceDirectory),
globs: optionalStringArray(input, "globs"),
updateAll: !dryRun,
},
};
}
function requireRecord(value: unknown): Record<string, unknown> {
if (!isRecord(value)) throw new Error("Tool arguments must be an object");
return value;
}
function requireString(input: Record<string, unknown>, key: string): string {
const value = input[key];
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} must be a non-empty string`);
return value;
}
function requireLanguage(input: Record<string, unknown>, key: string): CliLanguage {
const value = requireString(input, key);
if (!isCliLanguage(value)) throw new Error(`${key} must be one of: ${LANGUAGE_VALUES.join(", ")}`);
return value;
}
function isCliLanguage(value: string): value is CliLanguage {
return LANGUAGE_VALUES.includes(value);
}
function optionalStringArray(input: Record<string, unknown>, key: string): string[] | undefined {
const value = input[key];
if (value === undefined) return undefined;
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new Error(`${key} must be an array of strings`);
return value;
}
function enabledTools(options: AstGrepMcpOptions): readonly McpToolDescriptor[] {
const disabled = disabledToolNames(options);
return AST_GREP_MCP_TOOLS.filter((tool) => !disabled.has(tool.name));
}
function disabledToolNames(options: AstGrepMcpOptions): ReadonlySet<string> {
const fromOptions = options.disabledTools ?? [];
const fromEnv = process.env[DISABLED_TOOLS_ENV]?.split(",") ?? [];
return new Set([...fromOptions, ...fromEnv].map((tool) => tool.trim()).filter(Boolean));
}
function optionalNumber(input: Record<string, unknown>, key: string): number | undefined {
const value = input[key];
if (value === undefined) return undefined;
if (typeof value !== "number") throw new Error(`${key} must be a number`);
return value;
}
function optionalBoolean(input: Record<string, unknown>, key: string): boolean | undefined {
const value = input[key];
if (value === undefined) return undefined;
if (typeof value !== "boolean") throw new Error(`${key} must be a boolean`);
return value;
}
function successResponse(id: JsonRpcId, result: JsonRpcResult): JsonRpcResponse {
return { jsonrpc: "2.0", id, result };
}
function errorResponse(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcResponse {
return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } };
}
function requestedProtocolVersion(params: unknown): string {
if (!isRecord(params) || typeof params.protocolVersion !== "string") return "2024-11-05";
return params.protocolVersion;
}
function jsonRpcId(value: unknown): JsonRpcId {
return typeof value === "string" || typeof value === "number" || value === null ? value : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function messageFromError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -0,0 +1,63 @@
import type { CliLanguage } from "./types"
export function detectRegexMisuse(pattern: string): string | null {
const src = pattern.trim()
if (/\\[wWdDsSbB]/.test(src)) {
return 'Hint: "\\w", "\\d", "\\s", "\\b" are regex escapes. ast-grep matches AST nodes, not text - use $VAR for identifiers, $$$ for node lists, or switch to grep for text search.'
}
if (/\[[a-zA-Z0-9]-[a-zA-Z0-9]\]/.test(src)) {
return 'Hint: "[a-z]" and similar character classes are regex, not AST. Use $VAR to match any identifier, or switch to grep for text search.'
}
if (!src.includes("$") && /\w\.[*+]/.test(src)) {
return 'Hint: ".*" and ".+" are regex wildcards. In ast-grep use $$$ for multiple AST nodes and $VAR for a single node. For text patterns, switch to grep.'
}
if (/^[-\w.*]+\|[-\w.*|]+$/.test(src)) {
return 'Hint: "|" is regex alternation and does NOT work in ast-grep patterns. Options: (a) fire one ast_grep_search per alternative, or (b) switch to grep with a regex pattern like "foo|bar".'
}
return null
}
export function detectLanguageSpecificMistake(
pattern: string,
lang: CliLanguage,
): string | null {
const src = pattern.trim()
if (lang === "python") {
if (src.startsWith("class ") && src.endsWith(":")) {
return `Hint: Remove trailing colon. Try: "${src.slice(0, -1)}"`
}
if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
return `Hint: Remove trailing colon. Try: "${src.slice(0, -1)}"`
}
}
if (["javascript", "typescript", "tsx"].includes(lang)) {
if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) {
return 'Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"'
}
}
if (lang === "go") {
if (/^func\s+\$[A-Z_]+\s*$/i.test(src)) {
return 'Hint: Go function patterns need params and body. Try "func $NAME($$$) { $$$ }"'
}
}
if (lang === "rust") {
if (/^fn\s+\$[A-Z_]+\s*$/i.test(src)) {
return 'Hint: Rust fn patterns need params and body. Try "fn $NAME($$$) { $$$ }"'
}
}
return null
}
export function getPatternHint(pattern: string, lang: CliLanguage): string | null {
return detectRegexMisuse(pattern) ?? detectLanguageSpecificMistake(pattern, lang)
}
@@ -0,0 +1,28 @@
type SpawnedProcess = {
stdout: ReadableStream | null
stderr: ReadableStream | null
exited: Promise<number>
kill: () => void
}
export async function collectProcessOutputWithTimeout(
process: SpawnedProcess,
timeoutMs: number
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const timeoutPromise = new Promise<never>((_, reject) => {
const timeoutId = setTimeout(() => {
process.kill()
reject(new Error(`Search timeout after ${timeoutMs}ms`))
}, timeoutMs)
process.exited.then(() => clearTimeout(timeoutId))
})
const stdoutPromise = process.stdout ? new Response(process.stdout).text() : Promise.resolve("")
const stderrPromise = process.stderr ? new Response(process.stderr).text() : Promise.resolve("")
const stdout = await Promise.race([stdoutPromise, timeoutPromise])
const stderr = await stderrPromise
const exitCode = await process.exited
return { stdout, stderr, exitCode }
}
@@ -0,0 +1,70 @@
import type { SgResult } from "./types"
export function formatSearchResult(result: SgResult): string {
if (result.error) {
return `Error: ${result.error}`
}
if (result.matches.length === 0) {
return "No matches found"
}
const lines: string[] = []
if (result.truncated) {
const reason = result.truncatedReason === "max_matches"
? `showing first ${result.matches.length} of ${result.totalMatches}`
: result.truncatedReason === "max_output_bytes"
? "output exceeded 1MB limit"
: "search timed out"
lines.push(`[TRUNCATED] Results truncated (${reason})\n`)
}
lines.push(`Found ${result.matches.length} match(es)${result.truncated ? ` (truncated from ${result.totalMatches})` : ""}:\n`)
for (const match of result.matches) {
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
lines.push(`${loc}`)
lines.push(` ${match.lines.trim()}`)
lines.push("")
}
return lines.join("\n")
}
export function formatReplaceResult(result: SgResult, isDryRun: boolean): string {
if (result.error) {
return `Error: ${result.error}`
}
if (result.matches.length === 0) {
return "No matches found to replace"
}
const prefix = isDryRun ? "[DRY RUN] " : ""
const lines: string[] = []
if (result.truncated) {
const reason = result.truncatedReason === "max_matches"
? `showing first ${result.matches.length} of ${result.totalMatches}`
: result.truncatedReason === "max_output_bytes"
? "output exceeded 1MB limit"
: "search timed out"
lines.push(`[TRUNCATED] Results truncated (${reason})\n`)
}
lines.push(`${prefix}${result.matches.length} replacement(s):\n`)
for (const match of result.matches) {
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
lines.push(`${loc}`)
lines.push(` ${match.text}`)
lines.push("")
}
if (isDryRun) {
lines.push("Use dryRun=false to apply changes")
}
return lines.join("\n")
}
+184
View File
@@ -0,0 +1,184 @@
import { spawn } from "./bun-spawn-shim"
import { existsSync } from "fs"
import {
getSgCliPath,
DEFAULT_TIMEOUT_MS,
} from "./constants"
import type { CliLanguage, SgResult } from "./types"
import { getAstGrepPath } from "./cli-binary-path-resolution"
import { collectProcessOutputWithTimeout } from "./process-output-timeout"
import { createSgResultFromStdout } from "./sg-compact-json-output"
export {
ensureCliAvailable,
getAstGrepPath,
isCliAvailable,
startBackgroundInit,
} from "./cli-binary-path-resolution"
export interface RunOptions {
pattern: string
lang: CliLanguage
cwd?: string
paths?: readonly string[]
globs?: readonly string[]
rewrite?: string
context?: number
updateAll?: boolean
}
export async function runSg(options: RunOptions): Promise<SgResult> {
// ast-grep CLI silently ignores --update-all when --json is present.
// When both rewrite and updateAll are requested, we must run two separate
// invocations: one with --json=compact to collect match results, and
// another with --update-all to perform the actual file writes.
const shouldSeparateWritePass = !!(options.rewrite && options.updateAll)
const args = createSgArgs(options, { includeJson: true, includeUpdateAll: false })
let cliPath = getSgCliPath()
if (!cliPath || !existsSync(cliPath)) {
const resolvedPath = await getAstGrepPath()
if (resolvedPath) {
cliPath = resolvedPath
} else {
return {
matches: [],
totalMatches: 0,
truncated: false,
error:
`ast-grep (sg) binary not found.\n\n` +
`Install options:\n` +
` bun add -D @ast-grep/cli\n` +
` cargo install ast-grep --locked\n` +
` brew install ast-grep`,
}
}
}
const timeout = DEFAULT_TIMEOUT_MS
const proc = spawn([cliPath, ...args], {
cwd: options.cwd,
stdout: "pipe",
stderr: "pipe",
})
let stdout: string
let stderr: string
let exitCode: number
try {
const output = await collectProcessOutputWithTimeout(proc, timeout)
stdout = output.stdout
stderr = output.stderr
exitCode = output.exitCode
} catch (error) {
if (error instanceof Error && error.message.includes("timeout")) {
return {
matches: [],
totalMatches: 0,
truncated: true,
truncatedReason: "timeout",
error: error.message,
}
}
const errorMessage = error instanceof Error ? error.message : String(error)
const errorCode = errorCodeFrom(error)
const isNoEntry =
errorCode === "ENOENT" || errorMessage.includes("ENOENT") || errorMessage.includes("not found")
if (isNoEntry) {
return {
matches: [],
totalMatches: 0,
truncated: false,
error:
`ast-grep CLI binary not found.\n\n` +
`Install options:\n` +
` bun add -D @ast-grep/cli\n` +
` cargo install ast-grep --locked\n` +
` brew install ast-grep`,
}
}
return {
matches: [],
totalMatches: 0,
truncated: false,
error: `Failed to spawn ast-grep: ${errorMessage}`,
}
}
if (exitCode !== 0 && stdout.trim() === "") {
if (stderr.includes("No files found")) {
return { matches: [], totalMatches: 0, truncated: false }
}
if (stderr.trim()) {
return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() }
}
return { matches: [], totalMatches: 0, truncated: false }
}
const jsonResult = createSgResultFromStdout(stdout)
if (shouldSeparateWritePass && jsonResult.matches.length > 0) {
const writeArgs = createSgArgs(options, { includeJson: false, includeUpdateAll: true })
const writeProc = spawn([cliPath, ...writeArgs], {
cwd: options.cwd,
stdout: "pipe",
stderr: "pipe",
})
try {
const writeOutput = await collectProcessOutputWithTimeout(writeProc, timeout)
if (writeOutput.exitCode !== 0) {
const errorDetail = writeOutput.stderr.trim() || `ast-grep exited with code ${writeOutput.exitCode}`
return { ...jsonResult, error: `Replace failed: ${errorDetail}` }
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return { ...jsonResult, error: `Replace failed: ${errorMessage}` }
}
}
return jsonResult
}
function createSgArgs(options: RunOptions, flags: { readonly includeJson: boolean; readonly includeUpdateAll: boolean }): string[] {
const args = ["run", "-p", options.pattern, "--lang", options.lang]
if (flags.includeJson) {
args.push("--json=compact")
}
if (options.rewrite) {
args.push("-r", options.rewrite)
if (flags.includeUpdateAll) {
args.push("--update-all")
}
}
if (options.context && options.context > 0) {
args.push("-C", String(options.context))
}
if (options.globs) {
for (const glob of options.globs) {
args.push("--globs", glob)
}
}
const paths = options.paths && options.paths.length > 0 ? options.paths : ["."]
args.push("--", ...paths)
return args
}
function errorCodeFrom(error: unknown): unknown {
if (typeof error !== "object" || error === null || !("code" in error)) return undefined
return Reflect.get(error, "code")
}
+95
View File
@@ -0,0 +1,95 @@
import { createRequire } from "module"
import { dirname, join } from "path"
import { existsSync, statSync } from "fs"
type Platform = "darwin" | "linux" | "win32" | "unsupported"
function isValidBinary(filePath: string): boolean {
try {
return statSync(filePath).size > 10000
} catch {
return false
}
}
function getPlatformPackageName(): string | null {
const platform = process.platform as Platform
const arch = process.arch
const platformMap: Record<string, string> = {
"darwin-arm64": "@ast-grep/cli-darwin-arm64",
"darwin-x64": "@ast-grep/cli-darwin-x64",
"linux-arm64": "@ast-grep/cli-linux-arm64-gnu",
"linux-x64": "@ast-grep/cli-linux-x64-gnu",
"win32-x64": "@ast-grep/cli-win32-x64-msvc",
"win32-arm64": "@ast-grep/cli-win32-arm64-msvc",
"win32-ia32": "@ast-grep/cli-win32-ia32-msvc",
}
return platformMap[`${platform}-${arch}`] ?? null
}
export function findSgCliPathSync(): string | null {
const binaryName = process.platform === "win32" ? "sg.exe" : "sg"
try {
const require = createRequire(import.meta.url)
const cliPackageJsonPath = require.resolve("@ast-grep/cli/package.json")
const cliDirectory = dirname(cliPackageJsonPath)
const sgPath = join(cliDirectory, binaryName)
if (existsSync(sgPath) && isValidBinary(sgPath)) {
return sgPath
}
} catch {
// @ast-grep/cli not installed
}
const platformPackage = getPlatformPackageName()
if (platformPackage) {
try {
const require = createRequire(import.meta.url)
const packageJsonPath = require.resolve(`${platformPackage}/package.json`)
const packageDirectory = dirname(packageJsonPath)
const astGrepBinaryName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
const binaryPath = join(packageDirectory, astGrepBinaryName)
if (existsSync(binaryPath) && isValidBinary(binaryPath)) {
return binaryPath
}
} catch {
// Platform-specific package not installed
}
}
if (process.platform === "darwin") {
const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"]
for (const path of homebrewPaths) {
if (existsSync(path) && isValidBinary(path)) {
return path
}
}
}
return null
}
let resolvedCliPath: string | null = null
export function getSgCliPath(): string | null {
if (resolvedCliPath !== null) {
return resolvedCliPath
}
const syncPath = findSgCliPathSync()
if (syncPath) {
resolvedCliPath = syncPath
return syncPath
}
return null
}
export function setSgCliPath(path: string): void {
resolvedCliPath = path
}
@@ -0,0 +1,54 @@
import { DEFAULT_MAX_MATCHES, DEFAULT_MAX_OUTPUT_BYTES } from "./constants"
import type { CliMatch, SgResult } from "./types"
export function createSgResultFromStdout(stdout: string): SgResult {
if (!stdout.trim()) {
return { matches: [], totalMatches: 0, truncated: false }
}
const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
let matches: CliMatch[] = []
try {
matches = JSON.parse(outputToProcess) as CliMatch[]
} catch {
if (outputTruncated) {
try {
const lastValidIndex = outputToProcess.lastIndexOf("}")
if (lastValidIndex > 0) {
const bracketIndex = outputToProcess.lastIndexOf("},", lastValidIndex)
if (bracketIndex > 0) {
const truncatedJson = outputToProcess.substring(0, bracketIndex + 1) + "]"
matches = JSON.parse(truncatedJson) as CliMatch[]
}
}
} catch {
return {
matches: [],
totalMatches: 0,
truncated: true,
truncatedReason: "max_output_bytes",
error: "Output too large and could not be parsed",
}
}
} else {
return { matches: [], totalMatches: 0, truncated: false }
}
}
const totalMatches = matches.length
const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES
const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches
return {
matches: finalMatches,
totalMatches,
truncated: outputTruncated || matchesTruncated,
truncatedReason: outputTruncated
? "max_output_bytes"
: matchesTruncated
? "max_matches"
: undefined,
}
}
@@ -0,0 +1,35 @@
export const AST_GREP_SEARCH_DESCRIPTION = [
"Search code by AST structure (25 languages). This is NOT regex.",
"",
"Meta-variables (the only wildcards ast-grep understands):",
" $VAR - one AST node (an identifier, expression, statement, ...)",
" $$$ - zero or more nodes (argument lists, function bodies, ...)",
" $$$VAR - same, captured by name",
"Patterns must be complete, parseable source code. Each meta-variable replaces a whole node, not a substring.",
"",
"Regex syntax does NOT work - never pass these to pattern:",
' "foo|bar" alternation → run separate calls, or switch to grep',
' ".*", ".+" wildcards → use $$$ between AST fragments',
' "\\w", "\\d" escapes → use $VAR to capture any identifier',
' "[a-z]" class ranges → no AST equivalent',
"For text search, cross-language search, or regex features, use the grep tool instead.",
"",
"Examples by language:",
' typescript/tsx "function $NAME($$$) { $$$ }", "console.log($$$)", "import { $$$ } from \'$MOD\'"',
' python "def $FUNC($$$)", "class $C($$$)" - no trailing colon',
' go "func $NAME($$$) { $$$ }", "if err != nil { $$$ }"',
' rust "fn $NAME($$$) -> $RET { $$$ }", "impl $TRAIT for $T { $$$ }"',
"",
"On empty results the tool returns a hint naming the exact mistake. If the pattern is fundamentally text-shaped, stop retrying and switch to grep.",
].join("\n")
export const AST_GREP_SEARCH_PATTERN_PARAM =
"AST pattern - valid, parseable code using $VAR (one node) and $$$ (many nodes). NOT regex: no `|`, no `.*`, no `\\w`, no `[a-z]`. For text or alternation, use grep instead."
export const AST_GREP_REPLACE_DESCRIPTION = [
"Rewrite code by AST pattern (25 languages). Dry-run by default.",
"Both pattern and rewrite use AST syntax ($VAR for one node, $$$ for many) - regex does NOT work.",
"Meta-variables captured in pattern can be reused in rewrite to preserve matched content.",
'Example: pattern="console.log($MSG)" rewrite="logger.info($MSG)"',
"For text-only replacement or regex features, use a text editor instead.",
].join("\n")
+35
View File
@@ -0,0 +1,35 @@
import type { CLI_LANGUAGES } from "./constants"
export type CliLanguage = (typeof CLI_LANGUAGES)[number]
export interface Position {
line: number
column: number
}
export interface Range {
start: Position
end: Position
}
export interface CliMatch {
text: string
range: {
byteOffset: { start: number; end: number }
start: Position
end: Position
}
file: string
lines: string
charCount: { leading: number; trailing: number }
language: string
}
export interface SgResult {
matches: CliMatch[]
totalMatches: number
truncated: boolean
truncatedReason?: "max_matches" | "max_output_bytes" | "timeout"
error?: string
}
@@ -0,0 +1,36 @@
import { existsSync, realpathSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
export function normalizeWorkspaceDirectory(workspaceDirectory: string): string {
return realpathSync(resolve(workspaceDirectory));
}
export function resolveWorkspacePaths(rawPaths: readonly string[] | undefined, workspaceDirectory: string): readonly string[] {
const workspace = normalizeWorkspaceDirectory(workspaceDirectory);
const requestedPaths = rawPaths && rawPaths.length > 0 ? rawPaths : ["."];
return requestedPaths.map((rawPath) => resolveWorkspacePath(rawPath, workspace));
}
function resolveWorkspacePath(rawPath: string, workspaceDirectory: string): string {
if (rawPath.length === 0) throw new Error("paths entries must be non-empty strings");
if (rawPath.startsWith("-")) throw new Error(`paths entries must not start with '-': ${rawPath}`);
if (rawPath.includes("\0")) throw new Error("paths entries must not contain null bytes");
if (isAbsolute(rawPath)) throw new Error(`paths entries must be relative to the workspace: ${rawPath}`);
const absolutePath = resolve(workspaceDirectory, rawPath);
assertInsideWorkspace(absolutePath, workspaceDirectory, rawPath);
if (existsSync(absolutePath)) {
const realPath = realpathSync(absolutePath);
assertInsideWorkspace(realPath, workspaceDirectory, rawPath);
}
const normalizedPath = relative(workspaceDirectory, absolutePath);
return normalizedPath === "" ? "." : normalizedPath;
}
function assertInsideWorkspace(candidatePath: string, workspaceDirectory: string, rawPath: string): void {
const workspaceRelativePath = relative(workspaceDirectory, candidatePath);
if (workspaceRelativePath === "" || (!workspaceRelativePath.startsWith("..") && !isAbsolute(workspaceRelativePath))) return;
throw new Error(`paths entries must stay inside the workspace: ${rawPath}`);
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ESNext", "DOM"],
"types": ["bun-types"]
},
"include": ["src/**/*"]
}