From 4bf909545618b30b99ff12c92a8934a0b00db7b0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 29 May 2026 12:18:24 +0900 Subject: [PATCH] feat(codex-lsp): lazy-start mcp backend --- packages/omo-codex/plugin/.mcp.json | 2 +- .../omo-codex/plugin/components/lsp/.mcp.json | 2 +- .../plugin/components/lsp/src/cli.ts | 4 +- .../plugin/components/lsp/src/lazy-lsp-mcp.ts | 71 +++++ .../components/lsp/src/lazy-mcp-protocol.ts | 78 +++++ .../components/lsp/src/lazy-mcp-proxy.ts | 264 +++++++++++++++ .../lsp/src/lazy-mcp-stdio-backend.ts | 158 +++++++++ .../lsp/src/lazy-mcp-stdio-server.ts | 56 ++++ .../components/lsp/test/lazy-lsp-mcp.test.ts | 21 ++ .../lsp/test/lazy-mcp-proxy.test.ts | 301 ++++++++++++++++++ .../lsp/test/lazy-mcp-stdio-backend.test.ts | 54 ++++ .../components/lsp/test/package-smoke.test.ts | 4 +- .../omo-codex/plugin/test/aggregate.test.mjs | 4 +- src/cli/install-codex/codex-cache.test.ts | 4 +- 14 files changed, 1013 insertions(+), 10 deletions(-) create mode 100644 packages/omo-codex/plugin/components/lsp/src/lazy-lsp-mcp.ts create mode 100644 packages/omo-codex/plugin/components/lsp/src/lazy-mcp-protocol.ts create mode 100644 packages/omo-codex/plugin/components/lsp/src/lazy-mcp-proxy.ts create mode 100644 packages/omo-codex/plugin/components/lsp/src/lazy-mcp-stdio-backend.ts create mode 100644 packages/omo-codex/plugin/components/lsp/src/lazy-mcp-stdio-server.ts create mode 100644 packages/omo-codex/plugin/components/lsp/test/lazy-lsp-mcp.test.ts create mode 100644 packages/omo-codex/plugin/components/lsp/test/lazy-mcp-proxy.test.ts create mode 100644 packages/omo-codex/plugin/components/lsp/test/lazy-mcp-stdio-backend.test.ts diff --git a/packages/omo-codex/plugin/.mcp.json b/packages/omo-codex/plugin/.mcp.json index b38f76a57..daaf9aa8d 100644 --- a/packages/omo-codex/plugin/.mcp.json +++ b/packages/omo-codex/plugin/.mcp.json @@ -7,7 +7,7 @@ }, "lsp": { "command": "node", - "args": ["../../lsp-tools-mcp/dist/cli.js", "mcp"], + "args": ["./components/lsp/dist/cli.js", "mcp"], "cwd": "." } } diff --git a/packages/omo-codex/plugin/components/lsp/.mcp.json b/packages/omo-codex/plugin/components/lsp/.mcp.json index 46ecc491f..fe9385721 100644 --- a/packages/omo-codex/plugin/components/lsp/.mcp.json +++ b/packages/omo-codex/plugin/components/lsp/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "lsp": { "command": "node", - "args": ["../../../../lsp-tools-mcp/dist/cli.js", "mcp"], + "args": ["./dist/cli.js", "mcp"], "cwd": "." } } diff --git a/packages/omo-codex/plugin/components/lsp/src/cli.ts b/packages/omo-codex/plugin/components/lsp/src/cli.ts index 9373ad7d9..4a36c20e3 100644 --- a/packages/omo-codex/plugin/components/lsp/src/cli.ts +++ b/packages/omo-codex/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-codex/plugin/components/lsp/src/lazy-lsp-mcp.ts b/packages/omo-codex/plugin/components/lsp/src/lazy-lsp-mcp.ts new file mode 100644 index 000000000..b2572c3a9 --- /dev/null +++ b/packages/omo-codex/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-codex/plugin/components/lsp/src/lazy-mcp-protocol.ts b/packages/omo-codex/plugin/components/lsp/src/lazy-mcp-protocol.ts new file mode 100644 index 000000000..531e3ba16 --- /dev/null +++ b/packages/omo-codex/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-codex/plugin/components/lsp/src/lazy-mcp-proxy.ts b/packages/omo-codex/plugin/components/lsp/src/lazy-mcp-proxy.ts new file mode 100644 index 000000000..f1c13756c --- /dev/null +++ b/packages/omo-codex/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-codex/plugin/components/lsp/src/lazy-mcp-stdio-backend.ts b/packages/omo-codex/plugin/components/lsp/src/lazy-mcp-stdio-backend.ts new file mode 100644 index 000000000..8cdcb7253 --- /dev/null +++ b/packages/omo-codex/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-codex/plugin/components/lsp/src/lazy-mcp-stdio-server.ts b/packages/omo-codex/plugin/components/lsp/src/lazy-mcp-stdio-server.ts new file mode 100644 index 000000000..0b14c960d --- /dev/null +++ b/packages/omo-codex/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; +} diff --git a/packages/omo-codex/plugin/components/lsp/test/lazy-lsp-mcp.test.ts b/packages/omo-codex/plugin/components/lsp/test/lazy-lsp-mcp.test.ts new file mode 100644 index 000000000..b2cd3e888 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/test/lazy-lsp-mcp.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { resolveLazyLspIdleTimeoutMs } from "../src/lazy-lsp-mcp.js"; +import { DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS } from "../src/lazy-mcp-proxy.js"; + +describe("lazy LSP MCP config", () => { + it("#given idle timeout env input #when resolving lazy LSP config #then default and malformed values are safe", () => { + // given + const fallback = DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS; + + // when + const missing = resolveLazyLspIdleTimeoutMs(undefined, fallback); + const valid = resolveLazyLspIdleTimeoutMs("25", fallback); + const malformed = resolveLazyLspIdleTimeoutMs("abc", fallback); + + // then + expect(missing).toEqual({ value: fallback }); + expect(valid).toEqual({ value: 25 }); + expect(malformed.value).toBe(fallback); + expect(malformed.warning).toContain("Ignoring malformed lazy MCP idle timeout"); + }); +}); diff --git a/packages/omo-codex/plugin/components/lsp/test/lazy-mcp-proxy.test.ts b/packages/omo-codex/plugin/components/lsp/test/lazy-mcp-proxy.test.ts new file mode 100644 index 000000000..a30c36fce --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/test/lazy-mcp-proxy.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from "vitest"; + +import { + createLazyMcpProxy, + DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS, + type JsonRpcRequest, + type JsonRpcResponse, + type LazyMcpBackend, + type LazyMcpClock, + type LazyMcpConnection, + type LazyMcpTimer, + type McpToolDescriptor, + resolveLazyLspBackendConfig, +} from "../src/lazy-mcp-proxy.js"; + +const toolDescriptors: readonly McpToolDescriptor[] = [ + { + name: "diagnostics", + title: "LSP Diagnostics", + description: "Get diagnostics.", + inputSchema: { type: "object", properties: {} }, + }, +]; + +describe("lazy MCP proxy", () => { + it("#given a lazy proxy #when tools are listed #then the backend MCP server is not started", async () => { + // given + const backend = new RecordingBackend(); + const proxy = createLazyMcpProxy({ + backend, + clock: new ManualClock(), + idleTimeoutMs: DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS, + toolDescriptors, + }); + + // when + const response = await proxy.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" }); + + // then + expect(toolNames(response)).toEqual(["diagnostics"]); + expect(backend.startCalls).toBe(0); + }); + + it("#given a lazy proxy #when the first tool is called #then the backend starts and receives the call", async () => { + // given + const backend = new RecordingBackend(); + const proxy = createLazyMcpProxy({ + backend, + clock: new ManualClock(), + idleTimeoutMs: DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS, + toolDescriptors, + }); + + // when + const response = await proxy.handleRequest({ + jsonrpc: "2.0", + id: "call-1", + method: "tools/call", + params: { name: "diagnostics", arguments: { filePath: "src/cli.ts" } }, + }); + + // then + const connection = backend.connections[0]; + expect(backend.startCalls).toBe(1); + expect(connection?.methods()).toEqual(["initialize", "tools/call"]); + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: "call-1", + result: { isError: false }, + }); + }); + + it("#given concurrent first calls #when the backend is still starting #then only one backend starts", async () => { + // given + const gate = createDeferred(); + const backend = new RecordingBackend(gate.promise); + const proxy = createLazyMcpProxy({ + backend, + clock: new ManualClock(), + idleTimeoutMs: DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS, + toolDescriptors, + }); + + // when + const first = proxy.handleRequest({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "diagnostics", arguments: { filePath: "a.ts" } }, + }); + const second = proxy.handleRequest({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "diagnostics", arguments: { filePath: "b.ts" } }, + }); + + // then + expect(backend.startCalls).toBe(1); + gate.resolve(); + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + expect(backend.startCalls).toBe(1); + expect(backend.connections[0]?.methods()).toEqual(["initialize", "tools/call", "tools/call"]); + }); + + it("#given an active lazy backend #when no tool call arrives for ten minutes #then the backend is stopped", async () => { + // given + const clock = new ManualClock(); + const backend = new RecordingBackend(); + const proxy = createLazyMcpProxy({ + backend, + clock, + idleTimeoutMs: DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS, + toolDescriptors, + }); + await proxy.handleRequest({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "diagnostics", arguments: { filePath: "src/cli.ts" } }, + }); + + // when + clock.advanceBy(DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS - 1); + + // then + expect(backend.connections[0]?.stopCalls).toBe(0); + + // when + clock.advanceBy(1); + + // then + expect(backend.connections[0]?.stopCalls).toBe(1); + }); + + it("#given an active lazy backend #when a later call arrives #then the idle timeout is refreshed", async () => { + // given + const clock = new ManualClock(); + const backend = new RecordingBackend(); + const proxy = createLazyMcpProxy({ + backend, + clock, + idleTimeoutMs: DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS, + toolDescriptors, + }); + await proxy.handleRequest({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "diagnostics", arguments: { filePath: "first.ts" } }, + }); + clock.advanceBy(DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS - 1); + + // when + await proxy.handleRequest({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "diagnostics", arguments: { filePath: "second.ts" } }, + }); + clock.advanceBy(1); + + // then + expect(backend.connections[0]?.stopCalls).toBe(0); + + // when + clock.advanceBy(DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS - 1); + + // then + expect(backend.connections[0]?.stopCalls).toBe(1); + }); + + it("#given malformed optional backend config #when resolving lazy config #then the default backend is preserved", () => { + // given + const fallback = { command: "node", args: ["dist/cli.js", "mcp"], cwd: "/workspace" }; + + // when + const missing = resolveLazyLspBackendConfig(undefined, fallback); + const malformed = resolveLazyLspBackendConfig("{", fallback); + const wrongShape = resolveLazyLspBackendConfig(JSON.stringify({ command: 1, args: "mcp" }), fallback); + + // then + expect(missing).toEqual({ config: fallback }); + expect(malformed.config).toEqual(fallback); + expect(malformed.warning).toContain("Ignoring malformed lazy MCP backend config"); + expect(wrongShape.config).toEqual(fallback); + expect(wrongShape.warning).toContain("Ignoring malformed lazy MCP backend config"); + }); +}); + +class RecordingBackend implements LazyMcpBackend { + startCalls = 0; + readonly connections: RecordingConnection[] = []; + + constructor(private readonly beforeStartCompletes?: Promise) {} + + async start(): Promise { + this.startCalls++; + await this.beforeStartCompletes; + const connection = new RecordingConnection(); + this.connections.push(connection); + return connection; + } +} + +class RecordingConnection implements LazyMcpConnection { + stopCalls = 0; + readonly requests: JsonRpcRequest[] = []; + readonly closed: Promise; + + private resolveClosed: () => void = () => {}; + + constructor() { + this.closed = new Promise((resolve) => { + this.resolveClosed = resolve; + }); + } + + async request(request: JsonRpcRequest): Promise { + this.requests.push(request); + return { jsonrpc: "2.0", id: request.id ?? null, result: { content: [], isError: false } }; + } + + async stop(): Promise { + this.stopCalls++; + this.resolveClosed(); + } + + methods(): string[] { + return this.requests.flatMap((request) => (typeof request.method === "string" ? [request.method] : [])); + } +} + +class ManualClock implements LazyMcpClock { + private nowMs = 0; + private nextId = 1; + private readonly timers = new Map(); + + setTimeout(callback: () => void, delayMs: number): LazyMcpTimer { + const timer = new ManualTimer(this.nextId, this.nowMs + delayMs, callback); + this.nextId++; + this.timers.set(timer.id, timer); + return timer; + } + + clearTimeout(timer: LazyMcpTimer): void { + if (timer instanceof ManualTimer) { + timer.clear(); + this.timers.delete(timer.id); + } + } + + advanceBy(delayMs: number): void { + this.nowMs += delayMs; + const dueTimers = [...this.timers.values()] + .filter((timer) => timer.active && timer.dueAt <= this.nowMs) + .sort((left, right) => left.dueAt - right.dueAt); + for (const timer of dueTimers) { + timer.clear(); + this.timers.delete(timer.id); + timer.callback(); + } + } +} + +class ManualTimer { + active = true; + + constructor( + readonly id: number, + readonly dueAt: number, + readonly callback: () => void, + ) {} + + unref(): void {} + + clear(): void { + this.active = false; + } +} + +function createDeferred(): { readonly promise: Promise; readonly resolve: () => void } { + let resolveDeferred: () => void = () => {}; + const promise = new Promise((resolve) => { + resolveDeferred = resolve; + }); + return { promise, resolve: resolveDeferred }; +} + +function toolNames(response: JsonRpcResponse | undefined): string[] { + const tools = response?.result?.tools; + if (!Array.isArray(tools)) return []; + return tools.flatMap((tool) => { + if (!isRecord(tool) || typeof tool["name"] !== "string") return []; + return [tool["name"]]; + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/lsp/test/lazy-mcp-stdio-backend.test.ts b/packages/omo-codex/plugin/components/lsp/test/lazy-mcp-stdio-backend.test.ts new file mode 100644 index 000000000..00dd693c2 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/test/lazy-mcp-stdio-backend.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { createStdioLazyMcpBackend } from "../src/lazy-mcp-stdio-backend.js"; + +describe("stdio lazy MCP backend", () => { + it("#given backend stdio stays open after parent exit #when stopped #then stop resolves on process exit", async () => { + // given + const backend = createStdioLazyMcpBackend({ + command: process.execPath, + args: ["-e", delayedStdioCloseScript()], + }); + const connection = await backend.start(); + + // when + const result = await Promise.race([connection.stop().then(() => "stopped"), failAfter(300)]); + + // then + expect(result).toBe("stopped"); + }); + + it("#given backend ignores SIGTERM #when stopped #then stop escalates and resolves", async () => { + // given + const backend = createStdioLazyMcpBackend({ + command: process.execPath, + args: ["-e", ignoreSigtermScript()], + }); + const connection = await backend.start(); + + // when + const result = await Promise.race([connection.stop().then(() => "stopped"), failAfter(1_500)]); + + // then + expect(result).toBe("stopped"); + }); +}); + +function delayedStdioCloseScript(): string { + return [ + 'const { spawn } = require("node:child_process");', + 'const grandchild = spawn(process.execPath, ["-e", "setTimeout(() => process.exit(0), 1500); setInterval(() => {}, 1000)"], { stdio: ["ignore", "inherit", "inherit"] });', + 'process.on("SIGTERM", () => process.exit(0));', + "setInterval(() => {}, 1000);", + ].join(""); +} + +function ignoreSigtermScript(): string { + return ['process.on("SIGTERM", () => {});', "setInterval(() => {}, 1000);"].join(""); +} + +function failAfter(delayMs: number): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve("timeout"), delayMs); + }); +} diff --git a/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts b/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts index 079f6ddeb..239572dea 100644 --- a/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts +++ b/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts @@ -49,7 +49,7 @@ function readMcpJson(path: string): McpJson { } describe("plugin package metadata", () => { - it("#given packaged component files #when validating entrypoints #then hook and MCP commands use root LSP tooling", () => { + it("#given packaged component files #when validating entrypoints #then hook and MCP commands use the lazy LSP proxy", () => { // given const packageJson = readPackageJson("package.json"); const hooksJson = readHooksJson("hooks/hooks.json"); @@ -71,7 +71,7 @@ describe("plugin package metadata", () => { expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true); expect(command).toBe(`node "${pluginRoot}/dist/cli.js" hook post-tool-use`); expect(lspServer?.command).toBe("node"); - expect(lspServer?.args).toEqual(["../../../../lsp-tools-mcp/dist/cli.js", "mcp"]); + expect(lspServer?.args).toEqual(["./dist/cli.js", "mcp"]); }); it("#given LSP skill guidance #when validating MCP tool instructions #then tool names are not framed as shell commands", () => { diff --git a/packages/omo-codex/plugin/test/aggregate.test.mjs b/packages/omo-codex/plugin/test/aggregate.test.mjs index d342853d8..e94d4a762 100644 --- a/packages/omo-codex/plugin/test/aggregate.test.mjs +++ b/packages/omo-codex/plugin/test/aggregate.test.mjs @@ -72,7 +72,7 @@ test("#given aggregate OMO plugin is enabled #when hooks are inspected #then ult assert.deepEqual(preToolUseGroups.map((group) => group.matcher), ["^create_goal$"]); }); -test("#given aggregate MCP config #when inspected #then code MCP servers reuse root MCP packages", async () => { +test("#given aggregate MCP config #when inspected #then LSP is lazy while non-lazy code MCPs reuse root packages", async () => { // given const packageJson = await readJson("package.json"); const mcp = await readJson(".mcp.json"); @@ -90,7 +90,7 @@ test("#given aggregate MCP config #when inspected #then code MCP servers reuse r assert.equal(packageJson.workspaces.includes("components/ast-grep/packages/ast-grep-mcp"), false); assert.match(packageJson.scripts.build, /ast-grep-mcp/); assert.equal(lspServer.command, "node"); - assert.deepEqual(lspServer.args, ["../../lsp-tools-mcp/dist/cli.js", "mcp"]); + assert.deepEqual(lspServer.args, ["./components/lsp/dist/cli.js", "mcp"]); assert.equal(lspServer.cwd, "."); assert.equal(astGrepServer.command, "node"); assert.deepEqual(astGrepServer.args, ["../../ast-grep-mcp/dist/cli.js", "mcp"]); diff --git a/src/cli/install-codex/codex-cache.test.ts b/src/cli/install-codex/codex-cache.test.ts index 49744331f..51747ac5a 100644 --- a/src/cli/install-codex/codex-cache.test.ts +++ b/src/cli/install-codex/codex-cache.test.ts @@ -39,7 +39,7 @@ describe("codex-cache", () => { mcpServers: { ast_grep: { cwd: ".", args: ["../../ast-grep-mcp/dist/cli.js", "mcp"] }, custom: { args: ["/usr/local/bin/custom-mcp", "--stdio"] }, - lsp: { cwd: ".", args: ["../../lsp-tools-mcp/dist/cli.js", "mcp"] }, + lsp: { cwd: ".", args: ["./components/lsp/dist/cli.js", "mcp"] }, }, }), ) @@ -60,7 +60,7 @@ describe("codex-cache", () => { expect(rewritten.mcpServers.ast_grep.args[0]).toBe(join(root, "packages", "ast-grep-mcp", "dist", "cli.js")) expect(rewritten.mcpServers.custom.args).toEqual(["/usr/local/bin/custom-mcp", "--stdio"]) expect(rewritten.mcpServers.lsp.cwd).toBeUndefined() - expect(rewritten.mcpServers.lsp.args[0]).toBe(join(root, "packages", "lsp-tools-mcp", "dist", "cli.js")) + expect(rewritten.mcpServers.lsp.args[0]).toBe(join(cacheRoot, "./components/lsp/dist/cli.js")) }) test("rewrites cached package file dependencies that point outside the plugin cache back to the source package", async () => {