mcp: add lifecycle logs to local servers
This commit is contained in:
@@ -1,11 +1,17 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { argv, stderr } from "node:process";
|
import { argv, stderr } from "node:process";
|
||||||
|
import { writeMcpLifecycleLog } from "./mcp-lifecycle-log";
|
||||||
import { runMcpStdioServer } from "./mcp";
|
import { runMcpStdioServer } from "./mcp";
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const [command = "mcp"] = argv.slice(2);
|
const [command = "mcp"] = argv.slice(2);
|
||||||
if (command === "mcp") {
|
if (command === "mcp") {
|
||||||
await runMcpStdioServer();
|
await runMcpStdioServer(process.stdin, process.stdout, {}, {
|
||||||
|
log: writeMcpLifecycleLog,
|
||||||
|
onIdleTimeout: () => {
|
||||||
|
process.exit(0);
|
||||||
|
},
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stderr.write("Usage: ast-grep-mcp [mcp]\n");
|
stderr.write("Usage: ast-grep-mcp [mcp]\n");
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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-ast-grep-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { createInterface } from "node:readline";
|
||||||
|
|
||||||
|
import type { AstGrepMcpOptions, JsonRpcResponse } from "./mcp";
|
||||||
|
|
||||||
|
export type McpLifecycleLog = (event: string, fields?: Record<string, boolean | number | string | null>) => void;
|
||||||
|
|
||||||
|
export interface McpStdioServerOptions {
|
||||||
|
readonly idleTimeoutMs?: number;
|
||||||
|
readonly onIdleTimeout?: () => void | Promise<void>;
|
||||||
|
readonly log?: McpLifecycleLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type McpRequestHandler = (
|
||||||
|
input: unknown,
|
||||||
|
options: AstGrepMcpOptions,
|
||||||
|
) => Promise<JsonRpcResponse | undefined>;
|
||||||
|
|
||||||
|
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000;
|
||||||
|
const noopLog: McpLifecycleLog = () => {};
|
||||||
|
|
||||||
|
export async function runJsonRpcStdioServer(
|
||||||
|
handler: McpRequestHandler,
|
||||||
|
input: NodeJS.ReadableStream,
|
||||||
|
output: NodeJS.WritableStream,
|
||||||
|
options: AstGrepMcpOptions,
|
||||||
|
stdioOptions: McpStdioServerOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const log = stdioOptions.log ?? noopLog;
|
||||||
|
const idleTimeoutMs = stdioOptions.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
||||||
|
const idleTimer = createIdleTimer(idleTimeoutMs, log, stdioOptions.onIdleTimeout);
|
||||||
|
|
||||||
|
log("stdio_started", { cwd: process.cwd(), idle_timeout_ms: idleTimeoutMs });
|
||||||
|
idleTimer.arm();
|
||||||
|
const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
|
||||||
|
try {
|
||||||
|
for await (const line of lines) {
|
||||||
|
if (idleTimer.closed()) break;
|
||||||
|
idleTimer.arm();
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
const parsed = parseJsonRpcLine(line, output, log);
|
||||||
|
if (parsed === undefined) 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 handler(parsed, options);
|
||||||
|
if (response) {
|
||||||
|
output.write(`${JSON.stringify(response)}\n`);
|
||||||
|
log("response", { id: String(response.id), method, is_error: response.error !== undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
idleTimer.clear();
|
||||||
|
log("stdio_stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIdleTimer(idleTimeoutMs: number, log: McpLifecycleLog, onIdleTimeout?: () => void | Promise<void>) {
|
||||||
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
let isClosed = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
arm: () => {
|
||||||
|
if (timer !== null) clearTimeout(timer);
|
||||||
|
if (idleTimeoutMs <= 0) return;
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
isClosed = true;
|
||||||
|
log("idle_timeout", { idle_timeout_ms: idleTimeoutMs });
|
||||||
|
void onIdleTimeout?.();
|
||||||
|
}, idleTimeoutMs);
|
||||||
|
timer.unref();
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
if (timer === null) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
},
|
||||||
|
closed: () => isClosed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonRpcLine(line: string, output: NodeJS.WritableStream, log: McpLifecycleLog): unknown | undefined {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line);
|
||||||
|
} catch (error) {
|
||||||
|
const message = messageFromError(error);
|
||||||
|
log("parse_error", { message });
|
||||||
|
output.write(`${JSON.stringify(errorResponse(null, -32700, "Parse error", message))}\n`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorResponse(id: string | number | null, code: number, message: string, data?: unknown): JsonRpcResponse {
|
||||||
|
return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonRpcId(value: unknown): string | number | null {
|
||||||
|
return typeof value === "string" || typeof value === "number" || value === null ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageFromError(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it } from "bun:test";
|
|||||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { handleAstGrepMcpRequest } from "./mcp";
|
import { PassThrough } from "node:stream";
|
||||||
|
import { handleAstGrepMcpRequest, runMcpStdioServer } from "./mcp";
|
||||||
import type { RunOptions } from "./runner";
|
import type { RunOptions } from "./runner";
|
||||||
import type { SgResult } from "./types";
|
import type { SgResult } from "./types";
|
||||||
|
|
||||||
@@ -259,4 +260,20 @@ describe("ast-grep MCP", () => {
|
|||||||
expect(searchTool?.description).toContain("This is NOT regex");
|
expect(searchTool?.description).toContain("This is NOT regex");
|
||||||
expect(searchTool?.description).toContain("Meta-variables");
|
expect(searchTool?.description).toContain("Meta-variables");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("#given idle stdio connection #when no request arrives before timeout #then server exits through idle callback", async () => {
|
||||||
|
const input = new PassThrough();
|
||||||
|
const output = new PassThrough();
|
||||||
|
let idleCallCount = 0;
|
||||||
|
|
||||||
|
await runMcpStdioServer(input, output, {}, {
|
||||||
|
idleTimeoutMs: 1,
|
||||||
|
onIdleTimeout: () => {
|
||||||
|
idleCallCount++;
|
||||||
|
input.end();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(idleCallCount).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createInterface } from "node:readline";
|
|
||||||
import { CLI_LANGUAGES } from "./constants";
|
import { CLI_LANGUAGES } from "./constants";
|
||||||
|
import { runJsonRpcStdioServer, type McpStdioServerOptions } from "./mcp-stdio-server";
|
||||||
import { getPatternHint } from "./pattern-hints";
|
import { getPatternHint } from "./pattern-hints";
|
||||||
import { formatReplaceResult, formatSearchResult } from "./result-formatter";
|
import { formatReplaceResult, formatSearchResult } from "./result-formatter";
|
||||||
import { runSg, type RunOptions } from "./runner";
|
import { runSg, type RunOptions } from "./runner";
|
||||||
@@ -119,20 +119,9 @@ export async function runMcpStdioServer(
|
|||||||
input: NodeJS.ReadableStream = process.stdin,
|
input: NodeJS.ReadableStream = process.stdin,
|
||||||
output: NodeJS.WritableStream = process.stdout,
|
output: NodeJS.WritableStream = process.stdout,
|
||||||
options: AstGrepMcpOptions = {},
|
options: AstGrepMcpOptions = {},
|
||||||
|
stdioOptions: McpStdioServerOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
|
await runJsonRpcStdioServer(handleAstGrepMcpRequest, input, output, options, stdioOptions);
|
||||||
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 response = await handleAstGrepMcpRequest(parsed, options);
|
|
||||||
if (response) output.write(`${JSON.stringify(response)}\n`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToolCall(id: JsonRpcId, params: unknown, options: AstGrepMcpOptions): Promise<JsonRpcResponse> {
|
async function handleToolCall(id: JsonRpcId, params: unknown, options: AstGrepMcpOptions): Promise<JsonRpcResponse> {
|
||||||
|
|||||||
+1
-1
Submodule packages/lsp-tools-mcp updated: e7c65b04d0...e3cae69bd6
Reference in New Issue
Block a user