/** * MCP Server implementation for GNO. * Exposes search, retrieval, and status tools over stdio transport. * * @module src/mcp/server */ // node:path for join/dirname (no Bun path utils) import { dirname, join } from "node:path"; import { DEFAULT_INDEX_NAME, MCP_SERVER_NAME, VERSION, getIndexDbPath, } from "../app/constants"; import { canonicalizeIndexName } from "../app/index-name"; import { JobManager } from "../core/job-manager"; import { envIsSet } from "../llm/policy"; import { MCP_ACTIVATION_VERIFICATION_ENV } from "./activation-verification-mode"; import { createToolContext, Mutex, type ToolContext } from "./context"; import { serveMcpStdio } from "./stdio-serving"; import { DEFAULT_MCP_TOOL_PROFILE, type McpToolProfile } from "./tool-profile"; export type { ToolContext } from "./context"; // ───────────────────────────────────────────────────────────────────────────── // Server Options // ───────────────────────────────────────────────────────────────────────────── export interface McpServerOptions { indexName?: string; configPath?: string; verbose?: boolean; enableWrite?: boolean; /** Advertised tool set; defaults to `full`. */ toolProfile?: McpToolProfile; } // ───────────────────────────────────────────────────────────────────────────── // Server Lifecycle // ───────────────────────────────────────────────────────────────────────────── export async function startMcpServer(options: McpServerOptions): Promise { // ======================================== // STDOUT PURITY GUARD (CRITICAL) // ======================================== // Wrap stdout to catch accidental writes during init const originalStdoutWrite = process.stdout.write.bind(process.stdout); let protocolMode = false; // Stdout wrapper - redirect to stderr during init // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- overloaded write signature (process.stdout as any).write = ( chunk: string | Uint8Array, encodingOrCb?: BufferEncoding | ((err?: Error | null) => void), cb?: (err?: Error | null) => void ): boolean => { if (!protocolMode) { // During init, redirect to stderr if (typeof encodingOrCb === "function") { return process.stderr.write(chunk, encodingOrCb); } return process.stderr.write(chunk, encodingOrCb, cb); } // After transport connected, allow JSON-RPC only if (typeof encodingOrCb === "function") { return originalStdoutWrite(chunk, encodingOrCb); } return originalStdoutWrite(chunk, encodingOrCb, cb); }; // Lazy import to avoid pulling in all deps on --help const { initStore } = await import("../cli/commands/shared.js"); // Open DB once with index/config threading const activationVerification = envIsSet( process.env, MCP_ACTIVATION_VERIFICATION_ENV ); const init = await initStore({ indexName: canonicalizeIndexName(options.indexName ?? DEFAULT_INDEX_NAME), configPath: options.configPath, syncConfig: !activationVerification, }); if (!init.ok) { console.error("Failed to initialize:", init.error); process.exit(1); } const { store, config, actualConfigPath } = init; // Sequential execution mutex const toolMutex = new Mutex(); // Server instance ID (per-process) const serverInstanceId = crypto.randomUUID(); const enableWrite = activationVerification ? false : (options.enableWrite ?? envIsSet(process.env, "GNO_MCP_ENABLE_WRITE")); const dbPath = getIndexDbPath(options.indexName); const writeLockPath = join(dirname(dbPath), ".mcp-write.lock"); const jobManager = new JobManager({ lockPath: writeLockPath, serverInstanceId, toolMutex, }); // Shutdown state let shuttingDown = false; // Tool context (passed to all handlers) let currentConfig = config; const ctx: ToolContext = createToolContext({ store, getConfig: () => currentConfig, setConfig: (nextConfig) => { currentConfig = nextConfig; }, actualConfigPath, indexName: canonicalizeIndexName(options.indexName ?? DEFAULT_INDEX_NAME), toolMutex, jobManager, serverInstanceId, writeLockPath, enableWrite, toolProfile: options.toolProfile, isShuttingDown: () => shuttingDown, }); const serverIdentity = { name: MCP_SERVER_NAME, version: VERSION }; let stdioHandle: { close(): Promise } | undefined; if (options.verbose) { console.error( `[MCP] Loaded ${ctx.collections.length} collections from ${ctx.actualConfigPath}` ); } // ======================================== // GRACEFUL SHUTDOWN (ordered) // ======================================== const shutdown = async () => { if (shuttingDown) { return; } shuttingDown = true; console.error("[MCP] Shutting down..."); // 1. Wait for current handler (no timeout - correctness over speed) // If we timeout and close DB while tool is running, we risk corruption const release = await toolMutex.acquire(); release(); // 2. Wait for background jobs before closing DB await jobManager.shutdown(); // 3. Close MCP server/transport (flush buffers, clean disconnect) try { await stdioHandle?.close(); } catch { // Best-effort - server may already be closed } await ctx.disposeModels?.(); // 4. Close DB (safe now - no tool or job is running) await store.close(); // 5. Exit process.exit(0); }; process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); // ======================================== // CONSOLE REDIRECT (CRITICAL for stdout purity) // ======================================== // Redirect console.log/info/debug/warn to stderr to prevent JSON-RPC corruption // Save originals (prefixed with _ to indicate intentionally unused) const _origLog = console.log; const _origInfo = console.info; const _origDebug = console.debug; const _origWarn = console.warn; console.log = (...args: unknown[]) => console.error("[log]", ...args); console.info = (...args: unknown[]) => console.error("[info]", ...args); console.debug = (...args: unknown[]) => console.error("[debug]", ...args); console.warn = (...args: unknown[]) => console.error("[warn]", ...args); // Connect transport (dual-era: 2025-11-25 initialize or 2026-07-28 discover) protocolMode = true; // Enable stdout for JSON-RPC stdioHandle = serveMcpStdio(ctx, serverIdentity, { onerror: (error) => { if (options.verbose) console.error("[MCP] stdio:", error.message); }, }); console.error( `[MCP] ${MCP_SERVER_NAME} v${VERSION} ready on stdio (tool profile: ${options.toolProfile ?? DEFAULT_MCP_TOOL_PROFILE})` ); // Block forever until shutdown signal or stdin closes // This prevents the CLI from exiting after startMcpServer() returns await new Promise((resolve) => { process.stdin.on("end", () => { console.error("[MCP] stdin ended"); resolve(); }); process.stdin.on("close", () => { console.error("[MCP] stdin closed"); resolve(); }); // Also resolve on SIGTERM/SIGINT (already handled by shutdown()) }); await shutdown(); }