diff --git a/src/cli/doctor/checks/tools-lsp.test.ts b/src/cli/doctor/checks/tools-lsp.test.ts
index 104e83b33..f426166d1 100644
--- a/src/cli/doctor/checks/tools-lsp.test.ts
+++ b/src/cli/doctor/checks/tools-lsp.test.ts
@@ -1,13 +1,11 @@
///
-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: ["*"] }])
diff --git a/src/cli/doctor/checks/tools-lsp.ts b/src/cli/doctor/checks/tools-lsp.ts
index b74c60791..b80020e13 100644
--- a/src/cli/doctor/checks/tools-lsp.ts
+++ b/src/cli/doctor/checks/tools-lsp.ts
@@ -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([
...(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: ["*"] }] : []
}
diff --git a/src/mcp/lsp.test.ts b/src/mcp/lsp.test.ts
index 271a39de0..770500d0e 100644
--- a/src/mcp/lsp.test.ts
+++ b/src/mcp/lsp.test.ts
@@ -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)
})
})
diff --git a/src/mcp/lsp.ts b/src/mcp/lsp.ts
index 709328296..ac7b0f994 100644
--- a/src/mcp/lsp.ts
+++ b/src/mcp/lsp.ts
@@ -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()
@@ -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,
},
diff --git a/src/mcp/zauc-mocks-mcp-index/index.test.ts b/src/mcp/zauc-mocks-mcp-index/index.test.ts
index 7e152c12c..079df42ef 100644
--- a/src/mcp/zauc-mocks-mcp-index/index.test.ts
+++ b/src/mcp/zauc-mocks-mcp-index/index.test.ts
@@ -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", () => ({