From ae278eb9f29edcd9326aec4abbd6bf79ce94fc86 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 18 May 2026 16:45:02 +0900 Subject: [PATCH] fix(mcp): always register lsp server --- src/cli/doctor/checks/tools-lsp.test.ts | 5 +- src/cli/doctor/checks/tools-lsp.ts | 6 +- src/mcp/AGENTS.md | 3 +- src/mcp/index.ts | 5 +- src/mcp/lsp.test.ts | 80 ++++++++++++++++++++++ src/mcp/lsp.ts | 89 ++++++++++++++++++------- 6 files changed, 149 insertions(+), 39 deletions(-) create mode 100644 src/mcp/lsp.test.ts diff --git a/src/cli/doctor/checks/tools-lsp.test.ts b/src/cli/doctor/checks/tools-lsp.test.ts index 804af9685..104e83b33 100644 --- a/src/cli/doctor/checks/tools-lsp.test.ts +++ b/src/cli/doctor/checks/tools-lsp.test.ts @@ -57,13 +57,10 @@ describe("getInstalledLspServers", () => { expect(servers).toEqual([]) }) - it("returns bundled lsp server info when MCP is enabled and available", async () => { + 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-") - const lspCliDirectory = join(workspaceDirectory, "packages", "lsp-tools-mcp", "dist") - mkdirSync(lspCliDirectory, { recursive: true }) - writeFileSync(join(lspCliDirectory, "cli.js"), "#!/usr/bin/env node\n", "utf-8") process.env.OPENCODE_CONFIG_DIR = userConfigDirectory process.chdir(workspaceDirectory) clearPluginConfigFileDetectionCache() diff --git a/src/cli/doctor/checks/tools-lsp.ts b/src/cli/doctor/checks/tools-lsp.ts index 3add74053..b74c60791 100644 --- a/src/cli/doctor/checks/tools-lsp.ts +++ b/src/cli/doctor/checks/tools-lsp.ts @@ -43,9 +43,5 @@ export function getInstalledLspServers(): Array<{ id: string; extensions: string const lspMcpConfig = createLspMcpConfig() - if (!lspMcpConfig) { - return [] - } - - return [{ id: "lsp-tools-mcp", extensions: ["*"] }] + return lspMcpConfig.enabled ? [{ id: "lsp-tools-mcp", extensions: ["*"] }] : [] } diff --git a/src/mcp/AGENTS.md b/src/mcp/AGENTS.md index 5646329e4..ef27f15f0 100644 --- a/src/mcp/AGENTS.md +++ b/src/mcp/AGENTS.md @@ -13,13 +13,14 @@ Tier 1 of the three-tier MCP system. Built-ins are created by `createBuiltinMcps | **websearch** | remote | `mcp.exa.ai` (default) or `mcp.tavily.com` | `EXA_API_KEY` (optional), `TAVILY_API_KEY` (if tavily) | Web search | | **context7** | remote | `mcp.context7.com/mcp` | `CONTEXT7_API_KEY` (optional) | Library documentation | | **grep_app** | remote | `mcp.grep.app` | None | GitHub code search | -| **lsp** | local (stdio, node) | `node packages/lsp-tools-mcp/dist/cli.js mcp` | `LSP_TOOLS_MCP_PROJECT_CONFIG=.opencode/lsp.json` | `status`, diagnostics, goto definition, references, symbols, prepare_rename, rename | +| **lsp** | local (stdio, node/bun) | `node packages/lsp-tools-mcp/dist/cli.js mcp` or `bun packages/lsp-tools-mcp/src/cli.ts mcp` | `LSP_TOOLS_MCP_PROJECT_CONFIG=.opencode/lsp.json` | `status`, diagnostics, goto definition, references, symbols, prepare_rename, rename | ## SUBMODULE ARCHITECTURE - The local `lsp` MCP is a git submodule at `packages/lsp-tools-mcp/`. - Upstream project: https://github.com/code-yeongyu/lsp-tools-mcp - OMO resolves the CLI path dynamically in `src/mcp/lsp.ts` so both `src/` and `dist/` runtime layouts work. +- `lsp` is registered whenever it is not listed in `disabled_mcps`, even if the CLI artifact has not been built yet. Source checkouts fall back to the Bun source CLI; packaged builds prefer the Node dist CLI. ## THREE-TIER SYSTEM diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 77431d3e5..ad3f588c1 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -35,10 +35,7 @@ export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpen } if (!disabledMcps.includes("lsp")) { - const lspConfig = createLspMcpConfig() - if (lspConfig) { - mcps.lsp = lspConfig - } + mcps.lsp = createLspMcpConfig() } return mcps diff --git a/src/mcp/lsp.test.ts b/src/mcp/lsp.test.ts new file mode 100644 index 000000000..271a39de0 --- /dev/null +++ b/src/mcp/lsp.test.ts @@ -0,0 +1,80 @@ +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 { pathToFileURL } from "node:url" +import { createLspMcpConfig } from "./lsp" + +const temporaryDirectories: string[] = [] + +function createTemporaryDirectory(prefix: string): string { + const directory = mkdtempSync(join(tmpdir(), prefix)) + temporaryDirectories.push(directory) + return directory +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe("createLspMcpConfig", () => { + it("resolves bundled dist cli from module root when cwd is unrelated", () => { + // given + const packageRoot = createTemporaryDirectory("omo-lsp-package-root-") + 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") + 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: unrelatedCwd, + moduleUrl: pathToFileURL(moduleFilePath).href, + }) + + // then + expect(config.command).toEqual(["node", cliPath, "mcp"]) + }) + + it("falls back to bun source cli for source checkouts before build", () => { + // given + 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") + 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") + + // when + const config = createLspMcpConfig({ + cwd: createTemporaryDirectory("omo-lsp-source-cwd-"), + moduleUrl: pathToFileURL(moduleFilePath).href, + }) + + // then + expect(config.command).toEqual(["bun", sourceCliPath, "mcp"]) + }) + + it("still returns a built-in MCP config when the cli has not been built yet", () => { + // given + const packageRoot = createTemporaryDirectory("omo-lsp-missing-root-") + const moduleFilePath = join(packageRoot, "dist", "index.js") + mkdirSync(join(packageRoot, "dist"), { recursive: true }) + + // when + const config = createLspMcpConfig({ + cwd: createTemporaryDirectory("omo-lsp-missing-cwd-"), + moduleUrl: pathToFileURL(moduleFilePath).href, + }) + + // 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") + }) +}) diff --git a/src/mcp/lsp.ts b/src/mcp/lsp.ts index e1f674847..709328296 100644 --- a/src/mcp/lsp.ts +++ b/src/mcp/lsp.ts @@ -1,11 +1,24 @@ import { existsSync } from "node:fs" -import { resolve } from "node:path" +import { dirname, resolve } from "node:path" import { fileURLToPath } from "node:url" const SUBMODULE_REL = "packages/lsp-tools-mcp" -const CLI_REL = "dist/cli.js" +const DIST_CLI_REL = "dist/cli.js" +const SOURCE_CLI_REL = "src/cli.ts" const PROJECT_LSP_CONFIG = ".opencode/lsp.json" +type LspMcpConfigOptions = { + readonly cwd?: string + readonly moduleUrl?: string + readonly exists?: (path: string) => boolean +} + +type LspCommandCandidate = { + readonly command: string[] + readonly path: string + readonly exists: boolean +} + export type LocalMcpConfig = { type: "local" command: string[] @@ -13,11 +26,26 @@ export type LocalMcpConfig = { environment?: Record } -function addCliPathCandidates(startDirectory: string, maxParentDepth: number, target: Set): void { - let currentDirectory = startDirectory +function addAncestorCommandCandidates( + startDirectory: string, + target: LspCommandCandidate[], + seenPaths: Set, + pathExists: (path: string) => boolean, +): void { + let currentDirectory = resolve(startDirectory) - for (let depth = 0; depth <= maxParentDepth; depth += 1) { - target.add(resolve(currentDirectory, SUBMODULE_REL, CLI_REL)) + while (true) { + 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) }) + } + + 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) }) + } const parentDirectory = resolve(currentDirectory, "..") if (parentDirectory === currentDirectory) { @@ -28,32 +56,43 @@ function addCliPathCandidates(startDirectory: string, maxParentDepth: number, ta } } -function resolveLspCliPathCandidates(): string[] { - const candidates = new Set() - +function getModuleDirectory(moduleUrl: string): string | null { try { - const currentFilePath = fileURLToPath(import.meta.url) - const currentDirectory = resolve(currentFilePath, "..") - addCliPathCandidates(currentDirectory, 6, candidates) + return dirname(fileURLToPath(moduleUrl)) } catch { - // ignore and fall through to cwd-based candidates - } - - addCliPathCandidates(process.cwd(), 4, candidates) - - return [...candidates] -} - -export function createLspMcpConfig(): LocalMcpConfig | null { - const cliPath = resolveLspCliPathCandidates().find((candidatePath) => existsSync(candidatePath)) - - if (!cliPath) { return null } +} +function resolveLspCommand(options: LspMcpConfigOptions = {}): string[] { + const pathExists = options.exists ?? existsSync + const candidates: LspCommandCandidate[] = [] + const seenPaths = new Set() + const moduleDirectory = getModuleDirectory(options.moduleUrl ?? import.meta.url) + + if (moduleDirectory) { + addAncestorCommandCandidates(moduleDirectory, candidates, seenPaths, pathExists) + } + + addAncestorCommandCandidates(options.cwd ?? process.cwd(), candidates, seenPaths, pathExists) + + const distCandidate = candidates.find((candidate) => candidate.path.endsWith(DIST_CLI_REL) && candidate.exists) + if (distCandidate) { + return distCandidate.command + } + + const sourceCandidate = candidates.find((candidate) => candidate.path.endsWith(SOURCE_CLI_REL) && candidate.exists) + if (sourceCandidate) { + return sourceCandidate.command + } + + return candidates[0]?.command ?? ["node", resolve(process.cwd(), SUBMODULE_REL, DIST_CLI_REL), "mcp"] +} + +export function createLspMcpConfig(options: LspMcpConfigOptions = {}): LocalMcpConfig { return { type: "local", - command: ["node", cliPath, "mcp"], + command: resolveLspCommand(options), enabled: true, environment: { LSP_TOOLS_MCP_PROJECT_CONFIG: PROJECT_LSP_CONFIG,