fix(mcp): bootstrap lsp when cli is unavailable

Keep the built-in lsp MCP registered even when the submodule CLI artifact is missing.

The fallback command initializes the lsp-tools-mcp submodule, prefers the source CLI without dirtying the checkout with dist output, and keeps npm build as a last resort when Bun cannot run the source entrypoint.

Plan: plans/fix-lsp-mcp-missing-cli.md
This commit is contained in:
YeonGyu-Kim
2026-05-18 20:18:51 +09:00
parent f6fba0b154
commit 6fe2f72c76
5 changed files with 114 additions and 36 deletions
+8 -3
View File
@@ -37,6 +37,7 @@ describe("createLspMcpConfig", () => {
})
// then
expect(config.enabled).toBe(true)
expect(config.command).toEqual(["node", cliPath, "mcp"])
})
@@ -56,14 +57,16 @@ describe("createLspMcpConfig", () => {
})
// then
expect(config.enabled).toBe(true)
expect(config.command).toEqual(["bun", sourceCliPath, "mcp"])
})
it("still returns a built-in MCP config when the cli has not been built yet", () => {
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")
mkdirSync(join(packageRoot, "dist"), { recursive: true })
writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "oh-my-opencode" }), "utf-8")
// when
const config = createLspMcpConfig({
@@ -74,7 +77,9 @@ describe("createLspMcpConfig", () => {
// then
expect(config.enabled).toBe(true)
expect(config.command[0]).toBe("node")
expect(config.command[1]).toContain(join("packages", "lsp-tools-mcp", "dist", "cli.js"))
expect(config.command[2]).toBe("mcp")
expect(config.command[1]).toBe("-e")
expect(config.command[2]).toContain("submodule")
expect(config.command[2]).toContain("npm")
expect(config.command[3]).toBe(packageRoot)
})
})
+54 -8
View File
@@ -6,6 +6,26 @@ const SUBMODULE_REL = "packages/lsp-tools-mcp"
const DIST_CLI_REL = "dist/cli.js"
const SOURCE_CLI_REL = "src/cli.ts"
const PROJECT_LSP_CONFIG = ".opencode/lsp.json"
const LSP_BOOTSTRAP_SCRIPT = [
"const { existsSync } = require('node:fs')",
"const { join } = require('node:path')",
"const { spawnSync } = require('node:child_process')",
"const root = process.argv[1]",
"const submodule = join(root, 'packages/lsp-tools-mcp')",
"const dist = join(submodule, 'dist/cli.js')",
"const source = join(submodule, 'src/cli.ts')",
"const run = (command, args, stdio) => spawnSync(command, args, { cwd: root, env: process.env, stdio })",
"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 (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) }",
"finish(run(process.execPath, [dist, 'mcp'], 'inherit'))",
].join(";")
type LspMcpConfigOptions = {
readonly cwd?: string
@@ -15,6 +35,7 @@ type LspMcpConfigOptions = {
type LspCommandCandidate = {
readonly command: string[]
readonly root: string
readonly path: string
readonly exists: boolean
}
@@ -38,13 +59,23 @@ function addAncestorCommandCandidates(
const distCliPath = resolve(currentDirectory, SUBMODULE_REL, DIST_CLI_REL)
if (!seenPaths.has(distCliPath)) {
seenPaths.add(distCliPath)
target.push({ command: ["node", distCliPath, "mcp"], path: distCliPath, exists: pathExists(distCliPath) })
target.push({
command: ["node", distCliPath, "mcp"],
root: currentDirectory,
path: distCliPath,
exists: pathExists(distCliPath),
})
}
const sourceCliPath = resolve(currentDirectory, SUBMODULE_REL, SOURCE_CLI_REL)
if (!seenPaths.has(sourceCliPath)) {
seenPaths.add(sourceCliPath)
target.push({ command: ["bun", sourceCliPath, "mcp"], path: sourceCliPath, exists: pathExists(sourceCliPath) })
target.push({
command: ["bun", sourceCliPath, "mcp"],
root: currentDirectory,
path: sourceCliPath,
exists: pathExists(sourceCliPath),
})
}
const parentDirectory = resolve(currentDirectory, "..")
@@ -64,7 +95,20 @@ function getModuleDirectory(moduleUrl: string): string | null {
}
}
function resolveLspCommand(options: LspMcpConfigOptions = {}): string[] {
function findBootstrapRoot(candidates: readonly LspCommandCandidate[], pathExists: (path: string) => boolean): string {
return candidates.find((candidate) => pathExists(resolve(candidate.root, "package.json")))?.root ?? process.cwd()
}
function createBootstrapCandidate(root: string): LspCommandCandidate {
return {
command: ["node", "-e", LSP_BOOTSTRAP_SCRIPT, root],
root,
path: resolve(root, SUBMODULE_REL, DIST_CLI_REL),
exists: true,
}
}
function resolveLspCommand(options: LspMcpConfigOptions = {}): LspCommandCandidate {
const pathExists = options.exists ?? existsSync
const candidates: LspCommandCandidate[] = []
const seenPaths = new Set<string>()
@@ -78,22 +122,24 @@ function resolveLspCommand(options: LspMcpConfigOptions = {}): string[] {
const distCandidate = candidates.find((candidate) => candidate.path.endsWith(DIST_CLI_REL) && candidate.exists)
if (distCandidate) {
return distCandidate.command
return distCandidate
}
const sourceCandidate = candidates.find((candidate) => candidate.path.endsWith(SOURCE_CLI_REL) && candidate.exists)
if (sourceCandidate) {
return sourceCandidate.command
return sourceCandidate
}
return candidates[0]?.command ?? ["node", resolve(process.cwd(), SUBMODULE_REL, DIST_CLI_REL), "mcp"]
return createBootstrapCandidate(findBootstrapRoot(candidates, pathExists))
}
export function createLspMcpConfig(options: LspMcpConfigOptions = {}): LocalMcpConfig {
const resolvedCommand = resolveLspCommand(options)
return {
type: "local",
command: resolveLspCommand(options),
enabled: true,
command: resolvedCommand.command,
enabled: resolvedCommand.exists,
environment: {
LSP_TOOLS_MCP_PROJECT_CONFIG: PROJECT_LSP_CONFIG,
},
@@ -42,6 +42,20 @@ describe("createBuiltinMcps", () => {
expect(result.lsp).toBeDefined()
})
test("should keep lsp when it uses a bootstrap command", () => {
// given
mock.module("../lsp", () => ({
createLspMcpConfig: () => ({ type: "local", command: ["node", "-e", "bootstrap", "/repo"], enabled: true }),
}))
const { createBuiltinMcps } = require("../index") as typeof import("../index")
// when
const result = createBuiltinMcps([])
// then
expect(result.lsp).toBeDefined()
})
test("should return empty array when all MCPs are disabled", () => {
// given - disable all known MCPs
mock.module("../lsp", () => ({