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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user