import { AppBridge, PostMessageTransport, buildAllowAttribute, type McpUiResourceCsp, type McpUiResourcePermissions, } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { AGENT_NATIVE_EMBED_MESSAGE_TYPES, AGENT_NATIVE_EMBED_PROTOCOL, AGENT_NATIVE_EMBED_VERSION, } from "../../embedding/protocol.js"; import type { AgentMcpAppPayload } from "../../mcp-client/app-result.js"; import { sendToAgentChat, type AgentChatRequestMode } from "../agent-chat.js"; import { agentNativePath } from "../api-path.js"; import { cn } from "../utils.js"; export const DEFAULT_MCP_APP_IFRAME_HEIGHT = 650; export const MCP_APP_INITIALIZE_TIMEOUT_MS = 8000; const MIN_IFRAME_HEIGHT = 220; const VIEWPORT_MARGIN = 16; const SANDBOX_FLAGS = "allow-scripts allow-forms allow-popups"; const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; export interface McpAppRendererProps { app: AgentMcpAppPayload; className?: string; } type ResourceUiMeta = { csp?: McpUiResourceCsp; permissions?: McpUiResourcePermissions; prefersBorder?: boolean; }; type McpContentPart = { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown; url?: unknown; }; type McpAppModelContext = { content?: unknown; structuredContent?: unknown; }; export function McpAppRenderer({ app, className }: McpAppRendererProps) { const iframeRef = useRef(null); const desiredHeightRef = useRef(DEFAULT_MCP_APP_IFRAME_HEIGHT); const modelContextRef = useRef(null); const readyRef = useRef(false); const [height, setHeight] = useState(DEFAULT_MCP_APP_IFRAME_HEIGHT); const [error, setError] = useState(null); const [ready, setReady] = useState(false); const resourceHtml = app.resource ? htmlFromResource(app.resource) : ""; const uiMeta = useMemo(() => resourceUiMeta(app), [app]); const supportedPermissions = useMemo( () => supportedMcpAppPermissions(uiMeta.permissions), [uiMeta.permissions], ); const csp = buildMcpAppCsp(uiMeta.csp); const srcDoc = useMemo( () => (resourceHtml ? injectCsp(resourceHtml, csp) : ""), [resourceHtml, csp], ); const externalOpenUrl = useMemo(() => openUrlFromMcpApp(app), [app]); // Keep the latest payload/permissions/csp reachable from the bridge effect // without making them effect dependencies. The embedded resource identity is // fully captured by `srcDoc`. The bridge effect must NOT re-run when a benign // parent re-render hands us a new `app` object reference with identical // content (common during chat streaming/polling): re-running tears down a // live, already-initialized MCP App (teardownResource) and re-arms the // initialize watchdog against a fresh host AppBridge that the embed shell // will never re-handshake (its connect promise is memoized), surfacing a // false "MCP App did not finish initializing." error after the app is // visibly working. const appRef = useRef(app); const supportedPermissionsRef = useRef(supportedPermissions); const uiCspRef = useRef(uiMeta.csp); appRef.current = app; supportedPermissionsRef.current = supportedPermissions; uiCspRef.current = uiMeta.csp; useEffect(() => { desiredHeightRef.current = DEFAULT_MCP_APP_IFRAME_HEIGHT; setHeight( clampMcpAppHeight( DEFAULT_MCP_APP_IFRAME_HEIGHT, availableMcpAppHeight(iframeRef.current), ), ); readyRef.current = false; setReady(false); setError(null); modelContextRef.current = null; }, [srcDoc]); const markReady = useCallback(() => { readyRef.current = true; setReady(true); setError(null); }, []); const applyHeight = useCallback((desiredHeight?: number) => { if ( typeof desiredHeight === "number" && Number.isFinite(desiredHeight) && desiredHeight > 0 ) { desiredHeightRef.current = desiredHeight; } setHeight( clampMcpAppHeight( desiredHeightRef.current, availableMcpAppHeight(iframeRef.current), ), ); }, []); useEffect(() => { let frame = 0; const update = () => { if (frame) cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { frame = 0; applyHeight(); }); }; update(); window.addEventListener("resize", update, { passive: true }); window.visualViewport?.addEventListener("resize", update, { passive: true, }); document.addEventListener("scroll", update, true); const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(update) : null; if (observer) { observer.observe(document.documentElement); const parent = iframeRef.current?.parentElement; if (parent) observer.observe(parent); } return () => { if (frame) cancelAnimationFrame(frame); window.removeEventListener("resize", update); window.visualViewport?.removeEventListener("resize", update); document.removeEventListener("scroll", update, true); observer?.disconnect(); }; }, [applyHeight, srcDoc]); useEffect(() => { const iframe = iframeRef.current; if (!iframe?.contentWindow || !srcDoc) return; const frameWindow = iframe.contentWindow; const listener = (event: MessageEvent) => { if (event.source !== frameWindow) return; if (isMcpAppReadyMessage(event.data)) { markReady(); } }; window.addEventListener("message", listener); return () => window.removeEventListener("message", listener); }, [markReady, srcDoc]); useBrowserLayoutEffect(() => { const iframe = iframeRef.current; if (!iframe?.contentWindow || !srcDoc) return; const currentApp = appRef.current; let closed = false; const initializeTimer = window.setTimeout(() => { if (closed || readyRef.current) return; setReady(false); setError("MCP App did not finish initializing."); }, MCP_APP_INITIALIZE_TIMEOUT_MS); const bridge = new AppBridge( null, { name: "Agent Native", version: "1.0.0" }, { openLinks: {}, serverTools: {}, serverResources: {}, logging: {}, sandbox: { permissions: supportedPermissionsRef.current, csp: uiCspRef.current ?? {}, }, }, { hostContext: buildHostContext( currentApp, availableMcpAppHeight(iframe), ) as any, }, ); bridge.addEventListener("sizechange", ({ height: nextHeight }) => { if (typeof nextHeight !== "number" || !Number.isFinite(nextHeight)) { return; } applyHeight(nextHeight); }); bridge.addEventListener("initialized", () => { if (closed) return; clearTimeout(initializeTimer); markReady(); void bridge.sendToolInput({ arguments: appRef.current.toolInput }); void bridge.sendToolResult(appRef.current.toolResult as CallToolResult); }); bridge.addEventListener("loggingmessage", ({ level, data }) => { if (level === "error" || level === "critical" || level === "alert") { console.warn("[mcp-app]", data); } }); bridge.onopenlink = async ({ url }) => { if (!isSafeExternalUrl(url)) return { isError: true }; window.open(url, "_blank", "noopener,noreferrer"); return {}; }; bridge.oncalltool = async ({ name, arguments: toolArguments }) => { const toolName = normalizeSameServerToolName( appRef.current.serverId, name, ); if (!toolName) { return errorToolResult("Cross-server MCP App tool calls are blocked."); } try { return await postMcpAppEndpoint("call-tool", { serverId: appRef.current.serverId, toolName, arguments: toolArguments && typeof toolArguments === "object" ? toolArguments : {}, }); } catch (err: any) { return errorToolResult(err?.message ?? "MCP App tool call failed."); } }; (bridge as any).onlisttools = async () => postMcpAppEndpoint("list-tools", { serverId: appRef.current.serverId }); bridge.onreadresource = async ({ uri }) => postMcpAppEndpoint("read-resource", { serverId: appRef.current.serverId, uri, }); bridge.onlistresources = async () => ({ resources: [] }); bridge.onlistresourcetemplates = async () => ({ resourceTemplates: [] }); bridge.ondownloadfile = async () => ({ isError: true }); bridge.onmessage = async (params) => { const message = messageTextFromMcpUiMessage(params); if (!message.trim()) return { isError: true }; const mode = requestModeFromMcpUiMessage(params); sendToAgentChat({ message, context: contextTextFromMcpModelContext(modelContextRef.current), images: imageDataUrlsFromMcpContent(params.content), submit: true, openSidebar: true, ...(mode ? { mode } : {}), }); return {}; }; bridge.onupdatemodelcontext = async (params) => { modelContextRef.current = params as McpAppModelContext; return {}; }; const transport = new PostMessageTransport( iframe.contentWindow, iframe.contentWindow, ); setError(null); void bridge.connect(transport).catch((err: any) => { if (!closed) { clearTimeout(initializeTimer); setError(err?.message ?? "Failed to initialize MCP App."); } }); return () => { closed = true; modelContextRef.current = null; clearTimeout(initializeTimer); void bridge .teardownResource({}, { timeout: 500 }) .catch(() => undefined) .finally(() => { void (bridge as any).close?.().catch?.(() => undefined); }); }; // The embedded resource identity is captured by `srcDoc`; `app`, // `supportedPermissions`, and `uiMeta.csp` are read via refs so a // new-but-equal `app` object reference does not tear down a live bridge. }, [applyHeight, markReady, srcDoc]); if (!resourceHtml) { return (
MCP App resource was not available.
); } return (
{!ready && !error && (
Loading MCP App
)} {error && (
{error} {externalOpenUrl && ( )}
)}