fix(mcp): resolve local mcp runtimes

Resolve built-in local MCP runtime executables before handing command arrays to OpenCode so lsp and ast_grep do not depend on a bare node or bun lookup in the host PATH.

Keep source, dist, bootstrap, workspace-safety, and disabled_mcps behavior covered by focused tests and real OpenCode MCP status QA.

Plan: plans/fix-built-in-mcp-runtime-executables.md
This commit is contained in:
YeonGyu-Kim
2026-05-20 23:32:48 +09:00
parent 37be0d5691
commit eea27d6f7e
8 changed files with 299 additions and 31 deletions
+41 -3
View File
@@ -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<Record<string, string>>) {
return (commandName: string): RuntimeExecutable => {
const command = commands[commandName]
return command ? { command, available: true } : { command: commandName, available: false }
}
}
+39 -9
View File
@@ -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<string>,
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<string>();
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),
+8 -2
View File
@@ -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
+44 -3
View File
@@ -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<Record<string, string>>) {
return (commandName: string): RuntimeExecutable => {
const command = commands[commandName]
return command ? { command, available: true } : { command: commandName, available: false }
}
}
+33 -14
View File
@@ -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<string>,
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<string>()
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 {
+60
View File
@@ -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 })
})
})
+48
View File
@@ -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 }
}
@@ -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])
}
})
})