feat(codex-lsp): lazy-start mcp backend
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"lsp": {
|
||||
"command": "node",
|
||||
"args": ["../../lsp-tools-mcp/dist/cli.js", "mcp"],
|
||||
"args": ["./components/lsp/dist/cli.js", "mcp"],
|
||||
"cwd": "."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"mcpServers": {
|
||||
"lsp": {
|
||||
"command": "node",
|
||||
"args": ["../../../../lsp-tools-mcp/dist/cli.js", "mcp"],
|
||||
"args": ["./dist/cli.js", "mcp"],
|
||||
"cwd": "."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
const [command = "mcp", subcommand = ""] = argv.slice(2);
|
||||
@@ -15,7 +15,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
if (command === "mcp") {
|
||||
await runMcpStdioServer();
|
||||
await runLazyLspMcpServer();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
readonly serverInfo?: Record<string, unknown>;
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function messageFromError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -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<void>;
|
||||
request(request: JsonRpcRequest): Promise<JsonRpcResponse | undefined>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LazyMcpBackend {
|
||||
start(): Promise<LazyMcpConnection>;
|
||||
}
|
||||
|
||||
export interface LazyMcpBackendProcessConfig {
|
||||
readonly command: string;
|
||||
readonly args: readonly string[];
|
||||
readonly cwd?: string;
|
||||
readonly env?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface LazyMcpBackendConfigResolution {
|
||||
readonly config: LazyMcpBackendProcessConfig;
|
||||
readonly warning?: string;
|
||||
}
|
||||
|
||||
export interface LazyMcpProxy {
|
||||
handleRequest(input: unknown): Promise<JsonRpcResponse | undefined>;
|
||||
stopActiveBackend(): Promise<void>;
|
||||
hasActiveBackend(): boolean;
|
||||
}
|
||||
|
||||
export interface LazyMcpProxyOptions {
|
||||
readonly backend: LazyMcpBackend;
|
||||
readonly clock?: LazyMcpClock;
|
||||
readonly idleTimeoutMs?: number;
|
||||
readonly log?: (event: string, fields?: Record<string, string | number | boolean | null>) => 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<LazyMcpConnection> | undefined;
|
||||
|
||||
private readonly backend: LazyMcpBackend;
|
||||
private readonly clock: LazyMcpClock;
|
||||
private readonly idleTimeoutMs: number;
|
||||
private readonly log: NonNullable<LazyMcpProxyOptions["log"]>;
|
||||
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<JsonRpcResponse | undefined> {
|
||||
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<void> {
|
||||
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<string, unknown>): Promise<JsonRpcResponse> {
|
||||
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<LazyMcpConnection> {
|
||||
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<LazyMcpConnection> {
|
||||
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<typeof setTimeout>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<LazyMcpConnection> {
|
||||
const child = spawnBackend(config);
|
||||
return new StdioLazyMcpConnection(child);
|
||||
}
|
||||
|
||||
class StdioLazyMcpConnection implements LazyMcpConnection {
|
||||
readonly closed: Promise<void>;
|
||||
|
||||
private closedState = false;
|
||||
private nextRequestId = 1;
|
||||
private readonly pending = new Map<string, PendingRequest>();
|
||||
|
||||
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<JsonRpcResponse | undefined> {
|
||||
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<JsonRpcResponse | undefined>((resolve, reject) => {
|
||||
this.pending.set(upstreamId, { originalId: request.id ?? null, resolve, reject });
|
||||
});
|
||||
await this.writeLine(`${JSON.stringify(upstreamRequest)}\n`);
|
||||
return response;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
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<void> {
|
||||
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<string, unknown>, 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<string, unknown>): { 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);
|
||||
}
|
||||
@@ -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<string, string | number | boolean | null>) => 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<void> {
|
||||
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<JsonRpcResponse | undefined> {
|
||||
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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user