UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

247 lines (246 loc) 11.7 kB
import { a as asOptionalRecord } from "./record-coerce-DItp3I4t.js"; import { t as createLazyRuntimeMethod } from "./lazy-runtime-CgCh8H_K.js"; import { n as normalizeAgentId } from "./agent-id-CeT3w4ap.js"; import { O as parseAgentSessionKey } from "./session-key-BnWWjqNc.js"; import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import { i as logWarn } from "./logger-DwECwNVZ.js"; import { n as clearMcpAppModelContextForView, o as getSessionMcpRequestSignal } from "./mcp-app-model-context-dSmaJhyr.js"; import { o as normalizeSandboxHostCsp, r as buildSandboxHostPath, s as resolveSandboxHostPort } from "./sandbox-host-BttkEpCR.js"; import { randomUUID } from "node:crypto"; //#region src/agents/mcp-app-sandbox.ts const normalizeMcpAppCsp = normalizeSandboxHostCsp; const buildMcpAppSandboxPath = buildSandboxHostPath; const resolveMcpAppSandboxPort = resolveSandboxHostPort; //#endregion //#region src/agents/mcp-ui-resource.ts const completeDeferredSessionMcpRuntimeRetirement = createLazyRuntimeMethod(() => import("./agents/agent-bundle-mcp-manager-api.js"), (runtime) => runtime.completeDeferredSessionMcpRuntimeRetirement); const MCP_APP_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; const MCP_APP_RESOURCE_MAX_BYTES = 2097152; const MCP_APP_VIEW_TTL_MS = 6e5; const MCP_APP_VIEW_MAX_ENTRIES = 32; const MCP_APP_VIEW_MAX_BYTES = 6291456; const MCP_APP_VIEW_STORE_MAX_BYTES = 67108864; const MCP_APP_VIEW_STORE_KEY = Symbol.for("openclaw.mcpAppViewStore"); /** Retain only the bounded view identity needed for late channel materialization. */ function readMcpAppChannelView(result) { const details = asOptionalRecord(asOptionalRecord(result)?.details); const preview = asOptionalRecord(details?.mcpAppPreview); const view = asOptionalRecord(preview?.view); const descriptor = asOptionalRecord(preview?.mcpApp); const viewId = typeof descriptor?.viewId === "string" ? descriptor.viewId.trim() : ""; const projectedViewId = typeof view?.id === "string" ? view.id.trim() : ""; if (!viewId || projectedViewId !== viewId) return; return { viewId }; } function getViewStore() { const globalStore = globalThis; const existing = globalStore[MCP_APP_VIEW_STORE_KEY]; if (existing) return existing; const store = /* @__PURE__ */ new Map(); globalStore[MCP_APP_VIEW_STORE_KEY] = store; return store; } function deleteView(viewId, expected) { const store = getViewStore(); const view = store.get(viewId); if (!view || expected && view !== expected) return; clearTimeout(view.expiryTimer); clearMcpAppModelContextForView(view.runtime, view); view.releaseRuntimeLease?.(); store.delete(viewId); completeDeferredSessionMcpRuntimeRetirement(view.runtime).catch((error) => { logWarn(`mcp-app: deferred runtime cleanup failed: ${formatErrorMessage(error)}`); }); } function pruneViewStore(additionalBytes = 0, options) { const store = getViewStore(); const nowMs = options?.nowMs ?? Date.now(); for (const [viewId, view] of store) if (view.expiresAtMs <= nowMs) deleteView(viewId, view); let totalBytes = Array.from(store.values()).reduce((sum, view) => sum + (view.byteSize ?? 0), 0); while (store.size + (options?.reserveEntry ? 1 : 0) > MCP_APP_VIEW_MAX_ENTRIES || totalBytes + additionalBytes > MCP_APP_VIEW_STORE_MAX_BYTES) { const oldest = store.keys().next().value; if (oldest === void 0) return; const evicted = store.get(oldest); totalBytes -= evicted?.byteSize ?? 0; if (evicted) deleteView(oldest, evicted); } } function measureViewBytes(html, toolInput, toolResult) { const toolData = JSON.stringify({ toolInput, toolResult }); const byteSize = Buffer.byteLength(html, "utf8") + Buffer.byteLength(toolData, "utf8"); if (byteSize > MCP_APP_VIEW_MAX_BYTES) throw new Error(`MCP App view data exceeds ${MCP_APP_VIEW_MAX_BYTES} bytes`); return byteSize; } function assertBoundedViewDescriptor(value) { if (value.viewId && (value.viewId.length > 128 || !value.viewId.startsWith("mcp-app-")) || !value.serverName || value.serverName.length > 256 || !value.toolName || value.toolName.length > 256 || !value.uiResourceUri.startsWith("ui://") || value.uiResourceUri.length > 2048 || value.toolCallId !== void 0 && value.toolCallId.length > 512) throw new Error("MCP App preview descriptor exceeds safe limits"); } function normalizePermissions(value) { const record = asOptionalRecord(value); if (!record) return; const permissions = {}; for (const key of [ "camera", "clipboardWrite", "geolocation", "microphone" ]) if (asOptionalRecord(record[key])) permissions[key] = {}; return Object.keys(permissions).length > 0 ? permissions : void 0; } function decodeResourceHtml(content) { if (typeof content.text === "string") { if (Buffer.byteLength(content.text, "utf8") > MCP_APP_RESOURCE_MAX_BYTES) throw new Error(`MCP App resource exceeds ${MCP_APP_RESOURCE_MAX_BYTES} bytes`); return content.text; } if (typeof content.blob !== "string") throw new Error("MCP App resource must provide text or base64 blob content"); const maxEncodedBytes = Math.ceil(MCP_APP_RESOURCE_MAX_BYTES / 3) * 4 + 4; if (content.blob.length > maxEncodedBytes) throw new Error(`MCP App resource exceeds ${MCP_APP_RESOURCE_MAX_BYTES} bytes`); const decoded = Buffer.from(content.blob, "base64"); if (decoded.byteLength > MCP_APP_RESOURCE_MAX_BYTES) throw new Error(`MCP App resource exceeds ${MCP_APP_RESOURCE_MAX_BYTES} bytes`); return decoded.toString("utf8"); } async function resolveListingUiMeta(runtime, serverName, uri) { try { const listed = await runtime.listResources?.(serverName, { failureBackoff: "ignore" }); const { _meta: metadata } = (Array.isArray(listed) ? listed : Array.isArray(asOptionalRecord(listed)?.resources) ? asOptionalRecord(listed)?.resources : []).map(asOptionalRecord).find((entry) => entry?.uri === uri) ?? {}; return asOptionalRecord(asOptionalRecord(metadata)?.ui); } catch (error) { logWarn(`mcp-app: failed to read optional listing metadata for ${uri} from "${serverName}": ${formatErrorMessage(error)}`); return; } } async function fetchMcpAppView(params) { let releaseRuntimeLease; try { assertBoundedViewDescriptor(params); const agentId = params.agentId ? normalizeAgentId(params.agentId) : parseAgentSessionKey(params.runtime.sessionKey)?.agentId; if (!agentId) throw new Error("MCP App view requires a resolved session owner"); if (!params.runtime.readResource || !params.uiResourceUri.startsWith("ui://")) return; const result = asOptionalRecord(await params.runtime.readResource(params.serverName, params.uiResourceUri, { failureBackoff: "ignore" })); const contents = Array.isArray(result?.contents) ? result.contents : []; if (contents.length !== 1) throw new Error(`expected one MCP App resource, received ${contents.length}`); const content = asOptionalRecord(contents[0]); if (!content || content.mimeType !== MCP_APP_RESOURCE_MIME_TYPE) throw new Error(`resource must use ${MCP_APP_RESOURCE_MIME_TYPE}`); const html = decodeResourceHtml(content); const byteSize = measureViewBytes(html, params.toolInput, params.toolResult); const { _meta: metadata, meta: deprecatedMetadata } = content; const contentUiMeta = asOptionalRecord(asOptionalRecord(metadata ?? deprecatedMetadata)?.ui); const listingUiMeta = contentUiMeta ? void 0 : await resolveListingUiMeta(params.runtime, params.serverName, params.uiResourceUri); getSessionMcpRequestSignal()?.throwIfAborted(); const uiMeta = contentUiMeta ?? listingUiMeta; const csp = normalizeMcpAppCsp(uiMeta?.csp); const permissions = normalizePermissions(uiMeta?.permissions); const title = `${params.toolName} UI`; const viewId = params.viewId ?? `mcp-app-${randomUUID()}`; releaseRuntimeLease = params.runtime.acquireLease?.(); deleteView(viewId); pruneViewStore(byteSize, { reserveEntry: true }); const view = { viewId, runtime: params.runtime, agentId, sessionId: params.runtime.sessionId, serverName: params.serverName, toolName: params.toolName, uiResourceUri: params.uiResourceUri, ...params.toolCallId ? { toolCallId: params.toolCallId } : {}, html, ...csp ? { csp } : {}, ...permissions ? { permissions } : {}, ...params.allowedAppToolNames ? { allowedAppToolNames: new Set(params.allowedAppToolNames) } : {}, ...params.authorizeAppInteraction ? { authorizeAppInteraction: params.authorizeAppInteraction } : {}, ...params.readOnly ? { readOnly: true } : {}, toolInput: params.toolInput, toolResult: params.toolResult, expiresAtMs: Date.now() + MCP_APP_VIEW_TTL_MS, requestWindowStartedAtMs: Date.now(), requestCount: 0, toolCallCount: 0, activeRequests: 0, byteSize, ...releaseRuntimeLease ? { releaseRuntimeLease } : {} }; releaseRuntimeLease = void 0; view.expiryTimer = setTimeout(() => { deleteView(view.viewId, view); }, MCP_APP_VIEW_TTL_MS); view.expiryTimer.unref?.(); getViewStore().set(viewId, view); return { viewId, title, serverName: params.serverName, toolName: params.toolName, uiResourceUri: params.uiResourceUri, ...params.toolCallId ? { toolCallId: params.toolCallId } : {} }; } catch (error) { releaseRuntimeLease?.(); getSessionMcpRequestSignal()?.throwIfAborted(); logWarn(`mcp-app: failed to prepare ${params.uiResourceUri} from "${params.serverName}": ${formatErrorMessage(error)}`); return; } } function getMcpAppViewLease(viewId, runtime) { pruneViewStore(); const view = getViewStore().get(viewId); return view?.runtime === runtime ? view : void 0; } /** Resolve a live view owned by its originating session, including harness-native runtimes. */ function getMcpAppViewLeaseForSession(viewId, sessionKey, agentId) { pruneViewStore(); const view = getViewStore().get(viewId); return view?.runtime.sessionKey === sessionKey && view.agentId === normalizeAgentId(agentId) ? view : void 0; } function acquireMcpAppViewRequest(view, kind, nowMs = Date.now()) { if (nowMs - view.requestWindowStartedAtMs >= 6e4) { view.requestWindowStartedAtMs = nowMs; view.requestCount = 0; view.toolCallCount = 0; } if (view.activeRequests >= 4) throw new Error("MCP App request concurrency limit reached"); if (view.requestCount >= 120 || kind === "tool" && view.toolCallCount >= 30) throw new Error("MCP App request rate limit reached"); view.requestCount += 1; if (kind === "tool") view.toolCallCount += 1; view.activeRequests += 1; let released = false; return () => { if (!released) { released = true; view.activeRequests = Math.max(0, view.activeRequests - 1); } }; } function buildMcpAppCanvasPayload(view) { assertBoundedViewDescriptor(view); return { kind: "canvas", view: { id: view.viewId, title: view.title }, presentation: { target: "assistant_message", title: view.title, preferred_height: 600, sandbox: "scripts" }, mcpApp: { viewId: view.viewId, serverName: view.serverName, toolName: view.toolName, uiResourceUri: view.uiResourceUri, ...view.toolCallId ? { toolCallId: view.toolCallId } : {}, ...view.originSessionKey ? { originSessionKey: view.originSessionKey } : {}, ...view.resultMetaState ? { resultMetaState: view.resultMetaState } : {} } }; } const testing = { clearViewStore() { for (const [viewId, view] of getViewStore()) deleteView(viewId, view); } }; if (process.env.VITEST || false) globalThis[Symbol.for("openclaw.mcpUiResourceTestApi")] = testing; //#endregion export { getMcpAppViewLeaseForSession as a, resolveMcpAppSandboxPort as c, getMcpAppViewLease as i, buildMcpAppCanvasPayload as n, readMcpAppChannelView as o, fetchMcpAppView as r, buildMcpAppSandboxPath as s, acquireMcpAppViewRequest as t };