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/resource-aggregator.ts b/apps/mesh/src/aggregator/resource-aggregator.ts index 4215dbcaea..2219e48acd 100644 --- a/apps/mesh/src/aggregator/resource-aggregator.ts +++ b/apps/mesh/src/aggregator/resource-aggregator.ts @@ -96,26 +96,32 @@ export class ResourceAggregator { let resources = result.resources; // Apply selection based on mode + // Note: ui:// resources (MCP Apps) are always included regardless of selection mode if (this.options.selectionMode === "exclusion") { - // Exclusion mode: exclude matching resources + // Exclusion mode: exclude matching resources (but always keep ui:// resources) if (entry.selectedResources && entry.selectedResources.length > 0) { resources = resources.filter( - (r) => !matchesAnyPattern(r.uri, entry.selectedResources!), + (r) => + r.uri.startsWith("ui://") || + !matchesAnyPattern(r.uri, entry.selectedResources!), ); } // If selectedResources is null/empty in exclusion mode, include all resources } else { // Inclusion mode: include only selected resources // Resources require explicit selection (patterns or URIs) + // Exception: ui:// resources (MCP Apps) are always included if ( !entry.selectedResources || entry.selectedResources.length === 0 ) { - // No resources selected = no resources from this connection - resources = []; + // No resources selected = only include ui:// resources + resources = resources.filter((r) => r.uri.startsWith("ui://")); } else { - resources = resources.filter((r) => - matchesAnyPattern(r.uri, entry.selectedResources!), + resources = resources.filter( + (r) => + r.uri.startsWith("ui://") || + matchesAnyPattern(r.uri, entry.selectedResources!), ); } } diff --git a/apps/mesh/src/aggregator/strategy.ts b/apps/mesh/src/aggregator/strategy.ts index 0a41bb9632..607ce4f3f7 100644 --- a/apps/mesh/src/aggregator/strategy.ts +++ b/apps/mesh/src/aggregator/strategy.ts @@ -176,11 +176,18 @@ function createSearchTool(ctx: StrategyContext): ToolWithHandler { ); return jsonResult({ query: parsed.data.query, - results: results.map((t) => ({ - name: t.name, - description: t.description, - connection: t._meta.connectionTitle, - })), + results: results.map((t) => { + const meta = t._meta as Record; + return { + name: t.name, + description: t.description, + connection: t._meta.connectionTitle, + // Include UI resource URI if the tool has an associated MCP App + ...(meta?.["ui/resourceUri"] + ? { uiResourceUri: meta["ui/resourceUri"] as string } + : {}), + }; + }), totalAvailable: filteredTools.length, }); }, diff --git a/apps/mesh/src/aggregator/tool-aggregator.ts b/apps/mesh/src/aggregator/tool-aggregator.ts index df6c58b744..60eb6d525b 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 }); } @@ -152,7 +152,25 @@ export class ToolAggregator { arguments: args, }); - return result as CallToolResult; + // Inject connectionId into the result's _meta so the frontend knows + // which connection to use for reading associated resources (e.g., MCP Apps) + const resultWithMeta = result as CallToolResult & { + _meta?: Record; + }; + if (resultWithMeta._meta) { + resultWithMeta._meta = { + ...resultWithMeta._meta, + connectionId: mapping.connectionId, + }; + } else if ( + resultWithMeta._meta === undefined && + "ui/resourceUri" in (result as Record) + ) { + // If _meta doesn't exist but there's a ui/resourceUri at root level (shouldn't happen but be safe) + resultWithMeta._meta = { connectionId: mapping.connectionId }; + } + + return resultWithMeta as CallToolResult; }; // Apply the strategy to transform tools diff --git a/apps/mesh/src/api/routes/decopilot/helpers.ts b/apps/mesh/src/api/routes/decopilot/helpers.ts index 944b6bc63d..37ae56817d 100644 --- a/apps/mesh/src/api/routes/decopilot/helpers.ts +++ b/apps/mesh/src/api/routes/decopilot/helpers.ts @@ -109,10 +109,25 @@ export async function toolsFromMCP( }; } if ("structuredContent" in output) { - return { + // Include _meta if present in the output + const result: { type: "json"; value: JSONValue } = { type: "json", value: output.structuredContent as JSONValue, }; + if ("_meta" in output && output._meta) { + (result.value as Record)._meta = output._meta; + } + return result; + } + // For content type, wrap in an object that includes _meta + if ("_meta" in output && output._meta) { + return { + type: "json", + value: { + content: output.content, + _meta: output._meta, + } as JSONValue, + }; } return { type: "content", value: output.content as any }; }, diff --git a/apps/mesh/src/api/utils/mcp.ts b/apps/mesh/src/api/utils/mcp.ts index c74f2c31ae..28cd76a282 100644 --- a/apps/mesh/src/api/utils/mcp.ts +++ b/apps/mesh/src/api/utils/mcp.ts @@ -79,6 +79,17 @@ export interface ToolDefinition { }; } +/** + * Resource definition for MCP Apps UI resources + */ +export interface ResourceDefinition { + uri: string; + name: string; + description?: string; + mimeType: string; + content: string; +} + /** * Middleware for intercepting call tool requests * Wraps tool execution, allowing pre and post processing @@ -122,6 +133,7 @@ export interface McpServerConfig { class McpServerBuilder { private config: McpServerConfig; private tools: ToolDefinition[] = []; + private resources: ResourceDefinition[] = []; private callToolMiddlewares: CallToolMiddleware[] = []; // Cache JSON Schema conversions to avoid repeated z.toJSONSchema calls // which accumulate in Zod 4's __zod_globalRegistry and cause memory leaks @@ -133,7 +145,7 @@ class McpServerBuilder { constructor(config: McpServerConfig) { this.config = { ...config, - capabilities: config.capabilities ?? { tools: {} }, + capabilities: config.capabilities ?? { tools: {}, resources: {} }, }; } @@ -170,6 +182,22 @@ class McpServerBuilder { return this; } + /** + * Add a resource to the server + */ + withResource(resource: ResourceDefinition): this { + this.resources.push(resource); + return this; + } + + /** + * Add multiple resources to the server + */ + withResources(resources: ResourceDefinition[]): this { + this.resources.push(...resources); + return this; + } + /** * Add middleware for call tool requests * Middleware runs AFTER tool execution @@ -204,14 +232,35 @@ class McpServerBuilder { ): Promise => { try { const result = await tool.handler(args); + + // Check if result contains _meta (for MCP Apps UI resources) + const resultObj = result as Record | null; + const hasMeta = + resultObj && + typeof resultObj === "object" && + "_meta" in resultObj; + const meta = hasMeta + ? (resultObj._meta as Record) + : undefined; + + // Remove _meta from structuredContent to keep it clean + const structuredContent = hasMeta + ? Object.fromEntries( + Object.entries(resultObj).filter(([k]) => k !== "_meta"), + ) + : resultObj; + return { content: [ { type: "text" as const, - text: JSON.stringify(result), + text: JSON.stringify(structuredContent), }, ], - structuredContent: result as { [x: string]: unknown } | undefined, + structuredContent: structuredContent as + | { [x: string]: unknown } + | undefined, + ...(meta ? { _meta: meta } : {}), }; } catch (error) { const err = error as Error; @@ -268,6 +317,27 @@ class McpServerBuilder { ); } + // Register resources (for MCP Apps UI) + for (const resource of this.resources) { + server.resource( + resource.name, + resource.uri, + { + description: resource.description, + mimeType: resource.mimeType, + }, + async () => ({ + contents: [ + { + uri: resource.uri, + mimeType: resource.mimeType, + text: resource.content, + }, + ], + }), + ); + } + return server; }; diff --git a/apps/mesh/src/core/constants.ts b/apps/mesh/src/core/constants.ts index 24075efa6b..d6f09b282e 100644 --- a/apps/mesh/src/core/constants.ts +++ b/apps/mesh/src/core/constants.ts @@ -6,3 +6,25 @@ /** 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"]. + */ +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..d9407cf798 --- /dev/null +++ b/apps/mesh/src/mcp-apps/app-preview-dialog.tsx @@ -0,0 +1,177 @@ +/** + * 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, useRef } 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. + */ +/** + * Component that triggers loading on mount (used to avoid render-time side effects) + */ +function LoadTrigger({ onLoad }: { onLoad: () => void }) { + const loadedRef = useRef(false); + if (!loadedRef.current) { + loadedRef.current = true; + queueMicrotask(onLoad); + } + return null; +} + +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 resource content + const loadResource = () => { + setLoading(true); + setError(null); + (async () => { + 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); + } + })(); + }; + + // Handle dialog close - resets state + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + // Reset state after close animation + setTimeout(() => { + setHtml(null); + setError(null); + }, 200); + } + onOpenChange(newOpen); + }; + + // Determine if we need to trigger a load + const needsLoad = open && !html && !loading && !error; + + // 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} + + + {/* Trigger load when dialog is open and content not loaded */} + {needsLoad && } + +
+ {loading && ( +
+
+
+ Loading app... +
+
+ )} + + {error && ( +
+
+

Failed to load app

+

{error}

+
+
+ )} + + {html && !loading && !error && ( + + )} +
+ +
+ ); +} diff --git a/apps/mesh/src/mcp-apps/csp-injector.test.ts b/apps/mesh/src/mcp-apps/csp-injector.test.ts new file mode 100644 index 0000000000..747cf9f177 --- /dev/null +++ b/apps/mesh/src/mcp-apps/csp-injector.test.ts @@ -0,0 +1,112 @@ +/** + * CSP Injector Tests + */ + +import { describe, expect, it } from "bun:test"; +import { injectCSP, DEFAULT_CSP } from "./csp-injector"; + +describe("CSP Injector", () => { + describe("DEFAULT_CSP", () => { + it("should have default-src 'none'", () => { + expect(DEFAULT_CSP).toContain("default-src 'none'"); + }); + + it("should allow inline scripts and styles", () => { + expect(DEFAULT_CSP).toContain("script-src 'unsafe-inline'"); + expect(DEFAULT_CSP).toContain("style-src 'unsafe-inline'"); + }); + + it("should block external connections by default", () => { + expect(DEFAULT_CSP).toContain("connect-src 'none'"); + }); + + it("should prevent framing", () => { + expect(DEFAULT_CSP).toContain("frame-ancestors 'none'"); + }); + }); + + describe("injectCSP", () => { + it("should inject CSP into existing ", () => { + const html = "Test"; + const result = injectCSP(html); + + expect(result).toContain(' + expect(result.indexOf("")).toBeLessThan( + result.indexOf("Content-Security-Policy"), + ); + }); + + it("should create if missing", () => { + const html = "Content"; + const result = injectCSP(html); + + expect(result).toContain(""); + expect(result).toContain("Content-Security-Policy"); + }); + + it("should work with ", () => { + const html = "Test"; + const result = injectCSP(html); + + expect(result).toContain("Content-Security-Policy"); + expect(result).toContain(""); + }); + + it("should handle uppercase HEAD tag", () => { + const html = "Test"; + const result = injectCSP(html); + + expect(result).toContain("Content-Security-Policy"); + }); + + it("should use custom CSP if provided", () => { + const customCSP = "default-src 'self'"; + const html = ""; + const result = injectCSP(html, { csp: customCSP }); + + expect(result).toContain(customCSP); + expect(result).not.toContain(DEFAULT_CSP); + }); + + describe("external connections", () => { + it("should allow all hosts when allowExternalConnections is true without allowedHosts", () => { + const html = ""; + const result = injectCSP(html, { allowExternalConnections: true }); + + expect(result).toContain("connect-src *"); + expect(result).not.toContain("connect-src 'none'"); + }); + + it("should use specified hosts when allowedHosts is provided", () => { + const html = ""; + const result = injectCSP(html, { + allowExternalConnections: true, + allowedHosts: ["https://api.example.com", "https://cdn.example.com"], + }); + + expect(result).toContain( + "connect-src https://api.example.com https://cdn.example.com", + ); + }); + + it("should treat empty allowedHosts array as wildcard", () => { + const html = ""; + const result = injectCSP(html, { + allowExternalConnections: true, + allowedHosts: [], + }); + + expect(result).toContain("connect-src *"); + }); + + it("should not modify connect-src when allowExternalConnections is false", () => { + const html = ""; + const result = injectCSP(html, { allowExternalConnections: false }); + + expect(result).toContain("connect-src 'none'"); + }); + }); + }); +}); 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..815fdefc8c --- /dev/null +++ b/apps/mesh/src/mcp-apps/csp-injector.ts @@ -0,0 +1,158 @@ +/** + * 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 'none': Block all resources by default + * - 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 && options.allowedHosts.length > 0 + ? 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..c78342b3a1 --- /dev/null +++ b/apps/mesh/src/mcp-apps/mcp-app-model.ts @@ -0,0 +1,512 @@ +/** + * 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", () => { + // Give the widget time to set up its message listener + setTimeout(() => this.initializeApp(), 100); + }); + + // If iframe is already loaded (e.g., from cache or fast load), initialize now + // Check if contentDocument exists and is ready + try { + if ( + iframe.contentDocument?.readyState === "complete" || + iframe.contentWindow + ) { + // Longer delay to ensure the iframe's JS has executed + setTimeout(() => this.initializeApp(), 150); + } + } catch { + // Cross-origin access denied - will rely on load event + } + } + + /** + * 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 initializeAttempts = 0; + private readonly maxInitializeAttempts = 3; + + private async initializeApp(): Promise { + if (this.state !== "loading" || !this.iframe) { + return; + } + + this.setState("initializing"); + await this.attemptInitialize(); + } + + private async attemptInitialize(): Promise { + if (!this.iframe || this.disposed) { + return; + } + + this.initializeAttempts++; + + try { + const params: UIInitializeParams = { + hostContext: this.hostContext, + toolName: this.options.toolName, + toolInput: this.options.toolInput, + toolResult: this.options.toolResult, + }; + + // Send initialize request with shorter timeout for retries + const result = await this.sendRequestWithTimeout( + "ui/initialize", + params, + 5000, // 5 second timeout per attempt + ); + + // App initialized successfully + this.setState("ready"); + + // Handle guest capabilities if provided + if (result?.guestCapabilities) { + // Could update display mode based on preferences + } + } catch (error) { + console.warn( + `Initialize attempt ${this.initializeAttempts} failed:`, + error, + ); + + // Retry if we haven't exceeded max attempts + if (this.initializeAttempts < this.maxInitializeAttempts) { + // Exponential backoff: 200ms, 400ms, 800ms... + const delay = 200 * Math.pow(2, this.initializeAttempts - 1); + setTimeout(() => this.attemptInitialize(), delay); + } else { + console.error( + "Failed to initialize MCP App after", + this.maxInitializeAttempts, + "attempts", + ); + 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 with security features + window.open( + params.url, + params.target ?? "_blank", + "noopener,noreferrer", + ); + 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 sendRequestWithTimeout( + method: string, + params: unknown, + timeoutMs: number, + ): 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`)); + } + }, timeoutMs); + + 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..592903493b --- /dev/null +++ b/apps/mesh/src/mcp-apps/mcp-app-renderer.tsx @@ -0,0 +1,230 @@ +/** + * 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 = 150, + maxHeight = 600, + callTool, + readResource, + onMessage, + className, +}: MCPAppRendererProps) { + const iframeRef = useRef(null); + const modelRef = useRef(null); + const intervalRef = useRef | null>(null); + const prevBoundsRef = useRef({ minHeight, maxHeight }); + const [height, setHeight] = useState(minHeight); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // React to minHeight/maxHeight prop changes (for expand/collapse without remount) + // Always reset to minHeight when bounds change to ensure proper size transition + if ( + prevBoundsRef.current.minHeight !== minHeight || + prevBoundsRef.current.maxHeight !== maxHeight + ) { + prevBoundsRef.current = { minHeight, maxHeight }; + setHeight(minHeight); + } + + // 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 interval + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = 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); + // Clear interval once ready + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + } else if (state === "error") { + setIsLoading(false); + setError("Failed to initialize MCP App"); + // Clear interval on error + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + } + }; + + // Check state periodically until ready + intervalRef.current = setInterval(checkState, 100); + } 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... +
+
+ )} +