codex-lsp: add MCP lifecycle logging
This commit is contained in:
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+36
@@ -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<string, LogFieldValue> = {}): 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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, boolean | number | string | null>) => 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<void>;
|
||||
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<JsonRpcResponse | undefined> {
|
||||
if (!isRecord(input)) {
|
||||
@@ -69,20 +78,58 @@ export async function handleLspMcpRequest(input: unknown): Promise<JsonRpcRespon
|
||||
export async function runMcpStdioServer(
|
||||
input: NodeJS.ReadableStream = process.stdin,
|
||||
output: NodeJS.WritableStream = process.stdout,
|
||||
options: McpStdioServerOptions = {},
|
||||
): Promise<void> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user