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
+27 -18
View File
@@ -1,13 +1,11 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, it, mock } from "bun:test"
import { afterEach, describe, expect, it } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { clearPluginConfigFileDetectionCache } from "../../../shared/jsonc-parser"
const originalCwd = process.cwd()
const originalOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR
const temporaryDirectories: string[] = []
function createTemporaryDirectory(prefix: string): string {
@@ -16,16 +14,14 @@ function createTemporaryDirectory(prefix: string): string {
return directory
}
afterEach(() => {
mock.restore()
clearPluginConfigFileDetectionCache()
process.chdir(originalCwd)
function createLspDistCli(workspaceDirectory: string): void {
const lspDistDirectory = join(workspaceDirectory, "packages", "lsp-tools-mcp", "dist")
mkdirSync(lspDistDirectory, { recursive: true })
writeFileSync(join(lspDistDirectory, "cli.js"), "#!/usr/bin/env node\n", "utf-8")
}
if (originalOpenCodeConfigDir === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir
}
afterEach(() => {
clearPluginConfigFileDetectionCache()
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
@@ -39,36 +35,49 @@ describe("getInstalledLspServers", () => {
const workspaceDirectory = createTemporaryDirectory("omo-tools-lsp-workspace-")
const projectConfigDirectory = join(workspaceDirectory, ".opencode")
mkdirSync(projectConfigDirectory, { recursive: true })
createLspDistCli(workspaceDirectory)
writeFileSync(
join(projectConfigDirectory, "oh-my-openagent.json"),
JSON.stringify({ disabled_mcps: ["lsp"] }),
"utf-8",
)
process.env.OPENCODE_CONFIG_DIR = userConfigDirectory
process.chdir(workspaceDirectory)
clearPluginConfigFileDetectionCache()
const { getInstalledLspServers } = await import(`./tools-lsp?t=${Date.now()}-disabled`)
// when
const servers = getInstalledLspServers()
const servers = getInstalledLspServers({ configDirectory: userConfigDirectory, cwd: workspaceDirectory })
// then
expect(servers).toEqual([])
})
it("returns bundled lsp server info when lsp MCP uses bootstrap fallback", async () => {
// given
const userConfigDirectory = createTemporaryDirectory("omo-tools-lsp-user-")
const workspaceDirectory = createTemporaryDirectory("omo-tools-lsp-bootstrap-")
clearPluginConfigFileDetectionCache()
const { getInstalledLspServers } = await import(`./tools-lsp?t=${Date.now()}-bootstrap`)
// when
const servers = getInstalledLspServers({ configDirectory: userConfigDirectory, cwd: workspaceDirectory })
// then
expect(servers).toEqual([{ id: "lsp-tools-mcp", extensions: ["*"] }])
})
it("returns bundled lsp server info when MCP is enabled", async () => {
// given
const userConfigDirectory = createTemporaryDirectory("omo-tools-lsp-user-")
const workspaceDirectory = createTemporaryDirectory("omo-tools-lsp-enabled-")
process.env.OPENCODE_CONFIG_DIR = userConfigDirectory
process.chdir(workspaceDirectory)
createLspDistCli(workspaceDirectory)
clearPluginConfigFileDetectionCache()
const { getInstalledLspServers } = await import(`./tools-lsp?t=${Date.now()}-enabled`)
// when
const servers = getInstalledLspServers()
const servers = getInstalledLspServers({ configDirectory: userConfigDirectory, cwd: workspaceDirectory })
// then
expect(servers).toEqual([{ id: "lsp-tools-mcp", extensions: ["*"] }])
+11 -7
View File
@@ -7,7 +7,10 @@ type OmoConfigForDoctor = {
disabled_mcps?: string[]
}
const PROJECT_CONFIG_DIR = join(process.cwd(), ".opencode")
type InstalledLspServersOptions = {
readonly configDirectory?: string
readonly cwd?: string
}
function readOmoConfig(configDirectory: string): OmoConfigForDoctor | null {
const detected = detectPluginConfigFile(configDirectory)
@@ -23,10 +26,11 @@ function readOmoConfig(configDirectory: string): OmoConfigForDoctor | null {
}
}
function isLspMcpDisabled(): boolean {
const userConfigDirectory = getOpenCodeConfigDir({ binary: "opencode" })
function isLspMcpDisabled(options: InstalledLspServersOptions): boolean {
const userConfigDirectory = options.configDirectory ?? getOpenCodeConfigDir({ binary: "opencode" })
const projectConfigDirectory = join(options.cwd ?? process.cwd(), ".opencode")
const userConfig = readOmoConfig(userConfigDirectory)
const projectConfig = readOmoConfig(PROJECT_CONFIG_DIR)
const projectConfig = readOmoConfig(projectConfigDirectory)
const disabledMcps = new Set<string>([
...(userConfig?.disabled_mcps ?? []),
@@ -36,12 +40,12 @@ function isLspMcpDisabled(): boolean {
return disabledMcps.has("lsp")
}
export function getInstalledLspServers(): Array<{ id: string; extensions: string[] }> {
if (isLspMcpDisabled()) {
export function getInstalledLspServers(options: InstalledLspServersOptions = {}): Array<{ id: string; extensions: string[] }> {
if (isLspMcpDisabled(options)) {
return []
}
const lspMcpConfig = createLspMcpConfig()
const lspMcpConfig = createLspMcpConfig({ cwd: options.cwd })
return lspMcpConfig.enabled ? [{ id: "lsp-tools-mcp", extensions: ["*"] }] : []
}
+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", () => ({