From 94b94f26d68a27571d5a7cd4174efcda2600eedf Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 27 May 2026 15:14:15 +0900 Subject: [PATCH] codex-lsp: add MCP lifecycle logging --- .../lsp/packages/lsp-tools-mcp/src/cli.ts | 9 ++- .../lsp-tools-mcp/src/mcp-lifecycle-log.ts | 36 ++++++++++ .../lsp/packages/lsp-tools-mcp/src/mcp.ts | 71 +++++++++++++++---- .../packages/lsp-tools-mcp/test/mcp.test.ts | 23 +++++- 4 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp-lifecycle-log.ts diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/cli.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/cli.ts index bdc97b15f..c0dc203d4 100644 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/cli.ts +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/cli.ts @@ -3,13 +3,20 @@ import { argv, stderr } from "node:process"; import { disposeDefaultLspManager } from "./lsp/manager.js"; import { runMcpStdioServer } from "./mcp.js"; +import { writeMcpLifecycleLog } from "./mcp-lifecycle-log.js"; async function main(): Promise { const [command = "mcp"] = argv.slice(2); try { if (command === "mcp") { - await runMcpStdioServer(); + await runMcpStdioServer(process.stdin, process.stdout, { + log: writeMcpLifecycleLog, + onIdleTimeout: async () => { + await disposeDefaultLspManager(); + process.exit(0); + }, + }); return; } diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp-lifecycle-log.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp-lifecycle-log.ts new file mode 100644 index 000000000..54101b7b0 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp-lifecycle-log.ts @@ -0,0 +1,36 @@ +import { appendFileSync, renameSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +type LogFieldValue = boolean | number | string | null; + +const LOG_FILE_NAME = "omo-codex-lsp-mcp.log"; +const MAX_LOG_BYTES = 5 * 1024 * 1024; + +export function mcpLifecycleLogPath(): string { + return join(tmpdir(), LOG_FILE_NAME); +} + +export function writeMcpLifecycleLog(event: string, fields: Record = {}): void { + const path = mcpLifecycleLogPath(); + try { + rotateLogIfNeeded(path); + appendFileSync( + path, + `${JSON.stringify({ ts: new Date().toISOString(), event, pid: process.pid, ppid: process.ppid, ...fields })}\n`, + ); + } catch (error) { + if (error instanceof Error) return; + return; + } +} + +function rotateLogIfNeeded(path: string): void { + try { + if (statSync(path).size < MAX_LOG_BYTES) return; + renameSync(path, `${path}.1`); + } catch (error) { + if (error instanceof Error) return; + return; + } +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp.ts index 1c22f1eec..68fd9993a 100644 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp.ts +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp.ts @@ -3,6 +3,7 @@ import { createInterface } from "node:readline"; import { coerceToolArguments, executeLspTool, LSP_MCP_TOOLS, type TextContent } from "./tools.js"; export type JsonRpcId = string | number | null; +export type McpLifecycleLog = (event: string, fields?: Record) => void; export interface McpToolDescriptor { name: string; @@ -34,8 +35,16 @@ export interface JsonRpcResponse { error?: JsonRpcError; } +export interface McpStdioServerOptions { + readonly idleTimeoutMs?: number; + readonly onIdleTimeout?: () => void | Promise; + readonly log?: McpLifecycleLog; +} + const SERVER_NAME = "lsp"; const SERVER_VERSION = "0.1.0"; +const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000; +const noopLog: McpLifecycleLog = () => {}; export async function handleLspMcpRequest(input: unknown): Promise { if (!isRecord(input)) { @@ -69,20 +78,58 @@ export async function handleLspMcpRequest(input: unknown): Promise { - const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY }); - for await (const line of lines) { - if (!line.trim()) continue; - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch (error) { - output.write(`${JSON.stringify(errorResponse(null, -32700, "Parse error", messageFromError(error)))}\n`); - continue; - } + const log = options.log ?? noopLog; + const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + let idleTimer: NodeJS.Timeout | null = null; + let closed = false; - const response = await handleLspMcpRequest(parsed); - if (response) output.write(`${JSON.stringify(response)}\n`); + const clearIdleTimer = () => { + if (idleTimer === null) return; + clearTimeout(idleTimer); + idleTimer = null; + }; + const armIdleTimer = () => { + clearIdleTimer(); + if (idleTimeoutMs <= 0) return; + idleTimer = setTimeout(() => { + closed = true; + log("idle_timeout", { idle_timeout_ms: idleTimeoutMs }); + void options.onIdleTimeout?.(); + }, idleTimeoutMs); + idleTimer.unref(); + }; + + log("stdio_started", { cwd: process.cwd(), idle_timeout_ms: idleTimeoutMs }); + armIdleTimer(); + const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY }); + try { + for await (const line of lines) { + if (closed) break; + armIdleTimer(); + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + log("parse_error", { message: messageFromError(error) }); + output.write(`${JSON.stringify(errorResponse(null, -32700, "Parse error", messageFromError(error)))}\n`); + continue; + } + + const id = isRecord(parsed) ? jsonRpcId(parsed["id"]) : null; + const method = isRecord(parsed) && typeof parsed["method"] === "string" ? parsed["method"] : null; + log("request", { id: id === null ? null : String(id), method }); + const response = await handleLspMcpRequest(parsed); + if (response) { + output.write(`${JSON.stringify(response)}\n`); + log("response", { id: String(response.id), method, is_error: response.error !== undefined }); + } + } + } finally { + clearIdleTimer(); + log("stdio_stopped"); } } diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/mcp.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/mcp.test.ts index 61f380a7b..fce1bf577 100644 --- a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/mcp.test.ts +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/mcp.test.ts @@ -1,6 +1,8 @@ +import { PassThrough } from "node:stream"; + import { describe, expect, it } from "vitest"; -import { handleLspMcpRequest } from "../src/mcp.js"; +import { handleLspMcpRequest, runMcpStdioServer } from "../src/mcp.js"; describe("lsp MCP server", () => { it("responds to initialize with tool capabilities", async () => { @@ -79,4 +81,23 @@ describe("lsp MCP server", () => { }); expect(response?.result?.content?.[0]?.text).toContain("Configured LSP servers"); }); + + it("#given idle stdio connection #when no request arrives before timeout #then server exits through idle callback", async () => { + // given + const input = new PassThrough(); + const output = new PassThrough(); + let idleCallCount = 0; + + // when + await runMcpStdioServer(input, output, { + idleTimeoutMs: 1, + onIdleTimeout: () => { + idleCallCount++; + input.end(); + }, + }); + + // then + expect(idleCallCount).toBe(1); + }); });