diff --git a/src/mcp/ast-grep.test.ts b/src/mcp/ast-grep.test.ts index cfc78de1c..70441164f 100644 --- a/src/mcp/ast-grep.test.ts +++ b/src/mcp/ast-grep.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { pathToFileURL } from "node:url" import { createAstGrepMcpConfig } from "./ast-grep" +import type { RuntimeExecutable } from "./runtime-executable" const temporaryDirectories: string[] = [] @@ -26,6 +27,7 @@ describe("createAstGrepMcpConfig", () => { 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") + const nodePath = join(packageRoot, "bin", "node") 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") @@ -34,10 +36,12 @@ describe("createAstGrepMcpConfig", () => { const config = createAstGrepMcpConfig({ cwd: unrelatedCwd, moduleUrl: pathToFileURL(moduleFilePath).href, + resolveExecutable: createResolver({ node: nodePath }), }) // then - expect(config.command).toEqual(["node", cliPath, "mcp"]) + expect(config.enabled).toBe(true) + expect(config.command).toEqual([nodePath, cliPath, "mcp"]) expect(config.environment?.OMO_AST_GREP_WORKSPACE).toBe(unrelatedCwd) }) @@ -46,6 +50,7 @@ describe("createAstGrepMcpConfig", () => { 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") + const bunPath = join(packageRoot, "bin", "bun") 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") @@ -54,31 +59,55 @@ describe("createAstGrepMcpConfig", () => { const config = createAstGrepMcpConfig({ cwd: createTemporaryDirectory("omo-ast-grep-source-cwd-"), moduleUrl: pathToFileURL(moduleFilePath).href, + resolveExecutable: createResolver({ bun: bunPath }), }) // then - expect(config.command).toEqual(["bun", sourceCliPath, "mcp"]) + expect(config.enabled).toBe(true) + expect(config.command).toEqual([bunPath, 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") + const nodePath = join(packageRoot, "bin", "node") mkdirSync(join(packageRoot, "dist"), { recursive: true }) // when const config = createAstGrepMcpConfig({ cwd: createTemporaryDirectory("omo-ast-grep-missing-cwd-"), moduleUrl: pathToFileURL(moduleFilePath).href, + resolveExecutable: createResolver({ node: nodePath }), }) // then expect(config.enabled).toBe(true) - expect(config.command[0]).toBe("node") + expect(config.command[0]).toBe(nodePath) expect(config.command[1]).toContain(join("packages", "ast-grep-mcp", "dist", "cli.js")) expect(config.command[2]).toBe("mcp") }) + it("disables the MCP config when no runtime can launch ast-grep", () => { + // given + const packageRoot = createTemporaryDirectory("omo-ast-grep-no-runtime-root-") + 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: createTemporaryDirectory("omo-ast-grep-no-runtime-cwd-"), + moduleUrl: pathToFileURL(moduleFilePath).href, + resolveExecutable: createResolver({}), + }) + + // then + expect(config.enabled).toBe(false) + }) + it("does not resolve the MCP command from the opened workspace", () => { // given const packageRoot = createTemporaryDirectory("omo-ast-grep-safe-package-root-") @@ -93,6 +122,7 @@ describe("createAstGrepMcpConfig", () => { const config = createAstGrepMcpConfig({ cwd: workspaceRoot, moduleUrl: pathToFileURL(moduleFilePath).href, + resolveExecutable: createResolver({ node: join(packageRoot, "bin", "node") }), }) // then @@ -111,9 +141,17 @@ describe("createAstGrepMcpConfig", () => { cwd: createTemporaryDirectory("omo-ast-grep-disabled-cwd-"), disabledTools: ["ast_grep_replace", "glob"], moduleUrl: pathToFileURL(moduleFilePath).href, + resolveExecutable: createResolver({ node: join(packageRoot, "bin", "node") }), }) // then expect(config.environment?.OMO_AST_GREP_DISABLED_TOOLS).toBe("replace") }) }) + +function createResolver(commands: Readonly>) { + return (commandName: string): RuntimeExecutable => { + const command = commands[commandName] + return command ? { command, available: true } : { command: commandName, available: false } + } +} diff --git a/src/mcp/ast-grep.ts b/src/mcp/ast-grep.ts index 70df1b892..6edd92e07 100644 --- a/src/mcp/ast-grep.ts +++ b/src/mcp/ast-grep.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { LocalMcpConfig } from "./lsp"; +import { resolveRuntimeExecutable, type RuntimeExecutable, type RuntimeExecutableResolver } from "./runtime-executable"; const PACKAGE_REL = "packages/ast-grep-mcp"; const DIST_CLI_REL = "dist/cli.js"; @@ -19,32 +20,52 @@ type AstGrepMcpConfigOptions = { readonly disabledTools?: readonly string[]; readonly moduleUrl?: string; readonly exists?: (path: string) => boolean; + readonly resolveExecutable?: RuntimeExecutableResolver; }; type CommandCandidate = { readonly command: string[]; readonly path: string; readonly exists: boolean; + readonly runtimeAvailable: boolean; }; +function resolveJavaScriptRuntime(resolveExecutable: RuntimeExecutableResolver): RuntimeExecutable { + const node = resolveExecutable("node"); + return node.available ? node : resolveExecutable("bun"); +} + function addAncestorCommandCandidates( startDirectory: string, target: CommandCandidate[], seenPaths: Set, pathExists: (path: string) => boolean, + resolveExecutable: RuntimeExecutableResolver, ): void { let currentDirectory = resolve(startDirectory); while (true) { const distCliPath = resolve(currentDirectory, PACKAGE_REL, DIST_CLI_REL); if (!seenPaths.has(distCliPath)) { + const runtime = resolveJavaScriptRuntime(resolveExecutable); seenPaths.add(distCliPath); - target.push({ command: ["node", distCliPath, "mcp"], path: distCliPath, exists: pathExists(distCliPath) }); + target.push({ + command: [runtime.command, distCliPath, "mcp"], + path: distCliPath, + exists: runtime.available && pathExists(distCliPath), + runtimeAvailable: runtime.available, + }); } const sourceCliPath = resolve(currentDirectory, PACKAGE_REL, SOURCE_CLI_REL); if (!seenPaths.has(sourceCliPath)) { + const runtime = resolveExecutable("bun"); seenPaths.add(sourceCliPath); - target.push({ command: ["bun", sourceCliPath, "mcp"], path: sourceCliPath, exists: pathExists(sourceCliPath) }); + target.push({ + command: [runtime.command, sourceCliPath, "mcp"], + path: sourceCliPath, + exists: runtime.available && pathExists(sourceCliPath), + runtimeAvailable: runtime.available, + }); } const parentDirectory = resolve(currentDirectory, ".."); @@ -61,18 +82,26 @@ function getModuleDirectory(moduleUrl: string): string | null { } } -function resolveAstGrepCommand(options: AstGrepMcpConfigOptions = {}): string[] { +function createFallbackCandidate(resolveExecutable: RuntimeExecutableResolver): CommandCandidate { + const runtime = resolveJavaScriptRuntime(resolveExecutable); + const path = resolve(PACKAGE_REL, DIST_CLI_REL); + return { command: [runtime.command, path, "mcp"], path, exists: runtime.available, runtimeAvailable: runtime.available }; +} + +function resolveAstGrepCommand(options: AstGrepMcpConfigOptions = {}): CommandCandidate { const pathExists = options.exists ?? existsSync; + const resolveExecutable = options.resolveExecutable ?? resolveRuntimeExecutable; const candidates: CommandCandidate[] = []; const seenPaths = new Set(); const moduleDirectory = getModuleDirectory(options.moduleUrl ?? import.meta.url); - if (moduleDirectory) addAncestorCommandCandidates(moduleDirectory, candidates, seenPaths, pathExists); + if (moduleDirectory) addAncestorCommandCandidates(moduleDirectory, candidates, seenPaths, pathExists, resolveExecutable); const distCandidate = candidates.find((candidate) => candidate.path.endsWith(DIST_CLI_REL) && candidate.exists); - if (distCandidate) return distCandidate.command; + if (distCandidate) return distCandidate; 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"]; + if (sourceCandidate) return sourceCandidate; + const fallbackCandidate = candidates.find((candidate) => candidate.path.endsWith(DIST_CLI_REL)) ?? createFallbackCandidate(resolveExecutable); + return { ...fallbackCandidate, exists: fallbackCandidate.runtimeAvailable }; } function astGrepDisabledTools(disabledTools: readonly string[] | undefined): string { @@ -85,10 +114,11 @@ function astGrepDisabledTools(disabledTools: readonly string[] | undefined): str export function createAstGrepMcpConfig(options: AstGrepMcpConfigOptions = {}): LocalMcpConfig { const workspaceDirectory = options.cwd ?? process.cwd(); + const resolvedCommand = resolveAstGrepCommand(options); return { type: "local", - command: resolveAstGrepCommand(options), - enabled: true, + command: resolvedCommand.command, + enabled: resolvedCommand.exists, environment: { [WORKSPACE_ENV]: workspaceDirectory, [DISABLED_TOOLS_ENV]: astGrepDisabledTools(options.disabledTools), diff --git a/src/mcp/index.ts b/src/mcp/index.ts index a3e8f8ada..e9b934c5d 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -3,6 +3,7 @@ import { context7 } from "./context7" import { grep_app } from "./grep-app" import { createAstGrepMcpConfig } from "./ast-grep" import { createLspMcpConfig, type LocalMcpConfig } from "./lsp" +import type { RuntimeExecutableResolver } from "./runtime-executable" import type { OhMyOpenCodeConfig } from "../config/schema" export { McpNameSchema, type McpName } from "./types" @@ -19,6 +20,7 @@ type BuiltinMcpConfig = RemoteMcpConfig | LocalMcpConfig type BuiltinMcpOptions = { readonly cwd?: string + readonly resolveExecutable?: RuntimeExecutableResolver } export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpenCodeConfig, options: BuiltinMcpOptions = {}) { @@ -40,11 +42,15 @@ export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpen } if (!disabledMcps.includes("lsp")) { - mcps.lsp = createLspMcpConfig() + mcps.lsp = createLspMcpConfig({ resolveExecutable: options.resolveExecutable }) } if (!disabledMcps.includes("ast_grep")) { - mcps.ast_grep = createAstGrepMcpConfig({ cwd: options.cwd, disabledTools: config?.disabled_tools }) + mcps.ast_grep = createAstGrepMcpConfig({ + cwd: options.cwd, + disabledTools: config?.disabled_tools, + resolveExecutable: options.resolveExecutable, + }) } return mcps diff --git a/src/mcp/lsp.test.ts b/src/mcp/lsp.test.ts index 770500d0e..f4b1c23bb 100644 --- a/src/mcp/lsp.test.ts +++ b/src/mcp/lsp.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { pathToFileURL } from "node:url" import { createLspMcpConfig } from "./lsp" +import type { RuntimeExecutable } from "./runtime-executable" const temporaryDirectories: string[] = [] @@ -26,6 +27,7 @@ describe("createLspMcpConfig", () => { const unrelatedCwd = createTemporaryDirectory("omo-lsp-unrelated-cwd-") const moduleFilePath = join(packageRoot, "dist", "index.js") const cliPath = join(packageRoot, "packages", "lsp-tools-mcp", "dist", "cli.js") + const nodePath = join(packageRoot, "bin", "node") mkdirSync(join(packageRoot, "dist"), { recursive: true }) mkdirSync(join(packageRoot, "packages", "lsp-tools-mcp", "dist"), { recursive: true }) writeFileSync(cliPath, "#!/usr/bin/env node\n", "utf-8") @@ -34,11 +36,12 @@ describe("createLspMcpConfig", () => { const config = createLspMcpConfig({ cwd: unrelatedCwd, moduleUrl: pathToFileURL(moduleFilePath).href, + resolveExecutable: createResolver({ node: nodePath }), }) // then expect(config.enabled).toBe(true) - expect(config.command).toEqual(["node", cliPath, "mcp"]) + expect(config.command).toEqual([nodePath, cliPath, "mcp"]) }) it("falls back to bun source cli for source checkouts before build", () => { @@ -46,6 +49,7 @@ describe("createLspMcpConfig", () => { const packageRoot = createTemporaryDirectory("omo-lsp-source-root-") const moduleFilePath = join(packageRoot, "src", "mcp", "lsp.ts") const sourceCliPath = join(packageRoot, "packages", "lsp-tools-mcp", "src", "cli.ts") + const bunPath = join(packageRoot, "bin", "bun") mkdirSync(join(packageRoot, "src", "mcp"), { recursive: true }) mkdirSync(join(packageRoot, "packages", "lsp-tools-mcp", "src"), { recursive: true }) writeFileSync(sourceCliPath, "console.log('mcp')\n", "utf-8") @@ -54,17 +58,22 @@ describe("createLspMcpConfig", () => { const config = createLspMcpConfig({ cwd: createTemporaryDirectory("omo-lsp-source-cwd-"), moduleUrl: pathToFileURL(moduleFilePath).href, + resolveExecutable: createResolver({ bun: bunPath }), }) // then expect(config.enabled).toBe(true) - expect(config.command).toEqual(["bun", sourceCliPath, "mcp"]) + expect(config.command).toEqual([bunPath, sourceCliPath, "mcp"]) }) it("returns a bootstrap command when no LSP cli entrypoint exists", () => { // given const packageRoot = createTemporaryDirectory("omo-lsp-missing-root-") const moduleFilePath = join(packageRoot, "dist", "index.js") + const gitPath = join(packageRoot, "bin", "git") + const bunPath = join(packageRoot, "bin", "bun") + const nodePath = join(packageRoot, "bin", "node") + const npmPath = join(packageRoot, "bin", "npm") mkdirSync(join(packageRoot, "dist"), { recursive: true }) writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "oh-my-opencode" }), "utf-8") @@ -72,14 +81,46 @@ describe("createLspMcpConfig", () => { const config = createLspMcpConfig({ cwd: createTemporaryDirectory("omo-lsp-missing-cwd-"), moduleUrl: pathToFileURL(moduleFilePath).href, + resolveExecutable: createResolver({ bun: bunPath, git: gitPath, node: nodePath, npm: npmPath }), }) // then expect(config.enabled).toBe(true) - expect(config.command[0]).toBe("node") + expect(config.command[0]).toBe(nodePath) expect(config.command[1]).toBe("-e") expect(config.command[2]).toContain("submodule") expect(config.command[2]).toContain("npm") expect(config.command[3]).toBe(packageRoot) + expect(config.command[4]).toBe(gitPath) + expect(config.command[5]).toBe(npmPath) + expect(config.command[6]).toBe(bunPath) + }) + + it("disables the MCP config when no runtime can launch any LSP candidate", () => { + // given + const packageRoot = createTemporaryDirectory("omo-lsp-no-runtime-root-") + const moduleFilePath = join(packageRoot, "dist", "index.js") + const cliPath = join(packageRoot, "packages", "lsp-tools-mcp", "dist", "cli.js") + mkdirSync(join(packageRoot, "dist"), { recursive: true }) + mkdirSync(join(packageRoot, "packages", "lsp-tools-mcp", "dist"), { recursive: true }) + writeFileSync(cliPath, "#!/usr/bin/env node\n", "utf-8") + + // when + const config = createLspMcpConfig({ + cwd: createTemporaryDirectory("omo-lsp-no-runtime-cwd-"), + moduleUrl: pathToFileURL(moduleFilePath).href, + resolveExecutable: createResolver({}), + }) + + // then + expect(config.enabled).toBe(false) + expect(config.environment?.LSP_TOOLS_MCP_PROJECT_CONFIG).toBe(".opencode/lsp.json") }) }) + +function createResolver(commands: Readonly>) { + return (commandName: string): RuntimeExecutable => { + const command = commands[commandName] + return command ? { command, available: true } : { command: commandName, available: false } + } +} diff --git a/src/mcp/lsp.ts b/src/mcp/lsp.ts index ac7b0f994..712c3cad8 100644 --- a/src/mcp/lsp.ts +++ b/src/mcp/lsp.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs" import { dirname, resolve } from "node:path" import { fileURLToPath } from "node:url" +import { resolveRuntimeExecutable, type RuntimeExecutable, type RuntimeExecutableResolver } from "./runtime-executable" const SUBMODULE_REL = "packages/lsp-tools-mcp" const DIST_CLI_REL = "dist/cli.js" @@ -11,6 +12,9 @@ const LSP_BOOTSTRAP_SCRIPT = [ "const { join } = require('node:path')", "const { spawnSync } = require('node:child_process')", "const root = process.argv[1]", + "const git = process.argv[2] || 'git'", + "const npm = process.argv[3] || 'npm'", + "const bun = process.argv[4] || 'bun'", "const submodule = join(root, 'packages/lsp-tools-mcp')", "const dist = join(submodule, 'dist/cli.js')", "const source = join(submodule, 'src/cli.ts')", @@ -18,12 +22,12 @@ const LSP_BOOTSTRAP_SCRIPT = [ "const finish = (result) => { if (result.error) { console.error(result.error.message); process.exit(1) } process.exit(result.status ?? 1) }", "const runIfAvailable = (command, args) => { const result = run(command, args, 'inherit'); if (result.error) return false; finish(result); return true }", "if (existsSync(dist)) finish(run(process.execPath, [dist, 'mcp'], 'inherit'))", - "if (existsSync(source)) runIfAvailable('bun', [source, 'mcp'])", - "const submoduleResult = run('git', ['submodule', 'update', '--init', '--recursive', 'packages/lsp-tools-mcp'], ['ignore', 'ignore', 'inherit'])", + "if (existsSync(source)) runIfAvailable(bun, [source, 'mcp'])", + "const submoduleResult = run(git, ['submodule', 'update', '--init', '--recursive', 'packages/lsp-tools-mcp'], ['ignore', 'ignore', 'inherit'])", "if (submoduleResult.error || submoduleResult.status !== 0) finish(submoduleResult)", "if (existsSync(dist)) finish(run(process.execPath, [dist, 'mcp'], 'inherit'))", - "if (existsSync(source)) runIfAvailable('bun', [source, 'mcp'])", - "for (const [command, args] of [['npm', ['--prefix', submodule, 'install', '--no-package-lock', '--no-audit', '--no-fund']], ['npm', ['--prefix', submodule, 'run', 'build']]]) { const result = run(command, args, ['ignore', 'ignore', 'inherit']); if (result.error || result.status !== 0) finish(result) }", + "if (existsSync(source)) runIfAvailable(bun, [source, 'mcp'])", + "for (const [command, args] of [[npm, ['--prefix', submodule, 'install', '--no-package-lock', '--no-audit', '--no-fund']], [npm, ['--prefix', submodule, 'run', 'build']]]) { const result = run(command, args, ['ignore', 'ignore', 'inherit']); if (result.error || result.status !== 0) finish(result) }", "finish(run(process.execPath, [dist, 'mcp'], 'inherit'))", ].join(";") @@ -31,6 +35,7 @@ type LspMcpConfigOptions = { readonly cwd?: string readonly moduleUrl?: string readonly exists?: (path: string) => boolean + readonly resolveExecutable?: RuntimeExecutableResolver } type LspCommandCandidate = { @@ -52,29 +57,32 @@ function addAncestorCommandCandidates( target: LspCommandCandidate[], seenPaths: Set, pathExists: (path: string) => boolean, + resolveExecutable: RuntimeExecutableResolver, ): void { let currentDirectory = resolve(startDirectory) while (true) { const distCliPath = resolve(currentDirectory, SUBMODULE_REL, DIST_CLI_REL) if (!seenPaths.has(distCliPath)) { + const runtime = resolveJavaScriptRuntime(resolveExecutable) seenPaths.add(distCliPath) target.push({ - command: ["node", distCliPath, "mcp"], + command: [runtime.command, distCliPath, "mcp"], root: currentDirectory, path: distCliPath, - exists: pathExists(distCliPath), + exists: runtime.available && pathExists(distCliPath), }) } const sourceCliPath = resolve(currentDirectory, SUBMODULE_REL, SOURCE_CLI_REL) if (!seenPaths.has(sourceCliPath)) { + const runtime = resolveExecutable("bun") seenPaths.add(sourceCliPath) target.push({ - command: ["bun", sourceCliPath, "mcp"], + command: [runtime.command, sourceCliPath, "mcp"], root: currentDirectory, path: sourceCliPath, - exists: pathExists(sourceCliPath), + exists: runtime.available && pathExists(sourceCliPath), }) } @@ -99,26 +107,37 @@ function findBootstrapRoot(candidates: readonly LspCommandCandidate[], pathExist return candidates.find((candidate) => pathExists(resolve(candidate.root, "package.json")))?.root ?? process.cwd() } -function createBootstrapCandidate(root: string): LspCommandCandidate { +function resolveJavaScriptRuntime(resolveExecutable: RuntimeExecutableResolver): RuntimeExecutable { + const node = resolveExecutable("node") + return node.available ? node : resolveExecutable("bun") +} + +function createBootstrapCandidate(root: string, resolveExecutable: RuntimeExecutableResolver): LspCommandCandidate { + const runtime = resolveJavaScriptRuntime(resolveExecutable) + const bun = resolveExecutable("bun") + const git = resolveExecutable("git") + const npm = resolveExecutable("npm") + return { - command: ["node", "-e", LSP_BOOTSTRAP_SCRIPT, root], + command: [runtime.command, "-e", LSP_BOOTSTRAP_SCRIPT, root, git.command, npm.command, bun.command], root, path: resolve(root, SUBMODULE_REL, DIST_CLI_REL), - exists: true, + exists: runtime.available && git.available && npm.available, } } function resolveLspCommand(options: LspMcpConfigOptions = {}): LspCommandCandidate { const pathExists = options.exists ?? existsSync + const resolveExecutable = options.resolveExecutable ?? resolveRuntimeExecutable const candidates: LspCommandCandidate[] = [] const seenPaths = new Set() const moduleDirectory = getModuleDirectory(options.moduleUrl ?? import.meta.url) if (moduleDirectory) { - addAncestorCommandCandidates(moduleDirectory, candidates, seenPaths, pathExists) + addAncestorCommandCandidates(moduleDirectory, candidates, seenPaths, pathExists, resolveExecutable) } - addAncestorCommandCandidates(options.cwd ?? process.cwd(), candidates, seenPaths, pathExists) + addAncestorCommandCandidates(options.cwd ?? process.cwd(), candidates, seenPaths, pathExists, resolveExecutable) const distCandidate = candidates.find((candidate) => candidate.path.endsWith(DIST_CLI_REL) && candidate.exists) if (distCandidate) { @@ -130,7 +149,7 @@ function resolveLspCommand(options: LspMcpConfigOptions = {}): LspCommandCandida return sourceCandidate } - return createBootstrapCandidate(findBootstrapRoot(candidates, pathExists)) + return createBootstrapCandidate(findBootstrapRoot(candidates, pathExists), resolveExecutable) } export function createLspMcpConfig(options: LspMcpConfigOptions = {}): LocalMcpConfig { diff --git a/src/mcp/runtime-executable.test.ts b/src/mcp/runtime-executable.test.ts new file mode 100644 index 000000000..de8537d1e --- /dev/null +++ b/src/mcp/runtime-executable.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" +import { join } from "node:path" +import { resolveRuntimeExecutable } from "./runtime-executable" + +describe("resolveRuntimeExecutable", () => { + test("#given lookup returns an absolute command #when resolving #then marks the executable available", () => { + // given + const nodePath = join("/tmp", "omo-runtime", "node") + + // when + const result = resolveRuntimeExecutable("node", { + which: (commandName) => (commandName === "node" ? nodePath : null), + }) + + // then + expect(result).toEqual({ command: nodePath, available: true }) + }) + + test("#given lookup misses #when resolving #then keeps the command unavailable", () => { + // given + const commandName = "definitely-not-installed" + + // when + const result = resolveRuntimeExecutable(commandName, { + which: () => null, + execPath: join("/tmp", "omo-runtime", "bun"), + }) + + // then + expect(result).toEqual({ command: commandName, available: false }) + }) + + test("#given an unsafe command name #when resolving #then does not trust the lookup result", () => { + // given + const unsafeName = "../node" + + // when + const result = resolveRuntimeExecutable(unsafeName, { + which: () => join("/tmp", "omo-runtime", "node"), + execPath: join("/tmp", "omo-runtime", "node"), + }) + + // then + expect(result).toEqual({ command: unsafeName, available: false }) + }) + + test("#given the host process is node #when resolving node #then uses process execPath before PATH lookup", () => { + // given + const nodePath = join("/tmp", "omo-runtime", "node") + + // when + const result = resolveRuntimeExecutable("node", { + which: () => null, + execPath: nodePath, + }) + + // then + expect(result).toEqual({ command: nodePath, available: true }) + }) +}) diff --git a/src/mcp/runtime-executable.ts b/src/mcp/runtime-executable.ts new file mode 100644 index 000000000..117438c9a --- /dev/null +++ b/src/mcp/runtime-executable.ts @@ -0,0 +1,48 @@ +import { basename } from "node:path" +import { bunWhich } from "../shared/bun-which-shim" + +export type RuntimeExecutable = { + readonly command: string + readonly available: boolean +} + +export type RuntimeExecutableResolver = (commandName: string) => RuntimeExecutable + +type RuntimeExecutableOptions = { + readonly which?: (commandName: string) => string | null + readonly execPath?: string +} + +const NODE_EXECUTABLE_NAMES = new Set(["node", "node.exe"]) + +function isUnsafeCommandName(commandName: string): boolean { + if (commandName.length === 0) return true + if (commandName.includes("/") || commandName.includes("\\")) return true + if (commandName === "." || commandName === ".." || commandName.includes("..")) return true + if (/^[a-zA-Z]:/.test(commandName)) return true + if (commandName.includes("\0")) return true + + return false +} + +function isNodeExecPath(execPath: string): boolean { + return NODE_EXECUTABLE_NAMES.has(basename(execPath).toLowerCase()) +} + +export function resolveRuntimeExecutable(commandName: string, options: RuntimeExecutableOptions = {}): RuntimeExecutable { + if (isUnsafeCommandName(commandName)) { + return { command: commandName, available: false } + } + + const execPath = options.execPath ?? process.execPath + if (commandName === "node" && isNodeExecPath(execPath)) { + return { command: execPath, available: true } + } + + const resolved = (options.which ?? bunWhich)(commandName) + if (resolved) { + return { command: resolved, available: true } + } + + return { command: commandName, available: false } +} diff --git a/src/mcp/zauc-mocks-mcp-index/index.test.ts b/src/mcp/zauc-mocks-mcp-index/index.test.ts index a7fa110de..5d37ceec0 100644 --- a/src/mcp/zauc-mocks-mcp-index/index.test.ts +++ b/src/mcp/zauc-mocks-mcp-index/index.test.ts @@ -81,4 +81,30 @@ describe("createBuiltinMcps", () => { expect(remainingMcpNames).not.toContain("ast_grep") expect(remainingMcpNames).toEqual([]) }) + + test("should resolve enabled local MCP runtime commands before registration", async () => { + // given + mock.restore() + const nodePath = "/tmp/omo-runtime/node" + const bunPath = "/tmp/omo-runtime/bun" + const { createBuiltinMcps } = await import(`../index?runtime=${Date.now()}-${Math.random()}`) + + // when + const result = createBuiltinMcps([], undefined, { + cwd: process.cwd(), + resolveExecutable: (commandName: string) => { + if (commandName === "node") return { command: nodePath, available: true } + if (commandName === "bun") return { command: bunPath, available: true } + return { command: commandName, available: false } + }, + }) + + // then + for (const entry of [result.lsp, result.ast_grep]) { + expect(entry?.type).toBe("local") + if (entry?.type !== "local") throw new Error("expected local MCP config") + expect(["node", "bun"]).not.toContain(entry.command[0]) + expect([nodePath, bunPath]).toContain(entry.command[0]) + } + }) })