From 68eaa5e107fc57ba5ece0bffdcfa8d0b5db522ee Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 24 Jan 2026 18:07:43 -0300 Subject: [PATCH 01/13] feat(mcp-apps): initial implementation of MCP Apps spec --- .claude/settings.local.json | 5 + apps/mesh/src/aggregator/strategy.ts | 4 + apps/mesh/src/aggregator/tool-aggregator.ts | 2 +- apps/mesh/src/core/constants.ts | 24 + apps/mesh/src/mcp-apps/app-preview-dialog.tsx | 165 +++++++ apps/mesh/src/mcp-apps/csp-injector.ts | 155 ++++++ apps/mesh/src/mcp-apps/index.ts | 14 + apps/mesh/src/mcp-apps/mcp-app-model.ts | 460 ++++++++++++++++++ apps/mesh/src/mcp-apps/mcp-app-renderer.tsx | 211 ++++++++ apps/mesh/src/mcp-apps/resource-loader.ts | 215 ++++++++ apps/mesh/src/mcp-apps/types.ts | 319 ++++++++++++ .../mesh/src/mcp-apps/use-tool-ui-resource.ts | 99 ++++ apps/mesh/src/tools/code-execution/utils.ts | 4 + .../chat/message/parts/tool-call-part.tsx | 209 +++++++- .../details/connection/resources-tab.tsx | 141 +++++- 15 files changed, 2013 insertions(+), 14 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 apps/mesh/src/mcp-apps/app-preview-dialog.tsx create mode 100644 apps/mesh/src/mcp-apps/csp-injector.ts create mode 100644 apps/mesh/src/mcp-apps/index.ts create mode 100644 apps/mesh/src/mcp-apps/mcp-app-model.ts create mode 100644 apps/mesh/src/mcp-apps/mcp-app-renderer.tsx create mode 100644 apps/mesh/src/mcp-apps/resource-loader.ts create mode 100644 apps/mesh/src/mcp-apps/types.ts create mode 100644 apps/mesh/src/mcp-apps/use-tool-ui-resource.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000..555c20cb1e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "allow": ["Bash(git remote prune:*)"] + } +} diff --git a/apps/mesh/src/aggregator/strategy.ts b/apps/mesh/src/aggregator/strategy.ts index 0a41bb9632..8721226e40 100644 --- a/apps/mesh/src/aggregator/strategy.ts +++ b/apps/mesh/src/aggregator/strategy.ts @@ -180,6 +180,10 @@ function createSearchTool(ctx: StrategyContext): ToolWithHandler { name: t.name, description: t.description, connection: t._meta.connectionTitle, + // Include UI resource URI if the tool has an associated MCP App + ...(t._meta?.["ui/resourceUri"] + ? { uiResourceUri: t._meta["ui/resourceUri"] } + : {}), })), totalAvailable: filteredTools.length, }); diff --git a/apps/mesh/src/aggregator/tool-aggregator.ts b/apps/mesh/src/aggregator/tool-aggregator.ts index df6c58b744..6c5df1b75c 100644 --- a/apps/mesh/src/aggregator/tool-aggregator.ts +++ b/apps/mesh/src/aggregator/tool-aggregator.ts @@ -118,7 +118,7 @@ export class ToolAggregator { allTools.push({ ...tool, - _meta: { connectionId, connectionTitle }, + _meta: { ...tool._meta, connectionId, connectionTitle }, }); mappings.set(tool.name, { connectionId, originalName: tool.name }); } diff --git a/apps/mesh/src/core/constants.ts b/apps/mesh/src/core/constants.ts index 24075efa6b..e5c82d9f94 100644 --- a/apps/mesh/src/core/constants.ts +++ b/apps/mesh/src/core/constants.ts @@ -6,3 +6,27 @@ /** MCP Mesh metadata key in tool _meta */ export const MCP_MESH_KEY = "mcp.mesh"; + +/** + * MCP Apps feature flag + * + * When enabled, Mesh will render interactive UIs for tools + * that declare UI resources via _meta["ui/resourceUri"]. + * + * This is an experimental feature and is disabled by default. + */ +export const MCP_APPS_ENABLED = true; + +/** + * MCP Apps configuration + */ +export const MCP_APPS_CONFIG = { + /** Minimum height for MCP App iframes in pixels */ + minHeight: 100, + /** Maximum height for MCP App iframes in pixels */ + maxHeight: 600, + /** Default height for MCP App iframes in pixels */ + defaultHeight: 300, + /** Whether to show raw JSON output alongside MCP Apps in developer mode */ + showRawOutputInDevMode: true, +} as const; diff --git a/apps/mesh/src/mcp-apps/app-preview-dialog.tsx b/apps/mesh/src/mcp-apps/app-preview-dialog.tsx new file mode 100644 index 0000000000..06d7e54363 --- /dev/null +++ b/apps/mesh/src/mcp-apps/app-preview-dialog.tsx @@ -0,0 +1,165 @@ +/** + * App Preview Dialog + * + * Dialog component for previewing MCP Apps in the connection detail page. + * Shows the app in a sandboxed iframe with full interactive capabilities. + */ + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@deco/ui/components/dialog.tsx"; +import { useState } from "react"; +import { MCPAppRenderer } from "./mcp-app-renderer.tsx"; +import type { UIResourcesReadResult, UIToolsCallResult } from "./types.ts"; +import { UIResourceLoader, UIResourceLoadError } from "./resource-loader.ts"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface AppPreviewDialogProps { + /** Whether the dialog is open */ + open: boolean; + /** Callback when the dialog should close */ + onOpenChange: (open: boolean) => void; + /** The URI of the resource to preview */ + uri: string; + /** The name of the resource */ + name?: string; + /** Connection ID for the MCP server */ + connectionId: string; + /** Function to read resources from the MCP server */ + readResource: (uri: string) => Promise<{ + contents: Array<{ + uri: string; + mimeType?: string; + text?: string; + blob?: string; + }>; + }>; + /** Function to call tools on the MCP server */ + callTool: ( + name: string, + args: Record, + ) => Promise; +} + +// ============================================================================ +// Component +// ============================================================================ + +/** + * Dialog for previewing MCP Apps + * + * Fetches the UI resource content and renders it in the MCPAppRenderer. + */ +export function AppPreviewDialog({ + open, + onOpenChange, + uri, + name, + connectionId, + readResource, + callTool, +}: AppPreviewDialogProps) { + const [html, setHtml] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Load the resource when dialog opens + const loadResource = async () => { + if (!open || html) return; + + setLoading(true); + setError(null); + + try { + const loader = new UIResourceLoader(); + const content = await loader.load(uri, readResource); + setHtml(content.html); + } catch (err) { + console.error("Failed to load UI resource:", err); + if (err instanceof UIResourceLoadError) { + setError(err.message); + } else { + setError( + err instanceof Error ? err.message : "Failed to load resource", + ); + } + } finally { + setLoading(false); + } + }; + + // Load resource when dialog opens + if (open && !html && !loading && !error) { + loadResource(); + } + + // Reset state when dialog closes + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + // Reset state after a short delay to allow close animation + setTimeout(() => { + setHtml(null); + setError(null); + }, 200); + } + onOpenChange(newOpen); + }; + + // Wrapper for readResource to match the expected interface + const handleReadResource = async ( + resourceUri: string, + ): Promise => { + const result = await readResource(resourceUri); + return { contents: result.contents }; + }; + + return ( + + + + {name || uri} + + +
+ {loading && ( +
+
+
+ Loading app... +
+
+ )} + + {error && ( +
+
+

Failed to load app

+

{error}

+
+
+ )} + + {html && !loading && !error && ( + + )} +
+ +
+ ); +} diff --git a/apps/mesh/src/mcp-apps/csp-injector.ts b/apps/mesh/src/mcp-apps/csp-injector.ts new file mode 100644 index 0000000000..2d59c459c0 --- /dev/null +++ b/apps/mesh/src/mcp-apps/csp-injector.ts @@ -0,0 +1,155 @@ +/** + * CSP (Content Security Policy) Injector for MCP Apps + * + * Injects security policies into HTML content to restrict what the + * sandboxed iframe can do. This is a defense-in-depth measure on top + * of iframe sandboxing. + */ + +/** + * Default CSP policy for MCP Apps + * + * Restrictions: + * - default-src 'self': Only allow resources from the same origin (which is the srcdoc) + * - script-src 'unsafe-inline': Allow inline scripts (needed for the app to work) + * - style-src 'unsafe-inline': Allow inline styles + * - img-src 'self' data: blob:: Allow images from self, data URIs, and blob URLs + * - font-src 'self' data:: Allow fonts from self and data URIs + * - connect-src 'none': Disable fetch/XHR (communication goes through postMessage) + * - frame-ancestors 'none': Prevent the app from being framed + * - form-action 'none': Disable form submissions + */ +export const DEFAULT_CSP = [ + "default-src 'none'", + "script-src 'unsafe-inline'", + "style-src 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'none'", + "frame-ancestors 'none'", + "form-action 'none'", +].join("; "); + +/** + * Options for CSP injection + */ +export interface CSPInjectorOptions { + /** Custom CSP policy (defaults to DEFAULT_CSP) */ + csp?: string; + /** Allow external connections (relaxes connect-src) */ + allowExternalConnections?: boolean; + /** Allowed external hosts for connect-src */ + allowedHosts?: string[]; +} + +/** + * Inject CSP meta tag into HTML content + * + * This function adds a Content-Security-Policy meta tag to the + * of the HTML document. If no tag exists, one is created. + * + * @param html - The HTML content to inject CSP into + * @param options - CSP injection options + * @returns The HTML content with CSP meta tag injected + */ +export function injectCSP( + html: string, + options: CSPInjectorOptions = {}, +): string { + let csp = options.csp ?? DEFAULT_CSP; + + // If external connections are allowed, update connect-src + if (options.allowExternalConnections) { + const hosts = options.allowedHosts?.join(" ") ?? "*"; + csp = csp.replace("connect-src 'none'", `connect-src ${hosts}`); + } + + const cspMetaTag = ``; + + // Try to inject into existing + const headMatch = html.match(/]*>/i); + if (headMatch) { + const headTagEnd = headMatch.index! + headMatch[0].length; + return ( + html.slice(0, headTagEnd) + "\n " + cspMetaTag + html.slice(headTagEnd) + ); + } + + // Try to inject after or at the start + const doctypeMatch = html.match(/]*>/i); + if (doctypeMatch) { + const afterDoctype = doctypeMatch.index! + doctypeMatch[0].length; + return ( + html.slice(0, afterDoctype) + + "\n\n " + + cspMetaTag + + "\n" + + html.slice(afterDoctype) + ); + } + + // Try to inject before or at the very start + const htmlMatch = html.match(/]*>/i); + if (htmlMatch) { + const afterHtml = htmlMatch.index! + htmlMatch[0].length; + return ( + html.slice(0, afterHtml) + + "\n\n " + + cspMetaTag + + "\n" + + html.slice(afterHtml) + ); + } + + // No structure found, wrap the content + return ` + + + ${cspMetaTag} + + +${html} + +`; +} + +/** + * Validate that HTML content doesn't contain dangerous patterns + * + * This is an additional safety check to prevent obvious attacks. + * The CSP and sandbox should handle most cases, but this catches + * things like script injection via event handlers. + * + * @param html - The HTML content to validate + * @returns Object with isValid flag and any warnings + */ +export function validateHTMLSafety(html: string): { + isValid: boolean; + warnings: string[]; +} { + const warnings: string[] = []; + + // Check for external script sources + const externalScriptPattern = /]+src\s*=\s*["'][^"']+["']/gi; + if (externalScriptPattern.test(html)) { + warnings.push("External script sources detected - will be blocked by CSP"); + } + + // Check for external stylesheet sources + const externalStylePattern = + /]+href\s*=\s*["'][^"']+["'][^>]+rel\s*=\s*["']stylesheet["']/gi; + if (externalStylePattern.test(html)) { + warnings.push("External stylesheets detected - will be blocked by CSP"); + } + + // Check for base tag (could be used to hijack relative URLs) + const baseTagPattern = /]+href/gi; + if (baseTagPattern.test(html)) { + warnings.push("Base tag detected - could affect resource loading"); + } + + return { + isValid: true, // We don't fail, just warn + warnings, + }; +} diff --git a/apps/mesh/src/mcp-apps/index.ts b/apps/mesh/src/mcp-apps/index.ts new file mode 100644 index 0000000000..effd7e84e1 --- /dev/null +++ b/apps/mesh/src/mcp-apps/index.ts @@ -0,0 +1,14 @@ +/** + * MCP Apps - Interactive User Interfaces for MCP + * + * This module provides support for rendering interactive UIs + * declared by MCP servers in the Mesh host application. + */ + +export * from "./types.ts"; +export * from "./csp-injector.ts"; +export * from "./resource-loader.ts"; +export * from "./mcp-app-model.ts"; +export * from "./mcp-app-renderer.tsx"; +export * from "./app-preview-dialog.tsx"; +export * from "./use-tool-ui-resource.ts"; diff --git a/apps/mesh/src/mcp-apps/mcp-app-model.ts b/apps/mesh/src/mcp-apps/mcp-app-model.ts new file mode 100644 index 0000000000..4eb0d13f9d --- /dev/null +++ b/apps/mesh/src/mcp-apps/mcp-app-model.ts @@ -0,0 +1,460 @@ +/** + * MCP App Model + * + * Manages the lifecycle and messaging for an MCP App instance. + * Handles JSON-RPC communication between the host (Mesh) and + * the guest UI (sandboxed iframe). + */ + +import { injectCSP, type CSPInjectorOptions } from "./csp-injector.ts"; +import { + type DisplayMode, + type HostCapabilities, + type HostContext, + type JsonRpcError, + type JsonRpcMessage, + type JsonRpcNotification, + type JsonRpcRequest, + type JsonRpcResponse, + type Theme, + type UIInitializeParams, + type UIInitializeResult, + type UIMessageParams, + type UIOpenLinkParams, + type UIOpenLinkResult, + type UIResourcesReadParams, + type UIResourcesReadResult, + type UISizeChangedParams, + type UIToolResultParams, + type UIToolsCallParams, + type UIToolsCallResult, + isJsonRpcNotification, + isJsonRpcRequest, + isJsonRpcResponse, +} from "./types.ts"; + +// ============================================================================ +// Types +// ============================================================================ + +/** State of the MCP App */ +export type MCPAppState = + | "idle" + | "loading" + | "initializing" + | "ready" + | "error"; + +/** Event types emitted by the model */ +export interface MCPAppModelEvents { + stateChange: (state: MCPAppState) => void; + sizeChange: (params: UISizeChangedParams) => void; + message: (params: UIMessageParams) => void; + error: (error: Error) => void; +} + +/** Options for creating an MCP App model */ +export interface MCPAppModelOptions { + /** The HTML content of the app */ + html: string; + /** The URI of the app resource */ + uri: string; + /** Connection ID for proxying tool calls */ + connectionId: string; + /** Tool name that triggered this app */ + toolName?: string; + /** Tool input arguments */ + toolInput?: unknown; + /** Tool result */ + toolResult?: unknown; + /** Display mode */ + displayMode?: DisplayMode; + /** CSP injection options */ + cspOptions?: CSPInjectorOptions; + /** Function to call tools */ + callTool: ( + name: string, + args: Record, + ) => Promise; + /** Function to read resources */ + readResource: (uri: string) => Promise; + /** Callback when size changes */ + onSizeChange?: (params: UISizeChangedParams) => void; + /** Callback when app sends a message */ + onMessage?: (params: UIMessageParams) => void; + /** Callback for open link requests */ + onOpenLink?: (params: UIOpenLinkParams) => Promise; +} + +// ============================================================================ +// MCP App Model +// ============================================================================ + +/** + * Model for managing an MCP App instance + * + * This class handles: + * - HTML preparation (CSP injection) + * - Iframe message handling + * - JSON-RPC request/response routing + * - Proxying tool calls to the MCP server + */ +export class MCPAppModel { + private state: MCPAppState = "idle"; + private requestId = 0; + private pendingRequests = new Map< + string | number, + { + resolve: (result: unknown) => void; + reject: (error: Error) => void; + } + >(); + private iframe: HTMLIFrameElement | null = null; + private messageHandler: ((event: MessageEvent) => void) | null = null; + private disposed = false; + + /** Prepared HTML with CSP injected */ + public readonly preparedHtml: string; + + /** Host context for initialization */ + private readonly hostContext: HostContext; + + constructor(private readonly options: MCPAppModelOptions) { + // Prepare HTML with CSP injection + this.preparedHtml = injectCSP(options.html, options.cspOptions); + + // Create host context + this.hostContext = this.createHostContext(); + } + + /** + * Get the current state + */ + getState(): MCPAppState { + return this.state; + } + + /** + * Attach to an iframe element + * + * This sets up message handling and initializes the app + * once the iframe loads. + */ + attach(iframe: HTMLIFrameElement): void { + if (this.disposed) { + throw new Error("MCPAppModel has been disposed"); + } + + this.iframe = iframe; + this.setState("loading"); + + // Set up message handler + this.messageHandler = this.handleMessage.bind(this); + window.addEventListener("message", this.messageHandler); + + // The iframe will load via srcdoc, and we'll initialize + // when it sends a ready message or after a short delay + iframe.addEventListener("load", () => { + this.initializeApp(); + }); + } + + /** + * Detach from the iframe and clean up + */ + detach(): void { + if (this.messageHandler) { + window.removeEventListener("message", this.messageHandler); + this.messageHandler = null; + } + this.iframe = null; + + // Reject any pending requests + for (const [_, pending] of this.pendingRequests) { + pending.reject(new Error("MCPAppModel detached")); + } + this.pendingRequests.clear(); + } + + /** + * Dispose of the model + */ + dispose(): void { + this.detach(); + this.disposed = true; + } + + /** + * Send tool result notification to the app + */ + sendToolResult(toolName: string, result: unknown, isError = false): void { + this.sendNotification("ui/notifications/tool-result", { + toolName, + result, + isError, + } satisfies UIToolResultParams); + } + + // ============================================================================ + // Private Methods + // ============================================================================ + + private setState(state: MCPAppState): void { + this.state = state; + } + + private createHostContext(): HostContext { + // Detect theme from document + const isDark = + typeof window !== "undefined" && + window.matchMedia?.("(prefers-color-scheme: dark)").matches; + const theme: Theme = isDark ? "dark" : "light"; + + // Detect device capabilities + const isHoverDevice = + typeof window !== "undefined" && + window.matchMedia?.("(hover: hover)").matches; + const isTouchDevice = + typeof window !== "undefined" && "ontouchstart" in window; + + const capabilities: HostCapabilities = { + displayModes: ["inline", "fullscreen"], + toolCalls: true, + resourceReads: true, + messages: true, + openLinks: true, + externalIframes: false, + }; + + return { + displayMode: this.options.displayMode ?? "inline", + theme, + device: { + isHoverDevice, + isTouchDevice, + }, + capabilities, + instanceId: crypto.randomUUID(), + hostName: "Mesh", + hostVersion: "1.0.0", + }; + } + + private async initializeApp(): Promise { + if (this.state !== "loading" || !this.iframe) { + return; + } + + this.setState("initializing"); + + try { + const params: UIInitializeParams = { + hostContext: this.hostContext, + toolName: this.options.toolName, + toolInput: this.options.toolInput, + toolResult: this.options.toolResult, + }; + + // Send initialize request + const result = await this.sendRequest( + "ui/initialize", + params, + ); + + // App initialized successfully + this.setState("ready"); + + // Handle guest capabilities if provided + if (result?.guestCapabilities) { + // Could update display mode based on preferences + } + } catch (error) { + console.error("Failed to initialize MCP App:", error); + this.setState("error"); + } + } + + private handleMessage(event: MessageEvent): void { + // Verify the message is from our iframe + if (!this.iframe || event.source !== this.iframe.contentWindow) { + return; + } + + let message: JsonRpcMessage; + try { + message = + typeof event.data === "string" ? JSON.parse(event.data) : event.data; + } catch { + console.warn("MCP App sent non-JSON message:", event.data); + return; + } + + // Handle response to our request + if (isJsonRpcResponse(message)) { + this.handleResponse(message); + return; + } + + // Handle request from guest + if (isJsonRpcRequest(message)) { + this.handleRequest(message); + return; + } + + // Handle notification from guest + if (isJsonRpcNotification(message)) { + this.handleNotification(message); + return; + } + } + + private handleResponse(response: JsonRpcResponse): void { + const pending = this.pendingRequests.get(response.id); + if (!pending) { + console.warn("Received response for unknown request:", response.id); + return; + } + + this.pendingRequests.delete(response.id); + + if (response.error) { + pending.reject(new Error(response.error.message)); + } else { + pending.resolve(response.result); + } + } + + private async handleRequest(request: JsonRpcRequest): Promise { + let result: unknown; + let error: JsonRpcError | undefined; + + try { + switch (request.method) { + case "tools/call": { + const params = request.params as UIToolsCallParams; + result = await this.options.callTool( + params.name, + params.arguments ?? {}, + ); + break; + } + + case "resources/read": { + const params = request.params as UIResourcesReadParams; + result = await this.options.readResource(params.uri); + break; + } + + case "ui/open-link": { + const params = request.params as UIOpenLinkParams; + if (this.options.onOpenLink) { + const success = await this.options.onOpenLink(params); + result = { success } satisfies UIOpenLinkResult; + } else { + // Default: open in new tab + window.open(params.url, params.target ?? "_blank"); + result = { success: true } satisfies UIOpenLinkResult; + } + break; + } + + default: + error = { + code: -32601, + message: `Method not found: ${request.method}`, + }; + } + } catch (err) { + error = { + code: -32603, + message: err instanceof Error ? err.message : "Internal error", + }; + } + + // Send response + this.sendResponse(request.id, result, error); + } + + private handleNotification(notification: JsonRpcNotification): void { + switch (notification.method) { + case "ui/notifications/size-changed": { + const params = notification.params as UISizeChangedParams; + this.options.onSizeChange?.(params); + break; + } + + case "ui/message": { + const params = notification.params as UIMessageParams; + this.options.onMessage?.(params); + break; + } + + default: + console.warn("Unknown notification from MCP App:", notification.method); + } + } + + private sendRequest(method: string, params?: unknown): Promise { + return new Promise((resolve, reject) => { + if (!this.iframe?.contentWindow) { + reject(new Error("Iframe not available")); + return; + } + + const id = ++this.requestId; + const request: JsonRpcRequest = { + jsonrpc: "2.0", + id, + method, + params, + }; + + this.pendingRequests.set(id, { + resolve: resolve as (result: unknown) => void, + reject, + }); + + // Set a timeout for the request + setTimeout(() => { + if (this.pendingRequests.has(id)) { + this.pendingRequests.delete(id); + reject(new Error(`Request ${method} timed out`)); + } + }, 30000); + + this.iframe.contentWindow.postMessage(JSON.stringify(request), "*"); + }); + } + + private sendResponse( + id: string | number, + result?: unknown, + error?: JsonRpcError, + ): void { + if (!this.iframe?.contentWindow) { + return; + } + + const response: JsonRpcResponse = { + jsonrpc: "2.0", + id, + ...(error ? { error } : { result }), + }; + + this.iframe.contentWindow.postMessage(JSON.stringify(response), "*"); + } + + private sendNotification(method: string, params?: unknown): void { + if (!this.iframe?.contentWindow) { + return; + } + + const notification: JsonRpcNotification = { + jsonrpc: "2.0", + method, + params, + }; + + this.iframe.contentWindow.postMessage(JSON.stringify(notification), "*"); + } +} diff --git a/apps/mesh/src/mcp-apps/mcp-app-renderer.tsx b/apps/mesh/src/mcp-apps/mcp-app-renderer.tsx new file mode 100644 index 0000000000..fd19ecfc4f --- /dev/null +++ b/apps/mesh/src/mcp-apps/mcp-app-renderer.tsx @@ -0,0 +1,211 @@ +/** + * MCP App Renderer Component + * + * React component that renders an MCP App in a sandboxed iframe. + * Handles lifecycle, sizing, and cleanup. + */ + +import { cn } from "@deco/ui/lib/utils.ts"; +import { useRef, useState } from "react"; +import { MCPAppModel, type MCPAppModelOptions } from "./mcp-app-model.ts"; +import type { + DisplayMode, + UIMessageParams, + UISizeChangedParams, + UIToolsCallResult, + UIResourcesReadResult, +} from "./types.ts"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface MCPAppRendererProps { + /** The HTML content of the app */ + html: string; + /** The URI of the app resource */ + uri: string; + /** Connection ID for proxying tool calls */ + connectionId: string; + /** Tool name that triggered this app */ + toolName?: string; + /** Tool input arguments */ + toolInput?: unknown; + /** Tool result */ + toolResult?: unknown; + /** Display mode */ + displayMode?: DisplayMode; + /** Minimum height in pixels */ + minHeight?: number; + /** Maximum height in pixels */ + maxHeight?: number; + /** Function to call tools */ + callTool: ( + name: string, + args: Record, + ) => Promise; + /** Function to read resources */ + readResource: (uri: string) => Promise; + /** Callback when app sends a message to add to conversation */ + onMessage?: (params: UIMessageParams) => void; + /** Additional CSS class name */ + className?: string; +} + +// ============================================================================ +// Component +// ============================================================================ + +/** + * Renders an MCP App in a sandboxed iframe + * + * This component: + * - Creates a sandboxed iframe with the prepared HTML + * - Sets up the MCPAppModel for message handling + * - Handles dynamic sizing based on app requests + * - Cleans up resources on unmount + */ +export function MCPAppRenderer({ + html, + uri, + connectionId, + toolName, + toolInput, + toolResult, + displayMode = "inline", + minHeight = 100, + maxHeight = 600, + callTool, + readResource, + onMessage, + className, +}: MCPAppRendererProps) { + const iframeRef = useRef(null); + const modelRef = useRef(null); + const [height, setHeight] = useState(minHeight); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Handle size change from the app + const handleSizeChange = (params: UISizeChangedParams) => { + const newHeight = Math.max(minHeight, Math.min(maxHeight, params.height)); + setHeight(newHeight); + }; + + // Handle message from the app + const handleMessage = (params: UIMessageParams) => { + onMessage?.(params); + }; + + // Set up the model when iframe is available + const handleIframeRef = (iframe: HTMLIFrameElement | null) => { + // Clean up previous model + if (modelRef.current) { + modelRef.current.dispose(); + modelRef.current = null; + } + + if (!iframe) { + return; + } + + // Save ref for later access + (iframeRef as React.MutableRefObject).current = + iframe; + + try { + // Create model options + const options: MCPAppModelOptions = { + html, + uri, + connectionId, + toolName, + toolInput, + toolResult, + displayMode, + callTool, + readResource, + onSizeChange: handleSizeChange, + onMessage: handleMessage, + }; + + // Create and attach model + const model = new MCPAppModel(options); + modelRef.current = model; + model.attach(iframe); + + // Update loading state based on model state + const checkState = () => { + const state = model.getState(); + if (state === "ready") { + setIsLoading(false); + } else if (state === "error") { + setIsLoading(false); + setError("Failed to initialize MCP App"); + } + }; + + // Check state periodically until ready + const interval = setInterval(() => { + checkState(); + const state = model.getState(); + if (state === "ready" || state === "error") { + clearInterval(interval); + } + }, 100); + + // Cleanup interval on unmount + return () => clearInterval(interval); + } catch (err) { + console.error("Failed to create MCP App model:", err); + setError(err instanceof Error ? err.message : "Unknown error"); + setIsLoading(false); + } + }; + + // Get prepared HTML from model if available + const preparedHtml = modelRef.current?.preparedHtml ?? html; + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ {isLoading && ( +
+
+
+ Loading app... +
+
+ )} +