feat(mcp): register ast-grep as built-in 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:24 +09:00
parent 499aff011a
commit ef09880e26
9 changed files with 273 additions and 22 deletions
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, describe, expect, it } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { pathToFileURL } from "node:url"
import { createAstGrepMcpConfig } from "./ast-grep"
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("createAstGrepMcpConfig", () => {
it("resolves bundled dist cli from module root when cwd is unrelated", () => {
// given
const packageRoot = createTemporaryDirectory("omo-ast-grep-package-root-")
const unrelatedCwd = createTemporaryDirectory("omo-ast-grep-unrelated-cwd-")
const moduleFilePath = join(packageRoot, "dist", "index.js")
const cliPath = join(packageRoot, "packages", "ast-grep-mcp", "dist", "cli.js")
mkdirSync(join(packageRoot, "dist"), { recursive: true })
mkdirSync(join(packageRoot, "packages", "ast-grep-mcp", "dist"), { recursive: true })
writeFileSync(cliPath, "#!/usr/bin/env node\n", "utf-8")
// when
const config = createAstGrepMcpConfig({
cwd: unrelatedCwd,
moduleUrl: pathToFileURL(moduleFilePath).href,
})
// then
expect(config.command).toEqual(["node", cliPath, "mcp"])
expect(config.environment?.OMO_AST_GREP_WORKSPACE).toBe(unrelatedCwd)
})
it("falls back to bun source cli for source checkouts before build", () => {
// given
const packageRoot = createTemporaryDirectory("omo-ast-grep-source-root-")
const moduleFilePath = join(packageRoot, "src", "mcp", "ast-grep.ts")
const sourceCliPath = join(packageRoot, "packages", "ast-grep-mcp", "src", "cli.ts")
mkdirSync(join(packageRoot, "src", "mcp"), { recursive: true })
mkdirSync(join(packageRoot, "packages", "ast-grep-mcp", "src"), { recursive: true })
writeFileSync(sourceCliPath, "console.log('mcp')\n", "utf-8")
// when
const config = createAstGrepMcpConfig({
cwd: createTemporaryDirectory("omo-ast-grep-source-cwd-"),
moduleUrl: pathToFileURL(moduleFilePath).href,
})
// then
expect(config.command).toEqual(["bun", sourceCliPath, "mcp"])
})
it("still returns a built-in MCP config when the cli has not been built yet", () => {
// given
const packageRoot = createTemporaryDirectory("omo-ast-grep-missing-root-")
const moduleFilePath = join(packageRoot, "dist", "index.js")
mkdirSync(join(packageRoot, "dist"), { recursive: true })
// when
const config = createAstGrepMcpConfig({
cwd: createTemporaryDirectory("omo-ast-grep-missing-cwd-"),
moduleUrl: pathToFileURL(moduleFilePath).href,
})
// then
expect(config.enabled).toBe(true)
expect(config.command[0]).toBe("node")
expect(config.command[1]).toContain(join("packages", "ast-grep-mcp", "dist", "cli.js"))
expect(config.command[2]).toBe("mcp")
})
it("does not resolve the MCP command from the opened workspace", () => {
// given
const packageRoot = createTemporaryDirectory("omo-ast-grep-safe-package-root-")
const workspaceRoot = createTemporaryDirectory("omo-ast-grep-malicious-workspace-")
const moduleFilePath = join(packageRoot, "dist", "index.js")
const workspaceCliPath = join(workspaceRoot, "packages", "ast-grep-mcp", "dist", "cli.js")
mkdirSync(join(packageRoot, "dist"), { recursive: true })
mkdirSync(join(workspaceRoot, "packages", "ast-grep-mcp", "dist"), { recursive: true })
writeFileSync(workspaceCliPath, "console.log('malicious')\n", "utf-8")
// when
const config = createAstGrepMcpConfig({
cwd: workspaceRoot,
moduleUrl: pathToFileURL(moduleFilePath).href,
})
// then
expect(config.command[1]).not.toBe(workspaceCliPath)
expect(config.command[1]).toContain(packageRoot)
})
it("maps disabled ast-grep tool names to MCP subtools", () => {
// given
const packageRoot = createTemporaryDirectory("omo-ast-grep-disabled-root-")
const moduleFilePath = join(packageRoot, "dist", "index.js")
mkdirSync(join(packageRoot, "dist"), { recursive: true })
// when
const config = createAstGrepMcpConfig({
cwd: createTemporaryDirectory("omo-ast-grep-disabled-cwd-"),
disabledTools: ["ast_grep_replace", "glob"],
moduleUrl: pathToFileURL(moduleFilePath).href,
})
// then
expect(config.environment?.OMO_AST_GREP_DISABLED_TOOLS).toBe("replace")
})
})
+97
View File
@@ -0,0 +1,97 @@
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { LocalMcpConfig } from "./lsp";
const PACKAGE_REL = "packages/ast-grep-mcp";
const DIST_CLI_REL = "dist/cli.js";
const SOURCE_CLI_REL = "src/cli.ts";
const WORKSPACE_ENV = "OMO_AST_GREP_WORKSPACE";
const DISABLED_TOOLS_ENV = "OMO_AST_GREP_DISABLED_TOOLS";
const MCP_TOOL_BY_OPENCODE_TOOL: Readonly<Record<string, string>> = {
ast_grep_search: "search",
ast_grep_replace: "replace",
};
type AstGrepMcpConfigOptions = {
readonly cwd?: string;
readonly disabledTools?: readonly string[];
readonly moduleUrl?: string;
readonly exists?: (path: string) => boolean;
};
type CommandCandidate = {
readonly command: string[];
readonly path: string;
readonly exists: boolean;
};
function addAncestorCommandCandidates(
startDirectory: string,
target: CommandCandidate[],
seenPaths: Set<string>,
pathExists: (path: string) => boolean,
): void {
let currentDirectory = resolve(startDirectory);
while (true) {
const distCliPath = resolve(currentDirectory, PACKAGE_REL, DIST_CLI_REL);
if (!seenPaths.has(distCliPath)) {
seenPaths.add(distCliPath);
target.push({ command: ["node", distCliPath, "mcp"], path: distCliPath, exists: pathExists(distCliPath) });
}
const sourceCliPath = resolve(currentDirectory, PACKAGE_REL, SOURCE_CLI_REL);
if (!seenPaths.has(sourceCliPath)) {
seenPaths.add(sourceCliPath);
target.push({ command: ["bun", sourceCliPath, "mcp"], path: sourceCliPath, exists: pathExists(sourceCliPath) });
}
const parentDirectory = resolve(currentDirectory, "..");
if (parentDirectory === currentDirectory) return;
currentDirectory = parentDirectory;
}
}
function getModuleDirectory(moduleUrl: string): string | null {
try {
return dirname(fileURLToPath(moduleUrl));
} catch {
return null;
}
}
function resolveAstGrepCommand(options: AstGrepMcpConfigOptions = {}): string[] {
const pathExists = options.exists ?? existsSync;
const candidates: CommandCandidate[] = [];
const seenPaths = new Set<string>();
const moduleDirectory = getModuleDirectory(options.moduleUrl ?? import.meta.url);
if (moduleDirectory) addAncestorCommandCandidates(moduleDirectory, candidates, seenPaths, pathExists);
const distCandidate = candidates.find((candidate) => candidate.path.endsWith(DIST_CLI_REL) && candidate.exists);
if (distCandidate) return distCandidate.command;
const sourceCandidate = candidates.find((candidate) => candidate.path.endsWith(SOURCE_CLI_REL) && candidate.exists);
if (sourceCandidate) return sourceCandidate.command;
return candidates[0]?.command ?? ["node", resolve(PACKAGE_REL, DIST_CLI_REL), "mcp"];
}
function astGrepDisabledTools(disabledTools: readonly string[] | undefined): string {
if (!disabledTools) return "";
return disabledTools
.map((toolName) => MCP_TOOL_BY_OPENCODE_TOOL[toolName])
.filter((toolName): toolName is string => typeof toolName === "string")
.join(",");
}
export function createAstGrepMcpConfig(options: AstGrepMcpConfigOptions = {}): LocalMcpConfig {
const workspaceDirectory = options.cwd ?? process.cwd();
return {
type: "local",
command: resolveAstGrepCommand(options),
enabled: true,
environment: {
[WORKSPACE_ENV]: workspaceDirectory,
[DISABLED_TOOLS_ENV]: astGrepDisabledTools(options.disabledTools),
},
};
}
+10 -1
View File
@@ -1,6 +1,7 @@
import { createWebsearchConfig } from "./websearch"
import { context7 } from "./context7"
import { grep_app } from "./grep-app"
import { createAstGrepMcpConfig } from "./ast-grep"
import { createLspMcpConfig, type LocalMcpConfig } from "./lsp"
import type { OhMyOpenCodeConfig } from "../config/schema"
@@ -16,7 +17,11 @@ type RemoteMcpConfig = {
type BuiltinMcpConfig = RemoteMcpConfig | LocalMcpConfig
export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpenCodeConfig) {
type BuiltinMcpOptions = {
readonly cwd?: string
}
export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpenCodeConfig, options: BuiltinMcpOptions = {}) {
const mcps: Record<string, BuiltinMcpConfig> = {}
if (!disabledMcps.includes("websearch")) {
@@ -38,5 +43,9 @@ export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpen
mcps.lsp = createLspMcpConfig()
}
if (!disabledMcps.includes("ast_grep")) {
mcps.ast_grep = createAstGrepMcpConfig({ cwd: options.cwd, disabledTools: config?.disabled_tools })
}
return mcps
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { z } from "zod"
export const McpNameSchema = z.enum(["websearch", "context7", "grep_app", "lsp"])
export const McpNameSchema = z.enum(["websearch", "context7", "grep_app", "lsp", "ast_grep"])
export type McpName = z.infer<typeof McpNameSchema>
+18 -12
View File
@@ -4,12 +4,19 @@ afterEach(() => {
mock.restore()
})
function mockLocalMcps(): void {
mock.module("../lsp", () => ({
createLspMcpConfig: () => ({ type: "local", command: ["node", "dist/cli.js", "mcp"], enabled: true }),
}))
mock.module("../ast-grep", () => ({
createAstGrepMcpConfig: () => ({ type: "local", command: ["node", "ast-grep-mcp", "mcp"], enabled: true }),
}))
}
describe("createBuiltinMcps", () => {
test("should return all MCPs when disabled_mcps is empty", () => {
// given
mock.module("../lsp", () => ({
createLspMcpConfig: () => ({ type: "local", command: ["node", "dist/cli.js", "mcp"], enabled: true }),
}))
mockLocalMcps()
const { createBuiltinMcps } = require("../index") as typeof import("../index")
const disabledMcps: string[] = []
@@ -22,13 +29,12 @@ describe("createBuiltinMcps", () => {
expect(result.context7).toBeDefined()
expect(result.grep_app).toBeDefined()
expect(result.lsp).toBeDefined()
expect(result.ast_grep).toBeDefined()
})
test("should filter out disabled MCPs", () => {
// given
mock.module("../lsp", () => ({
createLspMcpConfig: () => ({ type: "local", command: ["node", "dist/cli.js", "mcp"], enabled: true }),
}))
mockLocalMcps()
const { createBuiltinMcps } = require("../index") as typeof import("../index")
const disabledMcps = ["websearch"]
@@ -40,6 +46,7 @@ describe("createBuiltinMcps", () => {
expect(result.context7).toBeDefined()
expect(result.grep_app).toBeDefined()
expect(result.lsp).toBeDefined()
expect(result.ast_grep).toBeDefined()
})
test("should keep lsp when it uses a bootstrap command", () => {
@@ -57,22 +64,21 @@ describe("createBuiltinMcps", () => {
})
test("should return empty array when all MCPs are disabled", () => {
// given - disable all known MCPs
mock.module("../lsp", () => ({
createLspMcpConfig: () => ({ type: "local", command: ["node", "dist/cli.js", "mcp"], enabled: true }),
}))
// given
mockLocalMcps()
const { createBuiltinMcps } = require("../index") as typeof import("../index")
const disabledMcps = ["websearch", "context7", "grep_app", "lsp"]
const disabledMcps = ["websearch", "context7", "grep_app", "lsp", "ast_grep"]
// when
const result = createBuiltinMcps(disabledMcps)
// then - may still have MCPs we didn't list
// then
const remainingMcpNames = Object.keys(result)
expect(remainingMcpNames).not.toContain("websearch")
expect(remainingMcpNames).not.toContain("context7")
expect(remainingMcpNames).not.toContain("grep_app")
expect(remainingMcpNames).not.toContain("lsp")
expect(remainingMcpNames).not.toContain("ast_grep")
expect(remainingMcpNames).toEqual([])
})
})
+1 -1
View File
@@ -38,7 +38,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
});
applyToolConfig({ config, pluginConfig, agentResult });
await applyMcpConfig({ config, pluginConfig, pluginComponents });
await applyMcpConfig({ config, pluginConfig, ctx, pluginComponents });
await applyCommandConfig({ config, pluginConfig, ctx, pluginComponents });
config.formatter = formatterConfig;
@@ -46,6 +46,8 @@ const EMPTY_PLUGIN_COMPONENTS = {
errors: [],
}
const TEST_CTX = { directory: "/workspace/project" }
async function importFreshMcpConfigHandlerModule(): Promise<typeof import("./mcp-config-handler")> {
return import(`./mcp-config-handler?test=${Date.now()}-${Math.random()}`)
}
@@ -69,7 +71,7 @@ describe("applyMcpConfig collision handling", () => {
//#when
const { applyMcpConfig } = await importFreshMcpConfigHandlerModule()
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
//#then
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
@@ -98,7 +100,7 @@ describe("applyMcpConfig collision handling", () => {
//#when
const { applyMcpConfig } = await importFreshMcpConfigHandlerModule()
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
//#then
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
@@ -126,7 +128,7 @@ describe("applyMcpConfig collision handling", () => {
//#when
const { applyMcpConfig } = await importFreshMcpConfigHandlerModule()
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
//#then
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
+20 -3
View File
@@ -42,6 +42,8 @@ const EMPTY_PLUGIN_COMPONENTS = {
errors: [],
}
const TEST_CTX = { directory: "/workspace/project" }
describe("applyMcpConfig", () => {
test("preserves enabled:false from user config after merge with .mcp.json MCPs", async () => {
//#given
@@ -62,7 +64,7 @@ describe("applyMcpConfig", () => {
//#when
const { applyMcpConfig } = await import("./mcp-config-handler")
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
//#then
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
@@ -89,6 +91,7 @@ describe("applyMcpConfig", () => {
const { applyMcpConfig } = await import("./mcp-config-handler")
await applyMcpConfig({
config,
ctx: TEST_CTX,
pluginConfig,
pluginComponents: {
...EMPTY_PLUGIN_COMPONENTS,
@@ -112,7 +115,7 @@ describe("applyMcpConfig", () => {
//#when
const { applyMcpConfig } = await import("./mcp-config-handler")
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
//#then
expect(loadMcpConfigsSpy).toHaveBeenCalledWith(["firecrawl", "exa"])
@@ -135,7 +138,7 @@ describe("applyMcpConfig", () => {
//#when
const { applyMcpConfig } = await import("./mcp-config-handler")
await applyMcpConfig({ config, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
//#then
const mergedMcp = config.mcp as Record<string, Record<string, unknown>>
@@ -152,6 +155,7 @@ describe("applyMcpConfig", () => {
const { applyMcpConfig } = await import("./mcp-config-handler")
await applyMcpConfig({
config,
ctx: TEST_CTX,
pluginConfig,
pluginComponents: {
...EMPTY_PLUGIN_COMPONENTS,
@@ -166,4 +170,17 @@ describe("applyMcpConfig", () => {
expect(mergedMcp).not.toHaveProperty("plugin:custom")
})
test("passes the OpenCode workspace directory into built-in MCP config", async () => {
//#given
const config: Record<string, unknown> = { mcp: {} }
const pluginConfig = createPluginConfig()
//#when
const { applyMcpConfig } = await import("./mcp-config-handler")
await applyMcpConfig({ config, ctx: TEST_CTX, pluginConfig, pluginComponents: EMPTY_PLUGIN_COMPONENTS })
//#then
expect(createBuiltinMcpsSpy).toHaveBeenCalledWith([], pluginConfig, { cwd: TEST_CTX.directory })
})
})
+2 -1
View File
@@ -27,6 +27,7 @@ function captureUserDisabledMcps(
export async function applyMcpConfig(params: {
config: Record<string, unknown>;
ctx: { directory: string };
pluginConfig: OhMyOpenCodeConfig;
pluginComponents: PluginComponents;
}): Promise<void> {
@@ -47,7 +48,7 @@ export async function applyMcpConfig(params: {
}
const merged = {
...createBuiltinMcps(disabledMcps, params.pluginConfig),
...createBuiltinMcps(disabledMcps, params.pluginConfig, { cwd: params.ctx.directory }),
...mcpResult.servers,
...(userMcp ?? {}),
...params.pluginComponents.mcpServers,