"use client"; import { type MutableRefObject, useEffect, useRef, useState } from "react"; import { type RenderedFrame, SafeContentFrame } from "safe-content-frame"; import { type McpAppBridge, createMcpAppBridge } from "./bridge"; import type { McpAppBridgeHandlers, McpAppFrameProps, McpAppHostContext, } from "./types"; const DEFAULT_PRODUCT = "assistant-ui-mcp-app"; const INIT_TIMEOUT_MS = 5000; const DEFAULT_MAX_HEIGHT = 800; function useBridgeNotify( value: T | undefined, bridgeRef: MutableRefObject, widgetReadyRef: MutableRefObject, pendingRef: MutableRefObject, lastSentRef: MutableRefObject, notify: (bridge: McpAppBridge, v: T) => void, ) { // biome-ignore lint/correctness/useExhaustiveDependencies: refs and notify are stable; we re-run only when value changes. useEffect(() => { if (!bridgeRef.current) return; if (value === undefined) return; if (lastSentRef.current === value) return; if (!widgetReadyRef.current) { pendingRef.current = value; return; } notify(bridgeRef.current, value); lastSentRef.current = value; }, [value]); } type LiveSnapshot = { handlers: McpAppBridgeHandlers | undefined; hostInfo: McpAppFrameProps["hostInfo"]; hostContext: McpAppFrameProps["hostContext"]; input: unknown; output: unknown; }; // Proxy each per-call handler through liveRef so the bridge always dispatches // to the latest handler reference (e.g. inline callbacks closing over state). // Capability presence is snapshot at mount: a handler added later requires a // remount (keyed on resource URI) to expose the capability to the widget. function buildLiveHandlers( initial: McpAppBridgeHandlers | undefined, liveRef: { readonly current: LiveSnapshot }, ): McpAppBridgeHandlers { const live = () => liveRef.current.handlers; const has = (key: K) => initial?.[key] !== undefined; const out: McpAppBridgeHandlers = {}; if (has("allowedTools")) { Object.defineProperty(out, "allowedTools", { get: () => live()?.allowedTools, enumerable: true, configurable: true, }); } const liveCall = ( key: K, ): NonNullable => ((p: unknown) => { const fn = live()?.[key] as ((p: unknown) => unknown) | undefined; if (!fn) { throw new Error(`${key} handler is no longer available`); } return fn(p); }) as NonNullable; if (has("callTool")) out.callTool = liveCall("callTool"); if (has("readResource")) out.readResource = liveCall("readResource"); if (has("listResources")) out.listResources = liveCall("listResources"); if (has("openLink")) out.openLink = liveCall("openLink"); if (has("sendMessage")) out.sendMessage = liveCall("sendMessage"); if (has("updateModelContext")) out.updateModelContext = liveCall("updateModelContext"); if (has("requestDisplayMode")) out.requestDisplayMode = liveCall("requestDisplayMode"); out.onSizeChange = (p) => live()?.onSizeChange?.(p); out.onInitialized = () => live()?.onInitialized?.(); out.onRequestTeardown = (p) => live()?.onRequestTeardown?.(p); out.onLog = (p) => live()?.onLog?.(p); out.onError = (e) => live()?.onError?.(e); return out; } export function McpAppFrame({ app, resource, input, output, sandbox, handlers, hostInfo, hostContext, maxHeight = DEFAULT_MAX_HEIGHT, }: McpAppFrameProps) { const containerRef = useRef(null); const [contentHeight, setContentHeight] = useState( undefined, ); const bridgeRef = useRef(null); const lastSentInputRef = useRef(undefined); const lastSentOutputRef = useRef(undefined); const lastSentHostContextRef = useRef( undefined, ); // Per MCP Apps spec, the host should defer notifications until the widget // signals readiness via `notifications/initialized`. Until then, we record // pending values and flush them on init. const widgetReadyRef = useRef(false); const pendingInputRef = useRef(undefined); const pendingOutputRef = useRef(undefined); const pendingHostContextRef = useRef( undefined, ); const liveRef = useRef(null!); liveRef.current = { handlers, hostInfo, hostContext, input, output, }; const resourceUri = resource.uri; // biome-ignore lint/correctness/useExhaustiveDependencies: re-mounts only on resource URI; live values flow through liveRef useEffect(() => { const container = containerRef.current; if (!container) return; let cancelled = false; let initTimeoutId: ReturnType | null = null; let frame: RenderedFrame | null = null; const sb = sandbox; const html = resource.html; const scf = new SafeContentFrame(sb?.product ?? DEFAULT_PRODUCT, { ...(sb?.sandbox !== undefined && { sandbox: sb.sandbox }), ...(sb?.useShadowDom !== undefined && { useShadowDom: sb.useShadowDom }), ...(sb?.enableBrowserCaching !== undefined && { enableBrowserCaching: sb.enableBrowserCaching, }), ...(sb?.salt !== undefined && { salt: sb.salt }), }); const renderOpts = sb?.unsafeDocumentWrite !== undefined ? { unsafeDocumentWrite: sb.unsafeDocumentWrite } : undefined; scf .renderHtml(html, container, renderOpts) .then((rendered) => { if (cancelled) { rendered.dispose(); return; } frame = rendered; const current = liveRef.current; const liveHandlers = buildLiveHandlers(current.handlers, liveRef); const liveOnInitialized = liveHandlers.onInitialized; const flushPending = () => { if (widgetReadyRef.current) return; widgetReadyRef.current = true; const b = bridgeRef.current; if (!b) return; if (pendingInputRef.current !== undefined) { b.notifyToolInput(pendingInputRef.current); lastSentInputRef.current = pendingInputRef.current; pendingInputRef.current = undefined; } if (pendingOutputRef.current !== undefined) { b.notifyToolResult(pendingOutputRef.current); lastSentOutputRef.current = pendingOutputRef.current; pendingOutputRef.current = undefined; } if (pendingHostContextRef.current !== undefined) { b.notifyHostContextChanged(pendingHostContextRef.current); lastSentHostContextRef.current = pendingHostContextRef.current; pendingHostContextRef.current = undefined; } }; const wrappedHandlers: McpAppBridgeHandlers = { ...liveHandlers, onInitialized: () => { if (initTimeoutId !== null) { clearTimeout(initTimeoutId); initTimeoutId = null; } flushPending(); liveOnInitialized?.(); }, onSizeChange: (p) => { if ( typeof p.height === "number" && Number.isFinite(p.height) && p.height > 0 ) { setContentHeight(p.height); } liveHandlers.onSizeChange?.(p); }, }; // Safety net: if the widget never sends notifications/initialized // (broken or non-spec-compliant), flush the queue anyway so the host // doesn't appear hung. initTimeoutId = setTimeout(() => { initTimeoutId = null; flushPending(); }, INIT_TIMEOUT_MS); bridgeRef.current = createMcpAppBridge({ frame: rendered, handlers: wrappedHandlers, hostInfo: current.hostInfo, hostContext: current.hostContext, }); if (current.input !== undefined) pendingInputRef.current = current.input; if (current.output !== undefined) pendingOutputRef.current = current.output; // hostContext is delivered inside the ui/initialize response; subsequent // changes flow through useBridgeNotify's pending path. }) .catch((err) => { liveRef.current.handlers?.onError?.( err instanceof Error ? err : new Error(String(err)), ); }); return () => { cancelled = true; if (initTimeoutId !== null) { clearTimeout(initTimeoutId); initTimeoutId = null; } bridgeRef.current?.dispose(); bridgeRef.current = null; frame?.dispose(); frame = null; lastSentInputRef.current = undefined; lastSentOutputRef.current = undefined; lastSentHostContextRef.current = undefined; widgetReadyRef.current = false; pendingInputRef.current = undefined; pendingOutputRef.current = undefined; pendingHostContextRef.current = undefined; setContentHeight(undefined); }; }, [resourceUri]); useBridgeNotify( input, bridgeRef, widgetReadyRef, pendingInputRef, lastSentInputRef, (b, v) => b.notifyToolInput(v), ); useBridgeNotify( output, bridgeRef, widgetReadyRef, pendingOutputRef, lastSentOutputRef, (b, v) => b.notifyToolResult(v), ); useBridgeNotify( hostContext, bridgeRef, widgetReadyRef, pendingHostContextRef, lastSentHostContextRef, (b, v) => b.notifyHostContextChanged(v), ); const resolvedHeight = contentHeight != null ? Math.min(contentHeight, maxHeight) : undefined; const mergedStyle = resolvedHeight != null ? { ...sandbox?.style, height: resolvedHeight } : sandbox?.style; return (
); }