From 7fbe4e2b4a195c9096fc6b21de5b11b217fcf60d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 19:09:10 +0900 Subject: [PATCH] feat(packages): add mcp-stdio-server.ts --- packages/ast-grep-mcp/src/mcp-stdio-server.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 packages/ast-grep-mcp/src/mcp-stdio-server.ts diff --git a/packages/ast-grep-mcp/src/mcp-stdio-server.ts b/packages/ast-grep-mcp/src/mcp-stdio-server.ts new file mode 100644 index 000000000..bed63e4fa --- /dev/null +++ b/packages/ast-grep-mcp/src/mcp-stdio-server.ts @@ -0,0 +1,94 @@ +import type { Readable, Writable } from "node:stream"; + +import type { AstGrepMcpOptions, JsonRpcResponse } from "./mcp"; +import { readStdioJsonRpcMessages, writeStdioJsonRpcResponse } from "./mcp-stdio-transport"; + +export type McpLifecycleLog = (event: string, fields?: Record) => void; + +export interface McpStdioServerOptions { + readonly idleTimeoutMs?: number; + readonly onIdleTimeout?: () => void | Promise; + readonly log?: McpLifecycleLog; +} + +export type McpRequestHandler = ( + input: unknown, + options: AstGrepMcpOptions, +) => Promise; + +const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000; +const noopLog: McpLifecycleLog = () => {}; + +export async function runJsonRpcStdioServer( + handler: McpRequestHandler, + input: Readable, + output: Writable, + options: AstGrepMcpOptions, + stdioOptions: McpStdioServerOptions = {}, +): Promise { + 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(); + try { + for await (const message of readStdioJsonRpcMessages(input)) { + if (idleTimer.closed()) break; + idleTimer.arm(); + if (message.kind === "parse_error") { + log("parse_error", { message: message.message }); + writeStdioJsonRpcResponse(output, errorResponse(null, -32700, "Parse error", message.message), message.responseMode); + continue; + } + const parsed = message.payload; + 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) { + writeStdioJsonRpcResponse(output, response, message.responseMode); + 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) { + 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 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +}