feat(codex-lsp): lazy-start mcp backend
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<void>) {}
|
||||
|
||||
async start(): Promise<LazyMcpConnection> {
|
||||
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<void>;
|
||||
|
||||
private resolveClosed: () => void = () => {};
|
||||
|
||||
constructor() {
|
||||
this.closed = new Promise((resolve) => {
|
||||
this.resolveClosed = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
async request(request: JsonRpcRequest): Promise<JsonRpcResponse | undefined> {
|
||||
this.requests.push(request);
|
||||
return { jsonrpc: "2.0", id: request.id ?? null, result: { content: [], isError: false } };
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
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<number, ManualTimer>();
|
||||
|
||||
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<void>; readonly resolve: () => void } {
|
||||
let resolveDeferred: () => void = () => {};
|
||||
const promise = new Promise<void>((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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -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<string> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve("timeout"), delayMs);
|
||||
});
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user