import crypto from "node:crypto"; import { createServer as createHttpServer, type IncomingMessage, type ServerResponse, } from "node:http"; import { getRuntimeConfig } from "../config/io.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { formatErrorMessage } from "../infra/errors.js"; import { logDebug, logWarn } from "../logger.js"; import { handleMcpJsonRpc } from "./mcp-http.handlers.js"; import { clearActiveMcpLoopbackRuntimeByOwnerToken, setActiveMcpLoopbackRuntime, } from "./mcp-http.loopback-runtime.js"; import { jsonRpcError, type JsonRpcRequest } from "./mcp-http.protocol.js"; import { readMcpHttpBody, resolveMcpRequestContext, validateMcpLoopbackRequest, } from "./mcp-http.request.js"; import { McpLoopbackToolCache } from "./mcp-http.runtime.js"; export { createMcpLoopbackServerConfig, getActiveMcpLoopbackRuntime, resolveMcpLoopbackBearerToken, } from "./mcp-http.loopback-runtime.js"; type McpLoopbackServer = { port: number; close: () => Promise; }; let activeMcpLoopbackServer: McpLoopbackServer | undefined; let activeMcpLoopbackServerPromise: Promise | null = null; function shouldLogMcpLoopbackTraffic(): boolean { return ( isTruthyEnvValue(process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_DEBUG) ); } function logMcpLoopbackTraffic(step: string, details: Record): void { if (!shouldLogMcpLoopbackTraffic()) { return; } console.error(`[mcp-loopback] ${step} ${JSON.stringify(details)}`); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function createRequestAbortSignal(req: IncomingMessage, res: ServerResponse) { const controller = new AbortController(); const abort = () => { if (!controller.signal.aborted) { controller.abort(); } }; const abortIfRequestIncomplete = () => { if (!req.complete) { abort(); } }; const abortIfResponseStillOpen = () => { if (!res.writableEnded) { abort(); } }; req.once("close", abortIfRequestIncomplete); res.once("close", abortIfResponseStillOpen); if (req.destroyed && !req.complete) { abort(); } return { signal: controller.signal, cleanup: () => { req.off("close", abortIfRequestIncomplete); res.off("close", abortIfResponseStillOpen); }, }; } export async function startMcpLoopbackServer(port = 0): Promise<{ port: number; close: () => Promise; }> { const ownerToken = crypto.randomBytes(32).toString("hex"); const nonOwnerToken = crypto.randomBytes(32).toString("hex"); const toolCache = new McpLoopbackToolCache(); const httpServer = createHttpServer((req, res) => { const auth = validateMcpLoopbackRequest({ req, res, ownerToken, nonOwnerToken }); if (!auth) { return; } const requestAbort = createRequestAbortSignal(req, res); void (async () => { try { const body = await readMcpHttpBody(req); const parsed: JsonRpcRequest | JsonRpcRequest[] = JSON.parse(body); const cfg = getRuntimeConfig(); const requestContext = resolveMcpRequestContext(req, cfg, auth); const scopedTools = toolCache.resolve({ cfg, sessionKey: requestContext.sessionKey, messageProvider: requestContext.messageProvider, accountId: requestContext.accountId, inboundTurnKind: requestContext.inboundTurnKind, senderIsOwner: requestContext.senderIsOwner, }); const messages = Array.isArray(parsed) ? parsed : [parsed]; logMcpLoopbackTraffic("request", { batchSize: messages.length, methods: messages.map((message) => message.method), sessionKey: requestContext.sessionKey, inboundTurnKind: requestContext.inboundTurnKind, senderIsOwner: requestContext.senderIsOwner, toolCount: scopedTools.toolSchema.length, cronVisible: scopedTools.toolSchema.some((tool) => tool.name === "cron"), }); const responses: object[] = []; for (const message of messages) { const response = await handleMcpJsonRpc({ message, tools: scopedTools.tools, toolSchema: scopedTools.toolSchema, hookContext: { agentId: scopedTools.agentId, config: cfg, sessionKey: requestContext.sessionKey, }, signal: requestAbort.signal, }); if (response !== null) { const toolName = message.method === "tools/call" && isRecord(message.params) ? message.params.name : undefined; const isError = isRecord(response) && isRecord(response.result) && response.result.isError === true; logMcpLoopbackTraffic("response", { method: message.method, toolName: typeof toolName === "string" ? toolName : undefined, isError, }); responses.push(response); } } if (responses.length === 0) { res.writeHead(202); res.end(); return; } const payload = Array.isArray(parsed) ? JSON.stringify(responses) : JSON.stringify(responses[0]); res.writeHead(200, { "Content-Type": "application/json" }); res.end(payload); } catch (error) { logWarn(`mcp loopback: request handling failed: ${formatErrorMessage(error)}`); logMcpLoopbackTraffic("request-failed", { message: formatErrorMessage(error), }); if (!res.headersSent) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify(jsonRpcError(null, -32700, "Parse error"))); } } finally { requestAbort.cleanup(); } })(); }); await new Promise((resolve, reject) => { httpServer.once("error", reject); httpServer.listen(port, "127.0.0.1", () => { httpServer.removeListener("error", reject); resolve(); }); }); const address = httpServer.address(); if (!address || typeof address === "string") { throw new Error("mcp loopback did not bind to a TCP port"); } setActiveMcpLoopbackRuntime({ port: address.port, ownerToken, nonOwnerToken }); logDebug(`mcp loopback listening on 127.0.0.1:${address.port}`); const server: McpLoopbackServer = { port: address.port, close: () => new Promise((resolve, reject) => { httpServer.close((error) => { if (!error) { clearActiveMcpLoopbackRuntimeByOwnerToken(ownerToken); if (activeMcpLoopbackServer === server) { activeMcpLoopbackServer = undefined; } } if (error) { reject(error); return; } resolve(); }); }), }; return server; } export async function ensureMcpLoopbackServer(port = 0): Promise { if (activeMcpLoopbackServer) { return activeMcpLoopbackServer; } if (!activeMcpLoopbackServerPromise) { activeMcpLoopbackServerPromise = startMcpLoopbackServer(port) .then((server) => { activeMcpLoopbackServer = server; return server; }) .finally(() => { activeMcpLoopbackServerPromise = null; }); } return activeMcpLoopbackServerPromise; } export async function closeMcpLoopbackServer(): Promise { const server = activeMcpLoopbackServer ?? (activeMcpLoopbackServerPromise ? await activeMcpLoopbackServerPromise : undefined); if (!server) { return; } activeMcpLoopbackServer = undefined; await server.close(); }