diff --git a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx index 3be26aa12..c4681f1a8 100644 --- a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx +++ b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx @@ -1,8 +1,8 @@ /** * McpAppRenderer — Renders interactive MCP App UIs inside a sandboxed iframe. * - * This component implements the host side of the MCP Apps protocol using the - * @mcp-ui/client SDK's AppRenderer. It handles resource fetching, sandbox + * This component implements the host side of the MCP Apps protocol using + * @mcp-ui/client protocol primitives. It handles resource fetching, sandbox * proxy setup, CSP enforcement, and bidirectional communication with guest apps. * * Protocol references: @@ -15,9 +15,16 @@ * - "standalone" — Goose-specific mode for dedicated Electron windows */ -import { AppRenderer, type RequestHandlerExtra } from '@mcp-ui/client'; +import { + AppBridge, + PostMessageTransport, + type AppInfo, + type RequestHandlerExtra, + type SandboxConfig, +} from '@mcp-ui/client'; import type { McpUiDisplayMode, + McpUiHostCapabilities, McpUiHostContext, McpUiResourceCsp, McpUiResourcePermissions, @@ -125,6 +132,7 @@ const i18n = defineMessages({ const DEFAULT_IFRAME_HEIGHT = 200; const FULLSCREEN_HEADER_HEIGHT = 48; +const DEFAULT_SANDBOX_PERMISSIONS = 'allow-scripts allow-same-origin allow-forms'; const DISPLAY_MODE_LAYOUTS: Record = { inline: { width: 'fixed', height: 'unbounded' }, @@ -229,6 +237,257 @@ interface ResourceMeta { const DEFAULT_META: ResourceMeta = { csp: null, permissions: null, prefersBorder: true }; +type FallbackRequestHandler = { + fallbackRequestHandler?: ( + request: JSONRPCRequest, + extra: RequestHandlerExtra + ) => Promise>; +}; + +interface GooseAppFrameProps { + html: string; + sandbox: SandboxConfig; + hostContext: McpUiHostContext; + toolInput?: Record; + toolInputPartial?: Record; + toolResult?: CallToolResult; + toolCancelled?: boolean; + onMessage: (params: { + content: Array<{ type: string; text?: string }>; + }) => Promise>; + onOpenLink: (params: { url: string }) => Promise<{ status: 'success' | 'error'; message?: string }>; + onCallTool: (params: { + name: string; + arguments?: Record; + }) => Promise; + onReadResource: (params: { uri: string }) => Promise<{ + contents: Array<{ uri: string; text: string; mimeType?: string }>; + }>; + onLoggingMessage: (params: { level?: string; logger?: string; data?: unknown }) => void; + onFallbackRequest: ( + request: JSONRPCRequest, + extra: RequestHandlerExtra + ) => Promise>; + onSizeChanged?: (params: McpUiSizeChangedNotification['params']) => void; + onInitialized?: (appInfo: AppInfo) => void; + onError?: (error: Error) => void; +} + +const SANDBOX_PROXY_READY_METHOD = 'ui/notifications/sandbox-proxy-ready'; + +function GooseAppFrame({ + html, + sandbox, + hostContext, + toolInput, + toolInputPartial, + toolResult, + toolCancelled, + onMessage, + onOpenLink, + onCallTool, + onReadResource, + onLoggingMessage, + onFallbackRequest, + onSizeChanged, + onInitialized, + onError, +}: GooseAppFrameProps) { + const containerRef = useRef(null); + const iframeRef = useRef(null); + const bridgeRef = useRef(null); + const [connected, setConnected] = useState(false); + const [initialized, setInitialized] = useState(false); + const hostContextRef = useRef(hostContext); + const onMessageRef = useRef(onMessage); + const onOpenLinkRef = useRef(onOpenLink); + const onCallToolRef = useRef(onCallTool); + const onReadResourceRef = useRef(onReadResource); + const onLoggingMessageRef = useRef(onLoggingMessage); + const onFallbackRequestRef = useRef(onFallbackRequest); + const onSizeChangedRef = useRef(onSizeChanged); + const onInitializedRef = useRef(onInitialized); + const onErrorRef = useRef(onError); + + useEffect(() => { + hostContextRef.current = hostContext; + onMessageRef.current = onMessage; + onOpenLinkRef.current = onOpenLink; + onCallToolRef.current = onCallTool; + onReadResourceRef.current = onReadResource; + onLoggingMessageRef.current = onLoggingMessage; + onFallbackRequestRef.current = onFallbackRequest; + onSizeChangedRef.current = onSizeChanged; + onInitializedRef.current = onInitialized; + onErrorRef.current = onError; + }); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + setConnected(false); + setInitialized(false); + + const capabilities: McpUiHostCapabilities = { + openLinks: {}, + serverTools: {}, + serverResources: {}, + logging: {}, + message: { text: {} }, + }; + const bridge = new AppBridge( + null, + { name: 'MCP-UI Host', version: '1.0.0' }, + capabilities, + { hostContext: hostContextRef.current } + ); + bridge.onmessage = (params) => onMessageRef.current(params); + bridge.onopenlink = (params) => onOpenLinkRef.current(params); + bridge.onloggingmessage = (params) => onLoggingMessageRef.current(params); + bridge.oncalltool = (params) => onCallToolRef.current(params); + bridge.onreadresource = (params) => onReadResourceRef.current(params); + (bridge as FallbackRequestHandler).fallbackRequestHandler = (request, extra) => + onFallbackRequestRef.current(request, extra); + + const iframe = document.createElement('iframe'); + iframe.style.width = '100%'; + iframe.style.height = '600px'; + iframe.style.border = 'none'; + iframe.style.backgroundColor = 'transparent'; + iframe.setAttribute('sandbox', sandbox.permissions || DEFAULT_SANDBOX_PERMISSIONS); + + let active = true; + let settled = false; + const cleanupReadyListener = () => { + window.removeEventListener('message', handleReadyMessage); + iframe.removeEventListener('error', handleFrameError); + clearTimeout(timeout); + }; + const fail = (error: Error) => { + if (settled) return; + settled = true; + cleanupReadyListener(); + if (active) { + onErrorRef.current?.(error); + } + }; + const ready = () => { + if (settled) return; + settled = true; + cleanupReadyListener(); + void connectBridge(); + }; + function handleReadyMessage(event: MessageEvent) { + if (event.source !== iframe.contentWindow) return; + if (event.data?.method === SANDBOX_PROXY_READY_METHOD) { + ready(); + } + } + function handleFrameError() { + fail(new Error('Failed to load sandbox proxy iframe')); + } + const timeout = window.setTimeout(() => { + fail(new Error('Timed out waiting for sandbox proxy iframe to be ready')); + }, 10_000); + const connectBridge = async () => { + if (!active || !iframe.contentWindow) return; + try { + bridge.onsizechange = (params) => { + onSizeChangedRef.current?.(params); + if (params.width !== undefined) { + iframe.style.width = `${params.width}px`; + } + if (params.height !== undefined) { + iframe.style.height = `${params.height}px`; + } + }; + bridge.oninitialized = () => { + if (!active) return; + setInitialized(true); + onInitializedRef.current?.({ + appVersion: bridge.getAppVersion(), + appCapabilities: bridge.getAppCapabilities(), + }); + }; + await bridge.connect(new PostMessageTransport(iframe.contentWindow, iframe.contentWindow)); + if (!active) return; + bridgeRef.current = bridge; + setConnected(true); + } catch (error) { + if (!active) return; + onErrorRef.current?.(error instanceof Error ? error : new Error(String(error))); + } + }; + + window.addEventListener('message', handleReadyMessage); + iframe.addEventListener('error', handleFrameError); + container.replaceChildren(iframe); + iframeRef.current = iframe; + iframe.src = sandbox.url.href; + + return () => { + active = false; + cleanupReadyListener(); + if (iframeRef.current === iframe) { + iframeRef.current = null; + } + if (bridgeRef.current === bridge) { + bridgeRef.current = null; + } + bridge.close(); + iframe.remove(); + }; + }, [sandbox.permissions, sandbox.url.href]); + + useEffect(() => { + const bridge = bridgeRef.current; + if (!connected || !bridge) return; + void Promise.resolve(bridge.sendSandboxResourceReady({ html, csp: sandbox.csp })).catch( + (error: unknown) => { + onErrorRef.current?.(error instanceof Error ? error : new Error(String(error))); + } + ); + }, [connected, html, sandbox.csp]); + + useEffect(() => { + const bridge = bridgeRef.current; + if (connected && initialized && toolInput && bridge) { + void bridge.sendToolInput({ arguments: toolInput }); + } + }, [connected, initialized, toolInput]); + + useEffect(() => { + const bridge = bridgeRef.current; + if (connected && initialized && toolResult && bridge) { + void bridge.sendToolResult(toolResult); + } + }, [connected, initialized, toolResult]); + + useEffect(() => { + const bridge = bridgeRef.current; + if (initialized && bridge) { + bridge.setHostContext(hostContext); + } + }, [initialized, hostContext]); + + useEffect(() => { + const bridge = bridgeRef.current; + if (initialized && toolInputPartial && bridge) { + void bridge.sendToolInputPartial({ arguments: toolInputPartial }); + } + }, [initialized, toolInputPartial]); + + useEffect(() => { + const bridge = bridgeRef.current; + if (initialized && toolCancelled && bridge) { + void bridge.sendToolCancelled({}); + } + }, [initialized, toolCancelled]); + + return
; +} + // Lifecycle: idle → loading_resource → loading_sandbox → ready // Any state can transition to error. The sandbox URL is fetched only once // to prevent iframe recreation (which would cause the app to lose state). @@ -732,7 +991,7 @@ export default function McpAppRenderer({ if (!readySandboxUrl) return null; return { url: readySandboxUrl, - permissions: meta.permissions || 'allow-scripts allow-same-origin', + permissions: meta.permissions || DEFAULT_SANDBOX_PERMISSIONS, csp: mcpUiCsp, }; }, [readySandboxUrl, meta.permissions, mcpUiCsp]); @@ -782,7 +1041,6 @@ export default function McpAppRenderer({ mcpTool, ]); - const isToolCancelled = !!toolCancelled; const isError = state.status === 'error'; const isReady = state.status === 'ready'; @@ -814,22 +1072,21 @@ export default function McpAppRenderer({ if (!sandboxConfig) return null; return ( - ); @@ -936,7 +1193,7 @@ export default function McpAppRenderer({ }; // Single stable container — CSS switches between inline/fullscreen/pip positioning. - // The AppRenderer and its iframe are never unmounted, preserving app state across mode changes. + // The iframe is never unmounted, preserving app state across mode changes. const containerClasses = cn( 'mcp-app-container bg-background-primary [&_iframe]:!w-full', isFillsViewport && 'fixed inset-0 z-[1000] overflow-hidden [&_iframe]:!h-full',