diff --git a/frontend/src/components/MCPAppRenderer.tsx b/frontend/src/components/MCPAppRenderer.tsx new file mode 100644 index 0000000..d6fee2e --- /dev/null +++ b/frontend/src/components/MCPAppRenderer.tsx @@ -0,0 +1,159 @@ +import { useRef, useEffect, useCallback, useState } from 'react' +import { IconAppWindow, IconLoader } from '@tabler/icons-react' + +interface MCPAppRendererProps { + resourceUri: string + data: Record + csp?: string + onToolCall?: (toolName: string, args: Record) => void + onSendMessage?: (content: string) => void +} + +export function MCPAppRenderer({ + resourceUri, + data, + onToolCall, + onSendMessage, +}: MCPAppRendererProps) { + const iframeRef = useRef(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [iframeHeight, setIframeHeight] = useState(300) + + const appName = resourceUri.replace(/^app:\/\//, '') + + useEffect(() => { + let cancelled = false + setLoading(true) + + fetch(`/api/apps/${appName}`, { credentials: 'include' }) + .then((res) => { + if (!res.ok) throw new Error(`Failed to load app: ${res.status}`) + return res.text() + }) + .then((html) => { + if (cancelled) return + if (iframeRef.current) { + iframeRef.current.srcdoc = html + } + }) + .catch((err: unknown) => { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { + cancelled = true + } + }, [appName]) + + const handleMessage = useCallback( + (event: MessageEvent) => { + if (!iframeRef.current?.contentWindow) return + + const msg = event.data + if (!msg || msg.jsonrpc !== '2.0') return + + const params = msg.params ?? {} + + switch (msg.method) { + case 'ui/initialize': + iframeRef.current.contentWindow.postMessage( + { jsonrpc: '2.0', method: 'tool/input', params: data }, + '*' + ) + break + + case 'tools/call': + onToolCall?.(params.name, params.arguments ?? {}) + iframeRef.current.contentWindow.postMessage( + { jsonrpc: '2.0', id: msg.id, result: { status: 'accepted' } }, + '*' + ) + break + + case 'ui/sendMessage': + if (onSendMessage && params.content) { + onSendMessage(params.content) + } + break + + case 'ui/resize': + if (typeof params.height === 'number') { + setIframeHeight(Math.min(params.height, 800)) + } + break + + default: + break + } + }, + [data, onToolCall, onSendMessage] + ) + + useEffect(() => { + window.addEventListener('message', handleMessage) + return () => { + window.removeEventListener('message', handleMessage) + } + }, [handleMessage]) + + if (error) { + return ( +
+ + MCP App error: {error} +
+ ) + } + + return ( +
+
+ {loading ? ( + + ) : ( + + )} + {appName} +
+