diff --git a/src/mcp/ast-grep.test.ts b/src/mcp/ast-grep.test.ts new file mode 100644 index 000000000..cfc78de1c --- /dev/null +++ b/src/mcp/ast-grep.test.ts @@ -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") + }) +}) diff --git a/src/mcp/ast-grep.ts b/src/mcp/ast-grep.ts new file mode 100644 index 000000000..70df1b892 --- /dev/null +++ b/src/mcp/ast-grep.ts @@ -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> = { + 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, + 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(); + 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), + }, + }; +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts index ad3f588c1..a3e8f8ada 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -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 = {} 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 } diff --git a/src/mcp/types.ts b/src/mcp/types.ts index f5e7f59a8..c22a8eb4e 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -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 diff --git a/src/mcp/zauc-mocks-mcp-index/index.test.ts b/src/mcp/zauc-mocks-mcp-index/index.test.ts index 079df42ef..a7fa110de 100644 --- a/src/mcp/zauc-mocks-mcp-index/index.test.ts +++ b/src/mcp/zauc-mocks-mcp-index/index.test.ts @@ -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([]) }) }) diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index e75d95ab1..d77920c44 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -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; diff --git a/src/plugin-handlers/mcp-config-handler-collision.test.ts b/src/plugin-handlers/mcp-config-handler-collision.test.ts index c8a85034a..a7b4b6d5e 100644 --- a/src/plugin-handlers/mcp-config-handler-collision.test.ts +++ b/src/plugin-handlers/mcp-config-handler-collision.test.ts @@ -46,6 +46,8 @@ const EMPTY_PLUGIN_COMPONENTS = { errors: [], } +const TEST_CTX = { directory: "/workspace/project" } + async function importFreshMcpConfigHandlerModule(): Promise { 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> @@ -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> @@ -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> diff --git a/src/plugin-handlers/mcp-config-handler.test.ts b/src/plugin-handlers/mcp-config-handler.test.ts index 217ca303e..4d1fade91 100644 --- a/src/plugin-handlers/mcp-config-handler.test.ts +++ b/src/plugin-handlers/mcp-config-handler.test.ts @@ -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> @@ -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> @@ -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 = { 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 }) + }) + }) diff --git a/src/plugin-handlers/mcp-config-handler.ts b/src/plugin-handlers/mcp-config-handler.ts index 474c76870..07506b6a3 100644 --- a/src/plugin-handlers/mcp-config-handler.ts +++ b/src/plugin-handlers/mcp-config-handler.ts @@ -27,6 +27,7 @@ function captureUserDisabledMcps( export async function applyMcpConfig(params: { config: Record; + ctx: { directory: string }; pluginConfig: OhMyOpenCodeConfig; pluginComponents: PluginComponents; }): Promise { @@ -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,