From 499aff011aeb16829e57b703c850176227787ba2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 18 May 2026 21:19:15 +0900 Subject: [PATCH] feat(mcp): add package-backed ast-grep MCP Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/ast-grep-mcp/package.json | 26 ++ packages/ast-grep-mcp/src/bun-spawn-shim.ts | 180 ++++++++++++ .../src/cli-binary-path-resolution.ts | 52 ++++ packages/ast-grep-mcp/src/cli.ts | 18 ++ packages/ast-grep-mcp/src/constants.ts | 2 + packages/ast-grep-mcp/src/index.ts | 4 + packages/ast-grep-mcp/src/language-support.ts | 31 ++ packages/ast-grep-mcp/src/mcp.test.ts | 170 +++++++++++ packages/ast-grep-mcp/src/mcp.ts | 275 ++++++++++++++++++ packages/ast-grep-mcp/src/pattern-hints.ts | 63 ++++ .../src/process-output-timeout.ts | 28 ++ packages/ast-grep-mcp/src/result-formatter.ts | 70 +++++ packages/ast-grep-mcp/src/runner.ts | 184 ++++++++++++ packages/ast-grep-mcp/src/sg-cli-path.ts | 95 ++++++ .../src/sg-compact-json-output.ts | 54 ++++ .../ast-grep-mcp/src/tool-descriptions.ts | 35 +++ packages/ast-grep-mcp/src/types.ts | 35 +++ packages/ast-grep-mcp/src/workspace-paths.ts | 36 +++ packages/ast-grep-mcp/tsconfig.json | 14 + 19 files changed, 1372 insertions(+) create mode 100644 packages/ast-grep-mcp/package.json create mode 100644 packages/ast-grep-mcp/src/bun-spawn-shim.ts create mode 100644 packages/ast-grep-mcp/src/cli-binary-path-resolution.ts create mode 100644 packages/ast-grep-mcp/src/cli.ts create mode 100644 packages/ast-grep-mcp/src/constants.ts create mode 100644 packages/ast-grep-mcp/src/index.ts create mode 100644 packages/ast-grep-mcp/src/language-support.ts create mode 100644 packages/ast-grep-mcp/src/mcp.test.ts create mode 100644 packages/ast-grep-mcp/src/mcp.ts create mode 100644 packages/ast-grep-mcp/src/pattern-hints.ts create mode 100644 packages/ast-grep-mcp/src/process-output-timeout.ts create mode 100644 packages/ast-grep-mcp/src/result-formatter.ts create mode 100644 packages/ast-grep-mcp/src/runner.ts create mode 100644 packages/ast-grep-mcp/src/sg-cli-path.ts create mode 100644 packages/ast-grep-mcp/src/sg-compact-json-output.ts create mode 100644 packages/ast-grep-mcp/src/tool-descriptions.ts create mode 100644 packages/ast-grep-mcp/src/types.ts create mode 100644 packages/ast-grep-mcp/src/workspace-paths.ts create mode 100644 packages/ast-grep-mcp/tsconfig.json diff --git a/packages/ast-grep-mcp/package.json b/packages/ast-grep-mcp/package.json new file mode 100644 index 000000000..286bf7925 --- /dev/null +++ b/packages/ast-grep-mcp/package.json @@ -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" + } +} diff --git a/packages/ast-grep-mcp/src/bun-spawn-shim.ts b/packages/ast-grep-mcp/src/bun-spawn-shim.ts new file mode 100644 index 000000000..a0183ee9b --- /dev/null +++ b/packages/ast-grep-mcp/src/bun-spawn-shim.ts @@ -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; + readonly stdout: ReadableStream>; + readonly stderr: ReadableStream>; + 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 & { + readonly stdout?: ReadableStream>; + readonly stderr?: ReadableStream>; +}; + +const runtime = globalThis as typeof globalThis & { readonly Bun?: BunSpawnRuntime }; +const IS_BUN = typeof runtime.Bun !== "undefined"; + +function emptyReadableStream(): ReadableStream> { + return new ReadableStream>({ + start(controller) { + controller.close(); + }, + }); +} + +function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream> { + if (!stream) return emptyReadableStream(); + return new ReadableStream>({ + 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 { + 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): SpawnedProcess { + let exitCode: number | null = null; + const exited = new Promise((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, + }; +} diff --git a/packages/ast-grep-mcp/src/cli-binary-path-resolution.ts b/packages/ast-grep-mcp/src/cli-binary-path-resolution.ts new file mode 100644 index 000000000..6efe84b70 --- /dev/null +++ b/packages/ast-grep-mcp/src/cli-binary-path-resolution.ts @@ -0,0 +1,52 @@ +import { existsSync } from "fs" + +import { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./constants" + +let resolvedCliPath: string | null = null +let initPromise: Promise | null = null + +export async function getAstGrepPath(): Promise { + 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 { + 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 +} diff --git a/packages/ast-grep-mcp/src/cli.ts b/packages/ast-grep-mcp/src/cli.ts new file mode 100644 index 000000000..397ffdb15 --- /dev/null +++ b/packages/ast-grep-mcp/src/cli.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import { argv, stderr } from "node:process"; +import { runMcpStdioServer } from "./mcp"; + +async function main(): Promise { + 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; +}); diff --git a/packages/ast-grep-mcp/src/constants.ts b/packages/ast-grep-mcp/src/constants.ts new file mode 100644 index 000000000..e535d0fe3 --- /dev/null +++ b/packages/ast-grep-mcp/src/constants.ts @@ -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" diff --git a/packages/ast-grep-mcp/src/index.ts b/packages/ast-grep-mcp/src/index.ts new file mode 100644 index 000000000..6f61a5ff5 --- /dev/null +++ b/packages/ast-grep-mcp/src/index.ts @@ -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"; diff --git a/packages/ast-grep-mcp/src/language-support.ts b/packages/ast-grep-mcp/src/language-support.ts new file mode 100644 index 000000000..52aa996c7 --- /dev/null +++ b/packages/ast-grep-mcp/src/language-support.ts @@ -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 diff --git a/packages/ast-grep-mcp/src/mcp.test.ts b/packages/ast-grep-mcp/src/mcp.test.ts new file mode 100644 index 000000000..e2b51815d --- /dev/null +++ b/packages/ast-grep-mcp/src/mcp.test.ts @@ -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"); + }); +}); diff --git a/packages/ast-grep-mcp/src/mcp.ts b/packages/ast-grep-mcp/src/mcp.ts new file mode 100644 index 000000000..20bb86e87 --- /dev/null +++ b/packages/ast-grep-mcp/src/mcp.ts @@ -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; + readonly serverInfo?: Record; + 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; +} + +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 { + 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 { + 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 { + 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 { + 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 { + if (!isRecord(value)) throw new Error("Tool arguments must be an object"); + return value; +} + +function requireString(input: Record, 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, 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, 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 { + 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, 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, 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function messageFromError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/ast-grep-mcp/src/pattern-hints.ts b/packages/ast-grep-mcp/src/pattern-hints.ts new file mode 100644 index 000000000..8370b830e --- /dev/null +++ b/packages/ast-grep-mcp/src/pattern-hints.ts @@ -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) +} diff --git a/packages/ast-grep-mcp/src/process-output-timeout.ts b/packages/ast-grep-mcp/src/process-output-timeout.ts new file mode 100644 index 000000000..2292b37c3 --- /dev/null +++ b/packages/ast-grep-mcp/src/process-output-timeout.ts @@ -0,0 +1,28 @@ +type SpawnedProcess = { + stdout: ReadableStream | null + stderr: ReadableStream | null + exited: Promise + kill: () => void +} + +export async function collectProcessOutputWithTimeout( + process: SpawnedProcess, + timeoutMs: number +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const timeoutPromise = new Promise((_, 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 } +} diff --git a/packages/ast-grep-mcp/src/result-formatter.ts b/packages/ast-grep-mcp/src/result-formatter.ts new file mode 100644 index 000000000..b43b451aa --- /dev/null +++ b/packages/ast-grep-mcp/src/result-formatter.ts @@ -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") +} diff --git a/packages/ast-grep-mcp/src/runner.ts b/packages/ast-grep-mcp/src/runner.ts new file mode 100644 index 000000000..462c115b1 --- /dev/null +++ b/packages/ast-grep-mcp/src/runner.ts @@ -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 { + // 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") +} diff --git a/packages/ast-grep-mcp/src/sg-cli-path.ts b/packages/ast-grep-mcp/src/sg-cli-path.ts new file mode 100644 index 000000000..dc1ec4fe0 --- /dev/null +++ b/packages/ast-grep-mcp/src/sg-cli-path.ts @@ -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 = { + "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 +} diff --git a/packages/ast-grep-mcp/src/sg-compact-json-output.ts b/packages/ast-grep-mcp/src/sg-compact-json-output.ts new file mode 100644 index 000000000..218253165 --- /dev/null +++ b/packages/ast-grep-mcp/src/sg-compact-json-output.ts @@ -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, + } +} diff --git a/packages/ast-grep-mcp/src/tool-descriptions.ts b/packages/ast-grep-mcp/src/tool-descriptions.ts new file mode 100644 index 000000000..c15872e47 --- /dev/null +++ b/packages/ast-grep-mcp/src/tool-descriptions.ts @@ -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") diff --git a/packages/ast-grep-mcp/src/types.ts b/packages/ast-grep-mcp/src/types.ts new file mode 100644 index 000000000..82b558903 --- /dev/null +++ b/packages/ast-grep-mcp/src/types.ts @@ -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 +} diff --git a/packages/ast-grep-mcp/src/workspace-paths.ts b/packages/ast-grep-mcp/src/workspace-paths.ts new file mode 100644 index 000000000..b3be6bc6e --- /dev/null +++ b/packages/ast-grep-mcp/src/workspace-paths.ts @@ -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}`); +} diff --git a/packages/ast-grep-mcp/tsconfig.json b/packages/ast-grep-mcp/tsconfig.json new file mode 100644 index 000000000..079c808c1 --- /dev/null +++ b/packages/ast-grep-mcp/tsconfig.json @@ -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/**/*"] +}