diff --git a/packages/omo-claude/plugin/components/lsp/src/cli.ts b/packages/omo-claude/plugin/components/lsp/src/cli.ts index 9373ad7d9..4a36c20e3 100644 --- a/packages/omo-claude/plugin/components/lsp/src/cli.ts +++ b/packages/omo-claude/plugin/components/lsp/src/cli.ts @@ -2,8 +2,8 @@ import { argv, stderr } from "node:process"; import { disposeDefaultLspManager } from "@code-yeongyu/lsp-tools-mcp/dist/lsp/manager.js"; -import { runMcpStdioServer } from "@code-yeongyu/lsp-tools-mcp/dist/mcp.js"; import { runPostToolUseHookCli } from "./codex-hook.js"; +import { runLazyLspMcpServer } from "./lazy-lsp-mcp.js"; async function main(): Promise { const [command = "mcp", subcommand = ""] = argv.slice(2); @@ -15,7 +15,7 @@ async function main(): Promise { } if (command === "mcp") { - await runMcpStdioServer(); + await runLazyLspMcpServer(); return; } diff --git a/packages/omo-claude/plugin/components/lsp/src/lazy-lsp-mcp.ts b/packages/omo-claude/plugin/components/lsp/src/lazy-lsp-mcp.ts new file mode 100644 index 000000000..b2572c3a9 --- /dev/null +++ b/packages/omo-claude/plugin/components/lsp/src/lazy-lsp-mcp.ts @@ -0,0 +1,71 @@ +import { createRequire } from "node:module"; +import { env, execPath, stderr } from "node:process"; + +import { LSP_MCP_TOOLS } from "@code-yeongyu/lsp-tools-mcp/dist/tools.js"; +import { + createLazyMcpProxy, + DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS, + type LazyMcpBackendProcessConfig, + type McpToolDescriptor, + resolveLazyLspBackendConfig, +} from "./lazy-mcp-proxy.js"; +import { createStdioLazyMcpBackend } from "./lazy-mcp-stdio-backend.js"; +import { type LazyMcpLifecycleLog, runLazyMcpStdioServer } from "./lazy-mcp-stdio-server.js"; + +const require = createRequire(import.meta.url); +const BACKEND_CONFIG_ENV = "CODEX_LSP_LAZY_BACKEND"; +const IDLE_TIMEOUT_ENV = "CODEX_LSP_LAZY_IDLE_TIMEOUT_MS"; + +export interface LazyLspIdleTimeoutResolution { + readonly value: number; + readonly warning?: string; +} + +export async function runLazyLspMcpServer( + input: NodeJS.ReadableStream = process.stdin, + output: NodeJS.WritableStream = process.stdout, +): Promise { + const fallback = defaultLazyLspBackendConfig(); + const resolved = resolveLazyLspBackendConfig(env[BACKEND_CONFIG_ENV], fallback); + if (resolved.warning !== undefined) stderr.write(`${resolved.warning}\n`); + const idleTimeout = resolveLazyLspIdleTimeoutMs(env[IDLE_TIMEOUT_ENV], DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS); + if (idleTimeout.warning !== undefined) stderr.write(`${idleTimeout.warning}\n`); + const log: LazyMcpLifecycleLog = (event, fields = {}) => { + stderr.write(`[codex-lsp lazy-mcp] ${event} ${JSON.stringify(fields)}\n`); + }; + const proxy = createLazyMcpProxy({ + backend: createStdioLazyMcpBackend(resolved.config), + idleTimeoutMs: idleTimeout.value, + log, + serverName: "lsp", + serverVersion: "0.2.0", + toolDescriptors: lspToolDescriptors(), + }); + await runLazyMcpStdioServer(proxy, input, output, { log }); +} + +export function defaultLazyLspBackendConfig(): LazyMcpBackendProcessConfig { + return { + command: execPath, + args: [require.resolve("@code-yeongyu/lsp-tools-mcp/dist/cli.js"), "mcp"], + }; +} + +export function resolveLazyLspIdleTimeoutMs( + rawValue: string | undefined, + fallback: number, +): LazyLspIdleTimeoutResolution { + if (rawValue === undefined || rawValue.trim() === "") return { value: fallback }; + const parsed = Number(rawValue); + if (Number.isInteger(parsed) && parsed >= 0) return { value: parsed }; + return { value: fallback, warning: `Ignoring malformed lazy MCP idle timeout: ${rawValue}` }; +} + +export function lspToolDescriptors(): readonly McpToolDescriptor[] { + return LSP_MCP_TOOLS.map((tool) => ({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + })); +} diff --git a/packages/omo-claude/plugin/components/lsp/src/lazy-mcp-protocol.ts b/packages/omo-claude/plugin/components/lsp/src/lazy-mcp-protocol.ts new file mode 100644 index 000000000..531e3ba16 --- /dev/null +++ b/packages/omo-claude/plugin/components/lsp/src/lazy-mcp-protocol.ts @@ -0,0 +1,78 @@ +export const DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS = 10 * 60_000; + +export type JsonRpcId = string | number | null; +export type LazyMcpTimer = { + unref?: () => void; +}; + +export interface LazyMcpClock { + setTimeout(callback: () => void, delayMs: number): LazyMcpTimer; + clearTimeout(timer: LazyMcpTimer): void; +} + +export interface TextContent { + readonly type: "text"; + readonly text: string; +} + +export interface McpToolDescriptor { + readonly name: string; + readonly title?: string; + readonly description?: string; + readonly inputSchema: unknown; +} + +export interface JsonRpcRequest { + readonly jsonrpc?: "2.0"; + readonly id?: JsonRpcId; + readonly method?: string; + readonly params?: unknown; +} + +export interface JsonRpcError { + readonly code: number; + readonly message: string; + readonly data?: unknown; +} + +export interface JsonRpcResult { + readonly capabilities?: Record; + readonly serverInfo?: Record; + readonly protocolVersion?: string; + readonly tools?: readonly McpToolDescriptor[]; + readonly content?: readonly TextContent[]; + readonly isError?: boolean; + readonly [key: string]: unknown; +} + +export interface JsonRpcResponse { + readonly jsonrpc: "2.0"; + readonly id: JsonRpcId; + readonly result?: JsonRpcResult; + readonly error?: JsonRpcError; +} + +export function successResponse(id: JsonRpcId, result: JsonRpcResult): JsonRpcResponse { + return { jsonrpc: "2.0", id, result }; +} + +export function errorResponse(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcResponse { + return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } }; +} + +export function jsonRpcId(value: unknown): JsonRpcId { + return typeof value === "string" || typeof value === "number" || value === null ? value : null; +} + +export function requestedProtocolVersion(params: unknown): string { + if (!isRecord(params) || typeof params["protocolVersion"] !== "string") return "2024-11-05"; + return params["protocolVersion"]; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function messageFromError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/omo-claude/plugin/components/lsp/src/lazy-mcp-proxy.ts b/packages/omo-claude/plugin/components/lsp/src/lazy-mcp-proxy.ts new file mode 100644 index 000000000..f1c13756c --- /dev/null +++ b/packages/omo-claude/plugin/components/lsp/src/lazy-mcp-proxy.ts @@ -0,0 +1,264 @@ +import { + DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS, + errorResponse, + isRecord, + type JsonRpcId, + type JsonRpcRequest, + type JsonRpcResponse, + jsonRpcId, + type LazyMcpClock, + type LazyMcpTimer, + type McpToolDescriptor, + messageFromError, + requestedProtocolVersion, + successResponse, +} from "./lazy-mcp-protocol.js"; + +export type { + JsonRpcRequest, + JsonRpcResponse, + LazyMcpClock, + LazyMcpTimer, + McpToolDescriptor, +} from "./lazy-mcp-protocol.js"; +export { DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS } from "./lazy-mcp-protocol.js"; + +export interface LazyMcpConnection { + readonly closed: Promise; + request(request: JsonRpcRequest): Promise; + stop(): Promise; +} + +export interface LazyMcpBackend { + start(): Promise; +} + +export interface LazyMcpBackendProcessConfig { + readonly command: string; + readonly args: readonly string[]; + readonly cwd?: string; + readonly env?: Readonly>; +} + +export interface LazyMcpBackendConfigResolution { + readonly config: LazyMcpBackendProcessConfig; + readonly warning?: string; +} + +export interface LazyMcpProxy { + handleRequest(input: unknown): Promise; + stopActiveBackend(): Promise; + hasActiveBackend(): boolean; +} + +export interface LazyMcpProxyOptions { + readonly backend: LazyMcpBackend; + readonly clock?: LazyMcpClock; + readonly idleTimeoutMs?: number; + readonly log?: (event: string, fields?: Record) => void; + readonly serverName?: string; + readonly serverVersion?: string; + readonly toolDescriptors: readonly McpToolDescriptor[]; +} + +const defaultClock: LazyMcpClock = { + setTimeout: (callback, delayMs) => createDefaultTimer(callback, delayMs), + clearTimeout: (timer) => { + if (isDefaultTimer(timer)) clearTimeout(timer.nodeTimer); + }, +}; + +export function createLazyMcpProxy(options: LazyMcpProxyOptions): LazyMcpProxy { + return new LazyMcpProxyState(options); +} + +export function resolveLazyLspBackendConfig( + rawConfig: string | undefined, + fallback: LazyMcpBackendProcessConfig, +): LazyMcpBackendConfigResolution { + if (rawConfig === undefined || rawConfig.trim() === "") return { config: fallback }; + try { + const parsed: unknown = JSON.parse(rawConfig); + if (isBackendProcessConfig(parsed)) return { config: parsed }; + return malformedConfig(fallback, "config shape is invalid"); + } catch (error) { + return malformedConfig(fallback, messageFromError(error)); + } +} + +class LazyMcpProxyState implements LazyMcpProxy { + private activeConnection: LazyMcpConnection | undefined; + private idleTimer: LazyMcpTimer | undefined; + private starting: Promise | undefined; + + private readonly backend: LazyMcpBackend; + private readonly clock: LazyMcpClock; + private readonly idleTimeoutMs: number; + private readonly log: NonNullable; + private readonly serverName: string; + private readonly serverVersion: string; + private readonly toolDescriptors: readonly McpToolDescriptor[]; + + constructor(options: LazyMcpProxyOptions) { + this.backend = options.backend; + this.clock = options.clock ?? defaultClock; + this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS; + this.log = options.log ?? (() => {}); + this.serverName = options.serverName ?? "lsp"; + this.serverVersion = options.serverVersion ?? "0.1.0"; + this.toolDescriptors = options.toolDescriptors; + } + + async handleRequest(input: unknown): Promise { + if (!isRecord(input)) return errorResponse(null, -32600, "Invalid Request"); + const id = jsonRpcId(input["id"]); + const method = input["method"]; + if (method === "notifications/initialized") return undefined; + if (method === "ping") return successResponse(id, {}); + if (method === "initialize") return this.initialize(id, input["params"]); + if (method === "tools/list") return successResponse(id, { tools: this.toolDescriptors }); + if (method === "resources/list") return successResponse(id, { resources: [] }); + if (method === "resources/templates/list") return successResponse(id, { resourceTemplates: [] }); + if (method === "tools/call") return this.handleToolCall(id, input); + return errorResponse(id, -32601, `Method not found: ${String(method)}`); + } + + async stopActiveBackend(): Promise { + this.clearIdleTimer(); + const connection = this.activeConnection; + this.activeConnection = undefined; + if (connection !== undefined) { + await connection.stop(); + this.log("lazy_backend_stopped"); + } + } + + hasActiveBackend(): boolean { + return this.activeConnection !== undefined; + } + + private initialize(id: JsonRpcId, params: unknown): JsonRpcResponse { + return successResponse(id, { + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: this.serverName, version: this.serverVersion }, + protocolVersion: requestedProtocolVersion(params), + }); + } + + private async handleToolCall(id: JsonRpcId, request: Record): Promise { + try { + const connection = await this.getConnection(); + const response = await connection.request({ + jsonrpc: "2.0", + id, + method: "tools/call", + params: request["params"], + }); + this.armIdleTimer(); + return response === undefined + ? errorResponse(id, -32603, "Lazy MCP backend returned no response") + : withId(response, id); + } catch (error) { + return successResponse(id, { + content: [{ type: "text", text: messageFromError(error) }], + isError: true, + }); + } + } + + private async getConnection(): Promise { + if (this.activeConnection !== undefined) return this.activeConnection; + if (this.starting !== undefined) return this.starting; + const starting = this.startBackend(); + this.starting = starting; + return starting; + } + + private async startBackend(): Promise { + try { + this.log("lazy_backend_starting"); + const connection = await this.backend.start(); + await connection.request({ + jsonrpc: "2.0", + id: "lazy-mcp-initialize", + method: "initialize", + params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: this.serverName } }, + }); + this.activeConnection = connection; + this.observeClose(connection); + this.log("lazy_backend_started"); + return connection; + } finally { + this.starting = undefined; + } + } + + private observeClose(connection: LazyMcpConnection): void { + void connection.closed.then( + () => { + if (this.activeConnection !== connection) return; + this.activeConnection = undefined; + this.clearIdleTimer(); + this.log("lazy_backend_stopped"); + }, + (error: unknown) => { + this.log("lazy_backend_close_error", { message: messageFromError(error) }); + }, + ); + } + + private armIdleTimer(): void { + this.clearIdleTimer(); + if (this.idleTimeoutMs <= 0) return; + const timer = this.clock.setTimeout(() => { + this.log("lazy_backend_idle_timeout", { idle_timeout_ms: this.idleTimeoutMs }); + void this.stopActiveBackend().catch((error: unknown) => { + this.log("lazy_backend_idle_stop_error", { message: messageFromError(error) }); + }); + }, this.idleTimeoutMs); + timer.unref?.(); + this.idleTimer = timer; + } + + private clearIdleTimer(): void { + if (this.idleTimer === undefined) return; + this.clock.clearTimeout(this.idleTimer); + this.idleTimer = undefined; + } +} + +function malformedConfig(fallback: LazyMcpBackendProcessConfig, reason: string): LazyMcpBackendConfigResolution { + return { config: fallback, warning: `Ignoring malformed lazy MCP backend config: ${reason}` }; +} + +function isBackendProcessConfig(value: unknown): value is LazyMcpBackendProcessConfig { + if (!isRecord(value) || typeof value["command"] !== "string" || !isStringArray(value["args"])) return false; + const cwd = value["cwd"]; + if (cwd !== undefined && typeof cwd !== "string") return false; + const env = value["env"]; + return env === undefined || (isRecord(env) && Object.values(env).every((entry) => typeof entry === "string")); +} + +function isStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} + +function withId(response: JsonRpcResponse, id: JsonRpcId): JsonRpcResponse { + if (response.id === id) return response; + if (response.error !== undefined) return { jsonrpc: "2.0", id, error: response.error }; + if (response.result !== undefined) return { jsonrpc: "2.0", id, result: response.result }; + return { jsonrpc: "2.0", id }; +} + +interface DefaultTimer extends LazyMcpTimer { + readonly nodeTimer: ReturnType; +} + +function createDefaultTimer(callback: () => void, delayMs: number): DefaultTimer { + const nodeTimer = setTimeout(callback, delayMs); + return { nodeTimer, unref: () => nodeTimer.unref() }; +} + +function isDefaultTimer(timer: LazyMcpTimer): timer is DefaultTimer { + return isRecord(timer) && "nodeTimer" in timer; +} diff --git a/packages/omo-claude/plugin/components/lsp/src/lazy-mcp-stdio-backend.ts b/packages/omo-claude/plugin/components/lsp/src/lazy-mcp-stdio-backend.ts new file mode 100644 index 000000000..8cdcb7253 --- /dev/null +++ b/packages/omo-claude/plugin/components/lsp/src/lazy-mcp-stdio-backend.ts @@ -0,0 +1,158 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { once } from "node:events"; +import { createInterface } from "node:readline"; + +import { + isRecord, + type JsonRpcId, + type JsonRpcRequest, + type JsonRpcResponse, + type JsonRpcResult, + jsonRpcId, + messageFromError, +} from "./lazy-mcp-protocol.js"; +import type { LazyMcpBackend, LazyMcpBackendProcessConfig, LazyMcpConnection } from "./lazy-mcp-proxy.js"; + +const FORCE_KILL_AFTER_MS = 1_000; + +interface PendingRequest { + readonly originalId: JsonRpcId; + readonly reject: (error: Error) => void; + readonly resolve: (response: JsonRpcResponse | undefined) => void; +} + +export function createStdioLazyMcpBackend(config: LazyMcpBackendProcessConfig): LazyMcpBackend { + return { + start: async () => startStdioConnection(config), + }; +} + +async function startStdioConnection(config: LazyMcpBackendProcessConfig): Promise { + const child = spawnBackend(config); + return new StdioLazyMcpConnection(child); +} + +class StdioLazyMcpConnection implements LazyMcpConnection { + readonly closed: Promise; + + private closedState = false; + private nextRequestId = 1; + private readonly pending = new Map(); + + constructor(private readonly child: ChildProcessWithoutNullStreams) { + this.closed = new Promise((resolve) => { + const finish = (error?: Error) => { + if (this.closedState) return; + this.closedState = true; + this.rejectPending(error ?? new Error("Lazy MCP backend exited")); + resolve(); + }; + child.once("exit", () => finish()); + child.once("error", (error) => finish(error)); + }); + this.consumeStdout(); + child.stderr.on("data", (chunk: Buffer) => { + process.stderr.write(chunk); + }); + } + + async request(request: JsonRpcRequest): Promise { + if (this.closedState) throw new Error("Lazy MCP backend is not running"); + const upstreamId = `lazy-${this.nextRequestId}`; + this.nextRequestId++; + const upstreamRequest = { ...request, id: upstreamId }; + const response = new Promise((resolve, reject) => { + this.pending.set(upstreamId, { originalId: request.id ?? null, resolve, reject }); + }); + await this.writeLine(`${JSON.stringify(upstreamRequest)}\n`); + return response; + } + + async stop(): Promise { + if (this.closedState) return; + this.child.kill("SIGTERM"); + const forceKill = setTimeout(() => { + if (!this.closedState) this.child.kill("SIGKILL"); + }, FORCE_KILL_AFTER_MS); + forceKill.unref(); + try { + await this.closed; + } finally { + clearTimeout(forceKill); + } + } + + private consumeStdout(): void { + const lines = createInterface({ input: this.child.stdout, crlfDelay: Number.POSITIVE_INFINITY }); + void (async () => { + try { + for await (const line of lines) { + this.handleLine(line); + } + } catch (error) { + this.rejectPending(new Error(`Lazy MCP backend stdout failed: ${messageFromError(error)}`)); + } + })(); + } + + private handleLine(line: string): void { + if (!line.trim()) return; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + this.rejectPending(new Error(`Lazy MCP backend emitted invalid JSON: ${messageFromError(error)}`)); + return; + } + if (!isRecord(parsed)) return; + const id = jsonRpcId(parsed["id"]); + const pending = id === null ? undefined : this.pending.get(String(id)); + if (pending === undefined) return; + this.pending.delete(String(id)); + pending.resolve(withOriginalId(parsed, pending.originalId)); + } + + private async writeLine(line: string): Promise { + if (this.child.stdin.write(line)) return; + await once(this.child.stdin, "drain"); + } + + private rejectPending(error: Error): void { + for (const pending of this.pending.values()) { + pending.reject(error); + } + this.pending.clear(); + } +} + +function spawnBackend(config: LazyMcpBackendProcessConfig): ChildProcessWithoutNullStreams { + const env = config.env === undefined ? process.env : { ...process.env, ...config.env }; + const stdio: ["pipe", "pipe", "pipe"] = ["pipe", "pipe", "pipe"]; + if (config.cwd === undefined) { + return spawn(config.command, [...config.args], { env, stdio }); + } + return spawn(config.command, [...config.args], { cwd: config.cwd, env, stdio }); +} + +function withOriginalId(value: Record, id: JsonRpcId): JsonRpcResponse | undefined { + const jsonrpc = value["jsonrpc"]; + if (jsonrpc !== "2.0") return undefined; + const result = value["result"]; + const error = value["error"]; + if (isRecord(error) && typeof error["code"] === "number" && typeof error["message"] === "string") { + return { jsonrpc, id, error: optionalErrorData(error) }; + } + return isJsonRpcResult(result) ? { jsonrpc, id, result } : { jsonrpc, id }; +} + +function optionalErrorData(error: Record): { code: number; message: string; data?: unknown } { + const code = error["code"]; + const message = error["message"]; + if (typeof code !== "number" || typeof message !== "string") return { code: -32603, message: "Invalid MCP error" }; + if (!("data" in error)) return { code, message }; + return { code, message, data: error["data"] }; +} + +function isJsonRpcResult(value: unknown): value is JsonRpcResult { + return isRecord(value); +} diff --git a/packages/omo-claude/plugin/components/lsp/src/lazy-mcp-stdio-server.ts b/packages/omo-claude/plugin/components/lsp/src/lazy-mcp-stdio-server.ts new file mode 100644 index 000000000..0b14c960d --- /dev/null +++ b/packages/omo-claude/plugin/components/lsp/src/lazy-mcp-stdio-server.ts @@ -0,0 +1,56 @@ +import { createInterface } from "node:readline"; + +import { errorResponse, isRecord, type JsonRpcResponse, jsonRpcId, messageFromError } from "./lazy-mcp-protocol.js"; +import type { LazyMcpProxy } from "./lazy-mcp-proxy.js"; + +export type LazyMcpLifecycleLog = (event: string, fields?: Record) => void; + +export interface LazyMcpStdioServerOptions { + readonly log?: LazyMcpLifecycleLog; +} + +const noopLog: LazyMcpLifecycleLog = () => {}; + +export async function runLazyMcpStdioServer( + proxy: LazyMcpProxy, + input: NodeJS.ReadableStream = process.stdin, + output: NodeJS.WritableStream = process.stdout, + options: LazyMcpStdioServerOptions = {}, +): Promise { + const log = options.log ?? noopLog; + const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY }); + log("lazy_proxy_stdio_started", { cwd: process.cwd() }); + try { + for await (const line of lines) { + if (!line.trim()) continue; + const response = await handleLine(proxy, line, log); + if (response !== undefined) output.write(`${JSON.stringify(response)}\n`); + } + } finally { + await proxy.stopActiveBackend(); + log("lazy_proxy_stdio_stopped"); + } +} + +async function handleLine( + proxy: LazyMcpProxy, + line: string, + log: LazyMcpLifecycleLog, +): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + const message = messageFromError(error); + log("lazy_proxy_parse_error", { message }); + return errorResponse(null, -32700, "Parse error", message); + } + const id = isRecord(parsed) ? jsonRpcId(parsed["id"]) : null; + const method = isRecord(parsed) && typeof parsed["method"] === "string" ? parsed["method"] : null; + log("lazy_proxy_request", { id: id === null ? null : String(id), method }); + const response = await proxy.handleRequest(parsed); + if (response !== undefined) { + log("lazy_proxy_response", { id: String(response.id), method, is_error: response.error !== undefined }); + } + return response; +}