UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

603 lines (602 loc) • 27.3 kB
import { l as normalizeOptionalString } from "./string-coerce-6kR-86XC.mjs"; import { s as runWithTrackedCancellation, t as AsyncWorkScope } from "./async-work-scope-BYeX3gh6.mjs"; import { c as isRecord } from "./record-coerce-TIsquoVR.mjs"; import { n as isTruthyEnvValue } from "./env-Bw1K_ti_.mjs"; import { t as formatErrorMessage } from "./errors-BSdPMFao.mjs"; import { n as createHttpRequestAbortSignal, o as sendHttpRequestRejection } from "./http-request-lifecycle-DO9mb9Gu.mjs"; import { a as isRequestBodyLimitError, s as readRequestBodyWithLimit } from "./http-body-CZtbU96N.mjs"; import { t as runOutsidePluginRuntimeGenerationScope } from "./generation-scope-CmJ9AjVM.mjs"; import { n as isAutomationsToolName } from "./automations-tool-name-DBMZPbPL.mjs"; import { r as getRuntimeConfig } from "./io.runtime-FunTy0y1.mjs"; import "./io-DQAKeHvj.mjs"; import { n as normalizeMessageChannel } from "./message-channel-core-JDeu-bNU.mjs"; import "./message-channel-BCUrZuO3.mjs"; import { i as logWarn, t as logDebug } from "./logger-CYgsNcie.mjs"; import { y as runOutsideGatewayRootWorkAdmission } from "./gateway-work-admission-G8XukyHo.mjs"; import { a as resolveMainSessionKey } from "./main-session-C5LOaIBF.mjs"; import { f as readAdmittedRunOperatorAuthority } from "./admitted-run-context-CbCp9bj_.mjs"; import { a as isAgentHarnessSessionKey, r as AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE, s as isAgentHarnessSessionStoreEntryProtected } from "./agent-harness-session-key-B7mhXqKs.mjs"; import { a as isLoopbackAddress } from "./net-BD0odJBF.mjs"; import "./session-accessor-BM3Wa1Bx.mjs"; import { a as resolveSessionEntryAccessTarget } from "./session-accessor.entry-BoDZTg0e.mjs"; import { t as acknowledgeInternalToolResult } from "./internal-hooks-DUPhyX-W.mjs"; import { c as withGatewayToolCallerIdentity, n as createAdmittedGatewayToolCallerIdentity } from "./gateway-caller-context-BeMzkOlX.mjs"; import "./sessions-nJdQddS3.mjs"; import { t as safeEqualSecret } from "./secret-equal-DRsL8lKD.mjs"; import { d as withAgentQuestionAnswerAuthority } from "./host-private-capabilities-D8EEgJHq.mjs"; import { n as getHeader } from "./http-header-value-DZ4LmuvM.mjs"; import { t as checkBrowserOrigin } from "./origin-check-BJTlyAy9.mjs"; import { t as resolveToolLoopDetectionConfig } from "./tool-loop-detection-config-CJgf3EZ5.mjs"; import { c as markMcpLoopbackRequestFinished, d as markMcpLoopbackToolCallStarted, f as recordMcpLoopbackToolCallResult, h as updateMcpLoopbackToolCallCapture, l as markMcpLoopbackRequestStarted, m as setActiveMcpLoopbackRuntime, n as clearActiveMcpLoopbackRuntimeByOwnerToken, o as getActiveMcpLoopbackRuntime, p as resolveMcpLoopbackYieldContext, s as markMcpLoopbackRequestClassified, u as markMcpLoopbackToolCallFinished } from "./mcp-http.loopback-runtime-BkzqlAkB.mjs"; import { c as resolveMcpLoopbackClientGrant, f as revokeMcpLoopbackClientGrantsForRuntime, o as registerMcpLoopbackClientGrantRevocationListener, s as resolveAttachGrant } from "./mcp-grant-store-Chv2y9y7.mjs"; import { i as jsonRpcError } from "./mcp-http.protocol-DlFNaqVL.mjs"; import crypto from "node:crypto"; import { createServer } from "node:http"; //#region src/gateway/mcp-http.request.ts const DEFAULT_MCP_BODY_TIMEOUT_MS = 3e4; function readPositiveIntEnv(name, fallback) { const raw = process.env[name]?.trim(); if (!raw) return fallback; const parsed = Number(raw); if (!/^\d+$/u.test(raw) || !Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer. Got: ${JSON.stringify(raw)}`); return parsed; } function logMcpLoopbackTraffic(step, details) { if (!isTruthyEnvValue(process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT) && !isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_DEBUG)) return; console.error(`[mcp-loopback] ${step} ${JSON.stringify(details)}`); } function resolveScopedSessionKey(cfg, rawSessionKey) { const trimmed = normalizeOptionalString(rawSessionKey); return !trimmed || trimmed === "main" ? resolveMainSessionKey(cfg) : trimmed; } function normalizeMcpInboundEventKind(value) { const trimmed = normalizeOptionalString(value); return trimmed === "room_event" || trimmed === "user_request" ? trimmed : void 0; } function normalizeMcpSourceReplyDeliveryMode(value) { const trimmed = normalizeOptionalString(value); return trimmed === "automatic" || trimmed === "message_tool_only" ? trimmed : void 0; } function normalizeMcpTaskSuggestionDeliveryMode(value) { return normalizeOptionalString(value) === "gateway" ? "gateway" : void 0; } function normalizeMcpBooleanHeader(value) { const trimmed = normalizeOptionalString(value); return trimmed ? isTruthyEnvValue(trimmed) : void 0; } function rejectsBrowserLoopbackRequest(req) { const origin = getHeader(req, "origin"); if (!origin) return false; return !checkBrowserOrigin({ requestHost: getHeader(req, "host"), origin, isLocalClient: isLoopbackAddress(req.socket?.remoteAddress) }).ok; } function resolveMcpSender(params) { const authHeader = getHeader(params.req, "authorization") ?? ""; const ownerTokenMatched = safeEqualSecret(authHeader, `Bearer ${params.ownerToken}`); const nonOwnerTokenMatched = safeEqualSecret(authHeader, `Bearer ${params.nonOwnerToken}`); if (ownerTokenMatched || nonOwnerTokenMatched) return { senderIsOwner: ownerTokenMatched }; const grantToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""; const captureKey = normalizeOptionalString(getHeader(params.req, "x-openclaw-cli-capture-key")); const clientGrant = grantToken && captureKey ? resolveMcpLoopbackClientGrant({ token: grantToken, runtimeOwnerToken: params.ownerToken, captureKey }) : void 0; if (clientGrant) return { senderIsOwner: clientGrant.context.senderIsOwner, boundClientGrant: clientGrant, boundGrantToken: grantToken }; const grant = grantToken ? resolveAttachGrant(grantToken) : void 0; if (grant) return { senderIsOwner: false, boundSessionKey: grant.sessionKey, ...grant.agentId ? { boundAgentId: grant.agentId } : {} }; } function validateMcpLoopbackRequest(params) { let url; try { url = new URL(params.req.url ?? "/", `http://${params.req.headers.host ?? "localhost"}`); } catch { logMcpLoopbackTraffic("reject", { reason: "bad_request_url", method: params.req.method ?? "" }); params.res.writeHead(400, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "bad_request" })); return null; } if (params.req.method === "GET" && url.pathname.startsWith("/.well-known/")) { params.res.writeHead(404); params.res.end(); return null; } if (url.pathname !== "/mcp") { logMcpLoopbackTraffic("reject", { reason: "not_found", method: params.req.method ?? "", path: url.pathname }); params.res.writeHead(404, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "not_found" })); return null; } if (params.req.method !== "GET" && params.req.method !== "DELETE" && params.req.method !== "POST") { logMcpLoopbackTraffic("reject", { reason: "method_not_allowed", method: params.req.method ?? "", path: url.pathname }); params.res.writeHead(405, { Allow: "GET, POST, DELETE" }); params.res.end(); return null; } if (rejectsBrowserLoopbackRequest(params.req)) { if (params.req.method === "POST") logMcpLoopbackTraffic("reject", { reason: "forbidden_origin", method: params.req.method, origin: getHeader(params.req, "origin") ?? "" }); params.res.writeHead(403, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "forbidden" })); return null; } const sender = resolveMcpSender(params); if (!sender) { if (params.req.method === "POST") logMcpLoopbackTraffic("reject", { reason: "unauthorized", method: params.req.method, hasAuthorization: (getHeader(params.req, "authorization") ?? "").length > 0 }); params.res.writeHead(401, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "unauthorized" })); return null; } if (params.req.method === "GET") { logMcpLoopbackTraffic("sse-open", { method: "GET", path: url.pathname }); params.res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }); params.res.flushHeaders(); params.res.write(":\n\n"); params.onSseResponse?.(params.res); params.req.on("close", () => { if (!params.res.writableEnded) params.res.end(); }); return null; } if (params.req.method === "DELETE") { logMcpLoopbackTraffic("session-delete", { method: "DELETE", path: url.pathname }); params.res.writeHead(200, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ ok: true })); return null; } const contentType = getHeader(params.req, "content-type") ?? ""; if (!contentType.startsWith("application/json")) { logMcpLoopbackTraffic("reject", { reason: "unsupported_media_type", method: params.req.method ?? "", contentType }); params.res.writeHead(415, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "unsupported_media_type" })); return null; } return sender; } function resolveMcpHttpBodyTimeoutMs() { return readPositiveIntEnv("OPENCLAW_MCP_LOOPBACK_BODY_TIMEOUT_MS", DEFAULT_MCP_BODY_TIMEOUT_MS); } function resolveMcpCliCaptureKey(req, auth) { if (auth.boundClientGrant || auth.boundSessionKey) return auth.boundClientGrant?.captureKey; return normalizeOptionalString(getHeader(req, "x-openclaw-cli-capture-key")); } function normalizeMcpClientCapsHeader(value) { const clientCaps = [...new Set((value ?? "").split(",").map((cap) => cap.trim()))].filter(Boolean); return clientCaps.length > 0 ? clientCaps : void 0; } function resolveMcpRequestContext(req, cfg, auth) { if (auth.boundClientGrant) return structuredClone(auth.boundClientGrant.context); if (auth.boundSessionKey) return { sessionKey: auth.boundSessionKey, agentId: auth.boundAgentId, sessionId: void 0, messageProvider: void 0, clientCaps: void 0, currentChannelId: void 0, currentThreadTs: void 0, currentMessageId: void 0, currentInboundAudio: void 0, accountId: void 0, inboundEventKind: void 0, sourceReplyDeliveryMode: void 0, taskSuggestionDeliveryMode: void 0, requireExplicitMessageTarget: void 0, senderIsOwner: auth.senderIsOwner }; return { sessionKey: resolveScopedSessionKey(cfg, getHeader(req, "x-session-key")), sessionId: normalizeOptionalString(getHeader(req, "x-openclaw-session-id")), messageProvider: normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? void 0, clientCaps: normalizeMcpClientCapsHeader(getHeader(req, "x-openclaw-client-caps")), currentChannelId: normalizeOptionalString(getHeader(req, "x-openclaw-current-channel-id")), currentThreadTs: normalizeOptionalString(getHeader(req, "x-openclaw-current-thread-ts")), currentMessageId: normalizeOptionalString(getHeader(req, "x-openclaw-current-message-id")), currentInboundAudio: normalizeMcpBooleanHeader(getHeader(req, "x-openclaw-current-inbound-audio")), accountId: normalizeOptionalString(getHeader(req, "x-openclaw-account-id")), inboundEventKind: normalizeMcpInboundEventKind(getHeader(req, "x-openclaw-inbound-event-kind")), sourceReplyDeliveryMode: normalizeMcpSourceReplyDeliveryMode(getHeader(req, "x-openclaw-source-reply-delivery-mode")), taskSuggestionDeliveryMode: normalizeMcpTaskSuggestionDeliveryMode(getHeader(req, "x-openclaw-task-suggestion-delivery-mode")), requireExplicitMessageTarget: normalizeMcpBooleanHeader(getHeader(req, "x-openclaw-require-explicit-message-target")), senderIsOwner: auth.senderIsOwner }; } //#endregion //#region src/gateway/mcp-http.ts const MAX_MCP_BODY_BYTES = 1048576; const MCP_HTTP_KEEPALIVE_MS = 15e3; function keepMcpResponseAlive(res, contentType, frame) { const timer = setInterval(() => { if (res.destroyed || res.writableEnded || res.writableNeedDrain) return; if (!res.headersSent) res.writeHead(200, { "Content-Type": contentType }); res.write(frame); }, MCP_HTTP_KEEPALIVE_MS); timer.unref(); const stop = () => { clearInterval(timer); res.off("close", stop); res.off("finish", stop); }; res.once("close", stop); res.once("finish", stop); return stop; } let closeActiveMcpLoopbackServer; let activeMcpLoopbackServerPromise = null; function parseMcpJsonBody(body) { try { return JSON.parse(body); } catch (error) { throw Object.assign(/* @__PURE__ */ new Error("MCP JSON parse error"), { cause: error, code: "mcp_json_parse_error" }); } } function readJsonRpcRequestId(message) { if (!isRecord(message)) return null; const id = message.id; return typeof id === "string" || typeof id === "number" || id === null ? id : void 0; } function isJsonRpcRequest(message) { return isRecord(message) && message.jsonrpc === "2.0" && typeof message.method === "string"; } function shouldSendJsonRpcResponse(message) { return !isJsonRpcRequest(message) || Object.hasOwn(message, "id"); } function collectJsonRpcResponses(messages, createResponse) { return messages.filter(shouldSendJsonRpcResponse).map(createResponse); } function jsonRpcInternalError(parsed) { const isBatch = Array.isArray(parsed); const responses = collectJsonRpcResponses(isBatch ? parsed : [parsed], (message) => jsonRpcError(readJsonRpcRequestId(message), -32603, "Internal error")); if (responses.length === 0) return null; return isBatch ? responses : responses[0]; } /** Starts a new MCP loopback HTTP server and registers its bearer tokens. */ async function startMcpLoopbackServer(port, work) { const [{ handleMcpJsonRpc }, { McpLoopbackToolCache }] = await Promise.all([import("./mcp-http.handlers-Cl8R5QCz.mjs"), import("./mcp-http.runtime.js")]); const ownerToken = crypto.randomBytes(32).toString("hex"); const nonOwnerToken = crypto.randomBytes(32).toString("hex"); const toolCache = new McpLoopbackToolCache(); const activeSseResponses = /* @__PURE__ */ new Set(); const trackSseResponse = (res) => { activeSseResponses.add(res); keepMcpResponseAlive(res, "text/event-stream", ":\n\n"); const cleanup = () => { activeSseResponses.delete(res); res.off("close", cleanup); res.off("finish", cleanup); }; res.once("close", cleanup); res.once("finish", cleanup); }; const closeActiveSseResponses = () => { for (const res of activeSseResponses) if (!res.destroyed && !res.writableEnded) { const socket = res.socket; res.end(); socket?.end(); } }; const httpServer = createServer((req, res) => { const auth = validateMcpLoopbackRequest({ req, res, ownerToken, nonOwnerToken, onSseResponse: trackSseResponse }); if (!auth) return; const cliCaptureKey = resolveMcpCliCaptureKey(req, auth); const cliRequestCaptureHandle = markMcpLoopbackRequestStarted(cliCaptureKey); const requestAbort = createHttpRequestAbortSignal(req, res); work.track(async () => { let parsed; let stopKeepalive; let cliCaptureHandles = []; try { parsed = parseMcpJsonBody(await readRequestBodyWithLimit(req, { maxBytes: MAX_MCP_BODY_BYTES, timeoutMs: resolveMcpHttpBodyTimeoutMs(), destroyOnLimit: false })); if (Array.isArray(parsed) && parsed.length === 0) { markMcpLoopbackRequestClassified(cliRequestCaptureHandle); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(jsonRpcError(null, -32600, "Invalid Request"))); return; } const messages = Array.isArray(parsed) ? parsed : [parsed]; cliCaptureHandles = messages.map((message) => { if (!cliRequestCaptureHandle || !isJsonRpcRequest(message) || message.method !== "tools/call") return; const admittedToolName = isRecord(message.params) && typeof message.params.name === "string" ? message.params.name : ""; const toolArgs = isRecord(message.params) && isRecord(message.params.arguments) ? message.params.arguments : {}; return markMcpLoopbackToolCallStarted({ requestCaptureHandle: cliRequestCaptureHandle, toolName: admittedToolName, args: toolArgs }); }); markMcpLoopbackRequestClassified(cliRequestCaptureHandle); const { boundGrantToken, boundClientGrant } = auth; if (boundClientGrant && !boundClientGrant.isCurrent()) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; } const cfg = getRuntimeConfig(); const requestContext = resolveMcpRequestContext(req, cfg, auth); const authorizeToolCall = () => !work.isClosing && getActiveMcpLoopbackRuntime()?.ownerToken === ownerToken && (boundClientGrant?.isCurrent() ?? true); const harnessEntry = isAgentHarnessSessionKey(requestContext.sessionKey) ? resolveSessionEntryAccessTarget({ cfg, sessionKey: requestContext.sessionKey }).entry : void 0; if (isAgentHarnessSessionKey(requestContext.sessionKey) && (!harnessEntry || isAgentHarnessSessionStoreEntryProtected(requestContext.sessionKey, harnessEntry))) { const errors = collectJsonRpcResponses(messages, (message) => jsonRpcError(readJsonRpcRequestId(message), -32600, AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE)); if (errors.length === 0) { res.writeHead(202); res.end(); return; } const payload = Array.isArray(parsed) ? JSON.stringify(errors) : JSON.stringify(errors[0]); res.writeHead(200, { "Content-Type": "application/json" }); res.end(payload); return; } const yieldContext = resolveMcpLoopbackYieldContext(cliRequestCaptureHandle); const scopedTools = await withAgentQuestionAnswerAuthority(boundClientGrant?.questionAnswerAuthority, () => toolCache.resolve({ context: requestContext, sessionControlAuthority: readAdmittedRunOperatorAuthority(boundClientGrant?.admittedRunContext), rootedExecution: boundClientGrant?.rootedExecution, messageActionTurnCapability: boundClientGrant?.messageActionTurnCapability, cfg, signal: requestAbort.signal, ...boundClientGrant?.toolAuth ? { authProfileStore: boundClientGrant.toolAuth.store, ...boundClientGrant.toolAuth.agentDir ? { authProfileStoreAgentDir: boundClientGrant.toolAuth.agentDir } : {} } : {}, ...boundGrantToken ? { grantToken: boundGrantToken } : {}, isGrantCurrent: authorizeToolCall, yieldContextCacheKey: yieldContext?.cacheKey, onYield: yieldContext?.onYield, ...boundClientGrant?.skillLibraryAuthoring ? { skillLibraryAuthoring: boundClientGrant.skillLibraryAuthoring } : {} })); requestAbort.signal.throwIfAborted(); if (boundClientGrant && !boundClientGrant.isCurrent()) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; } logMcpLoopbackTraffic("request", { batchSize: messages.length, methods: messages.map((message) => isJsonRpcRequest(message) ? message.method : void 0), sessionKey: requestContext.sessionKey, inboundEventKind: requestContext.inboundEventKind, senderIsOwner: requestContext.senderIsOwner, toolCount: scopedTools.toolSchema.length, cronVisible: scopedTools.toolSchema.some((tool) => isAutomationsToolName(tool.name)) }); if (messages.some(shouldSendJsonRpcResponse)) stopKeepalive = keepMcpResponseAlive(res, "application/json", "\n"); const responses = []; for (const [messageIndex, message] of messages.entries()) { if (!isJsonRpcRequest(message)) { responses.push(jsonRpcError(readJsonRpcRequestId(message), -32600, "Invalid Request")); continue; } if (message.method === "tools/call" && requestContext.nativeCronCreatorToolAllowlist === null) { if (shouldSendJsonRpcResponse(message)) responses.push(jsonRpcError(readJsonRpcRequestId(message), -32e3, "Native tool authority is not initialized. Retry after native startup, or start a fresh session; no tool action was taken.")); continue; } const cliCaptureHandle = cliCaptureHandles[messageIndex]; let response; try { const handleRequest = async (signal) => await handleMcpJsonRpc({ message, tools: scopedTools.tools, toolSchema: scopedTools.toolSchema, hookContext: { agentId: scopedTools.agentId, config: cfg, ...scopedTools.workspaceDir ? { workspaceDir: scopedTools.workspaceDir } : {}, sessionKey: requestContext.sessionKey, sessionId: requestContext.sessionId, runId: requestContext.runId, approvalReviewerDeviceId: requestContext.approvalReviewerDeviceId, channelId: requestContext.currentChannelId, turnSourceChannel: requestContext.messageProvider, turnSourceTo: requestContext.currentChannelId, turnSourceAccountId: requestContext.accountId, turnSourceThreadId: requestContext.currentThreadTs, loopDetection: resolveToolLoopDetectionConfig({ cfg, agentId: scopedTools.agentId }) }, signal, authorizeToolCall, onToolCallPrepared: cliCaptureHandle ? ({ toolName: preparedToolName, args }) => { updateMcpLoopbackToolCallCapture(cliCaptureHandle, { toolName: preparedToolName, args }); } : void 0, onToolCallResult: cliCaptureHandle ? (result) => { recordMcpLoopbackToolCallResult({ captureHandle: cliCaptureHandle, ...result }); } : void 0 }); const callerIdentity = boundClientGrant ? createAdmittedGatewayToolCallerIdentity({ admittedRunContext: boundClientGrant.admittedRunContext, receiptAuthority: boundClientGrant.isCurrent, cronAuthorityCheck: boundClientGrant.cronAuthorityCheck, mintCronRequesterGrant: boundClientGrant.mintCronRequesterGrant, agentId: scopedTools.agentId, sessionKey: requestContext.sessionKey, turnSourceChannel: requestContext.messageProvider, turnSourceLocal: !requestContext.messageProvider && requestContext.cronCreatorCallerOrigin?.kind === "local" ? true : void 0, turnSourceTo: requestContext.currentChannelId, turnSourceAccountId: requestContext.accountId, turnSourceThreadId: requestContext.currentThreadTs }) : void 0; response = await withGatewayToolCallerIdentity(callerIdentity, () => runWithTrackedCancellation(requestAbort.signal, handleRequest)); } finally { markMcpLoopbackToolCallFinished(cliCaptureHandle); } if (response !== null && shouldSendJsonRpcResponse(message)) { const responseToolName = message.method === "tools/call" && isRecord(message.params) ? message.params.name : void 0; const isError = isRecord(response) && isRecord(response.result) && response.result.isError === true; logMcpLoopbackTraffic("response", { method: message.method, toolName: typeof responseToolName === "string" ? responseToolName : void 0, 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]); if (!res.headersSent) res.writeHead(200, { "Content-Type": "application/json" }); res.end(payload, () => { if (res.writableFinished) responses.forEach(acknowledgeInternalToolResult); }); } catch (error) { const message = isRequestBodyLimitError(error) ? { PAYLOAD_TOO_LARGE: `Request body exceeds ${MAX_MCP_BODY_BYTES} bytes`, REQUEST_BODY_TIMEOUT: "Request body timed out", CONNECTION_CLOSED: "Request body connection closed" }[error.code] : formatErrorMessage(error); logWarn(`mcp-loopback: request handling failed: ${message}`); logMcpLoopbackTraffic("request-failed", { message }); if (res.headersSent && !res.destroyed && !res.writableEnded) res.end(JSON.stringify(jsonRpcInternalError(parsed))); else if (!res.headersSent) { if (isRequestBodyLimitError(error, "PAYLOAD_TOO_LARGE") || isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT")) { const tooLarge = error.code === "PAYLOAD_TOO_LARGE"; sendHttpRequestRejection(req, res, tooLarge ? 413 : 408, JSON.stringify({ error: tooLarge ? "payload_too_large" : "request_body_timeout" }), "application/json"); } else if (isRecord(error) && error.code === "mcp_json_parse_error") { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify(jsonRpcError(null, -32700, "Parse error"))); } else { const internalError = jsonRpcInternalError(parsed); if (internalError === null) { res.writeHead(202); res.end(); } else { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify(internalError)); } } } } finally { stopKeepalive?.(); requestAbort.cleanup(); for (const captureHandle of cliCaptureHandles) markMcpLoopbackToolCallFinished(captureHandle); markMcpLoopbackRequestFinished(cliRequestCaptureHandle); } }); }); 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"); const unregisterGrantRevocation = registerMcpLoopbackClientGrantRevocationListener((event) => { if (event.runtimeOwnerToken === ownerToken) toolCache.evictGrant(event.token); }); setActiveMcpLoopbackRuntime({ port: address.port, ownerToken, nonOwnerToken }); logDebug(`mcp loopback listening on 127.0.0.1:${address.port}`); return async () => { clearActiveMcpLoopbackRuntimeByOwnerToken(ownerToken); revokeMcpLoopbackClientGrantsForRuntime(ownerToken); unregisterGrantRevocation(); toolCache.clear(); try { await new Promise((resolve, reject) => { httpServer.close((error) => error ? reject(error) : resolve()); closeActiveSseResponses(); }); } finally { await work.drain(); } }; } /** Waits for the process-owned MCP loopback server, starting one if needed. */ async function ensureMcpLoopbackServer(port = 0) { if (closeActiveMcpLoopbackServer) return; if (!activeMcpLoopbackServerPromise) { const work = new AsyncWorkScope(); activeMcpLoopbackServerPromise = runOutsidePluginRuntimeGenerationScope(() => runOutsideGatewayRootWorkAdmission(() => work.run(() => startMcpLoopbackServer(port, work)))).then((close) => { closeActiveMcpLoopbackServer = close; }).catch(async (error) => { await work.drain(); throw error; }).finally(() => { activeMcpLoopbackServerPromise = null; }); } return activeMcpLoopbackServerPromise; } /** Closes the active MCP loopback server if one has been started. */ async function closeMcpLoopbackServer() { if (activeMcpLoopbackServerPromise) await activeMcpLoopbackServerPromise; const close = closeActiveMcpLoopbackServer; closeActiveMcpLoopbackServer = void 0; await close?.(); } //#endregion export { ensureMcpLoopbackServer as n, closeMcpLoopbackServer as t };