|
| 1 | +import { useRef, useEffect, useState, useCallback } from 'react' |
| 2 | +import { AppBridge, PostMessageTransport, buildAllowAttribute } from '@modelcontextprotocol/ext-apps/app-bridge' |
| 3 | + |
| 4 | +export default function MCPAppFrame({ toolName, toolInput, toolResult, mcpClient, toolDefinition, appHtml, resourceMeta }) { |
| 5 | + const iframeRef = useRef(null) |
| 6 | + const bridgeRef = useRef(null) |
| 7 | + const [iframeHeight, setIframeHeight] = useState(200) |
| 8 | + const [error, setError] = useState(null) |
| 9 | + const initializedRef = useRef(false) |
| 10 | + |
| 11 | + const setupBridge = useCallback(async () => { |
| 12 | + if (!mcpClient || !iframeRef.current || initializedRef.current) return |
| 13 | + |
| 14 | + const iframe = iframeRef.current |
| 15 | + initializedRef.current = true |
| 16 | + |
| 17 | + try { |
| 18 | + const transport = new PostMessageTransport(iframe.contentWindow, iframe.contentWindow) |
| 19 | + const bridge = new AppBridge( |
| 20 | + mcpClient, |
| 21 | + { name: 'LocalAI', version: '1.0.0' }, |
| 22 | + { openLinks: {}, serverTools: {}, serverResources: {}, logging: {} }, |
| 23 | + { hostContext: { displayMode: 'inline' } } |
| 24 | + ) |
| 25 | + |
| 26 | + bridge.oninitialized = () => { |
| 27 | + if (toolInput) bridge.sendToolInput({ arguments: toolInput }) |
| 28 | + if (toolResult) bridge.sendToolResult(toolResult) |
| 29 | + } |
| 30 | + |
| 31 | + bridge.onsizechange = ({ height }) => { |
| 32 | + if (height && height > 0) setIframeHeight(Math.min(height, 600)) |
| 33 | + } |
| 34 | + |
| 35 | + bridge.onopenlink = async ({ url }) => { |
| 36 | + window.open(url, '_blank', 'noopener,noreferrer') |
| 37 | + return {} |
| 38 | + } |
| 39 | + |
| 40 | + bridge.onmessage = async () => { |
| 41 | + return {} |
| 42 | + } |
| 43 | + |
| 44 | + bridge.onrequestdisplaymode = async () => { |
| 45 | + return { mode: 'inline' } |
| 46 | + } |
| 47 | + |
| 48 | + await bridge.connect(transport) |
| 49 | + bridgeRef.current = bridge |
| 50 | + } catch (err) { |
| 51 | + setError(`Bridge error: ${err.message}`) |
| 52 | + } |
| 53 | + }, [mcpClient, toolInput, toolResult]) |
| 54 | + |
| 55 | + const handleIframeLoad = useCallback(() => { |
| 56 | + setupBridge() |
| 57 | + }, [setupBridge]) |
| 58 | + |
| 59 | + // Send toolResult when it arrives after initialization |
| 60 | + useEffect(() => { |
| 61 | + if (bridgeRef.current && toolResult && initializedRef.current) { |
| 62 | + bridgeRef.current.sendToolResult(toolResult) |
| 63 | + } |
| 64 | + }, [toolResult]) |
| 65 | + |
| 66 | + // Cleanup on unmount — only close the local transport, don't send |
| 67 | + // teardownResource which would kill server-side state and cause |
| 68 | + // "Connection closed" errors if the component remounts (e.g. when |
| 69 | + // streaming ends and ActivityGroup takes over from StreamingActivity). |
| 70 | + useEffect(() => { |
| 71 | + return () => { |
| 72 | + const bridge = bridgeRef.current |
| 73 | + if (bridge) { |
| 74 | + try { bridge.close() } catch (_) { /* ignore */ } |
| 75 | + } |
| 76 | + } |
| 77 | + }, []) |
| 78 | + |
| 79 | + if (!appHtml) return null |
| 80 | + |
| 81 | + const permissions = resourceMeta?.permissions |
| 82 | + const allowAttr = permissions ? buildAllowAttribute(permissions) : undefined |
| 83 | + |
| 84 | + return ( |
| 85 | + <div className="mcp-app-frame-container"> |
| 86 | + <iframe |
| 87 | + ref={iframeRef} |
| 88 | + srcDoc={appHtml} |
| 89 | + sandbox="allow-scripts allow-forms" |
| 90 | + allow={allowAttr} |
| 91 | + className="mcp-app-iframe" |
| 92 | + style={{ height: `${iframeHeight}px` }} |
| 93 | + onLoad={handleIframeLoad} |
| 94 | + title={`MCP App: ${toolName || 'unknown'}`} |
| 95 | + /> |
| 96 | + {error && <div className="mcp-app-error">{error}</div>} |
| 97 | + {!mcpClient && ( |
| 98 | + <div className="mcp-app-reconnect-overlay"> |
| 99 | + Reconnect to MCP server to interact with this app |
| 100 | + </div> |
| 101 | + )} |
| 102 | + </div> |
| 103 | + ) |
| 104 | +} |
0 commit comments