From 94fbf5c400a99f85ba18d788694f037d1add6f5a Mon Sep 17 00:00:00 2001 From: marcelo Date: Tue, 9 Dec 2025 13:50:28 -0800 Subject: [PATCH 1/7] initial commit --- .../components/chat-v2/mcp-apps-renderer.tsx | 10 +- .../ui-playground/PlaygroundMain.tsx | 496 ++++++++++++------ .../ui-playground/UIPlaygroundTab.tsx | 42 ++ client/src/stores/ui-playground-store.ts | 20 +- server/routes/mcp/apps.ts | 50 +- 5 files changed, 451 insertions(+), 167 deletions(-) diff --git a/client/src/components/chat-v2/mcp-apps-renderer.tsx b/client/src/components/chat-v2/mcp-apps-renderer.tsx index ed3e810c4..d7a061e59 100644 --- a/client/src/components/chat-v2/mcp-apps-renderer.tsx +++ b/client/src/components/chat-v2/mcp-apps-renderer.tsx @@ -11,6 +11,7 @@ import { useRef, useState, useEffect, useCallback } from "react"; import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; +import { useUIPlaygroundStore, type CspMode } from "@/stores/ui-playground-store"; import { X } from "lucide-react"; import { SandboxedIframe, @@ -80,6 +81,11 @@ export function MCPAppsRenderer({ const sandboxRef = useRef(null); const themeMode = usePreferencesStore((s) => s.themeMode); + // Get CSP mode from playground store when in playground, otherwise use permissive + const isPlaygroundActive = useUIPlaygroundStore((s) => s.isPlaygroundActive); + const playgroundCspMode = useUIPlaygroundStore((s) => s.mcpAppsCspMode); + const cspMode: CspMode = isPlaygroundActive ? playgroundCspMode : "permissive"; + const [displayMode, setDisplayMode] = useState("inline"); const [contentHeight, setContentHeight] = useState(400); const [maxHeight] = useState(800); @@ -120,6 +126,7 @@ export function MCPAppsRenderer({ toolName, theme: themeMode, protocol: "mcp-apps", + cspMode, // Pass CSP mode preference }), }); @@ -131,7 +138,7 @@ export function MCPAppsRenderer({ // Fetch widget content with CSP metadata (SEP-1865) const contentResponse = await fetch( - `/api/mcp/apps/widget-content/${toolCallId}`, + `/api/mcp/apps/widget-content/${toolCallId}?csp_mode=${cspMode}`, ); if (!contentResponse.ok) { const errorData = await contentResponse.json().catch(() => ({})); @@ -162,6 +169,7 @@ export function MCPAppsRenderer({ toolOutput, toolName, themeMode, + cspMode, ]); // UI logging diff --git a/client/src/components/ui-playground/PlaygroundMain.tsx b/client/src/components/ui-playground/PlaygroundMain.tsx index ba97eeac6..04b8f7aae 100644 --- a/client/src/components/ui-playground/PlaygroundMain.tsx +++ b/client/src/components/ui-playground/PlaygroundMain.tsx @@ -59,6 +59,7 @@ import { type DeviceType, type DisplayMode, type CspMode, + type AppProtocol, } from "@/stores/ui-playground-store"; import { SafeAreaEditor } from "./SafeAreaEditor"; import { usePostHog } from "posthog-js/react"; @@ -262,14 +263,27 @@ export function PlaygroundMain({ return () => setPlaygroundActive(false); }, [setPlaygroundActive]); - // CSP mode from store + // CSP mode from store (ChatGPT Apps) const cspMode = useUIPlaygroundStore((s) => s.cspMode); const setCspMode = useUIPlaygroundStore((s) => s.setCspMode); + // CSP mode for MCP Apps (SEP-1865) + const mcpAppsCspMode = useUIPlaygroundStore((s) => s.mcpAppsCspMode); + const setMcpAppsCspMode = useUIPlaygroundStore((s) => s.setMcpAppsCspMode); + + // Currently selected protocol (detected from tool metadata) + const selectedProtocol = useUIPlaygroundStore((s) => s.selectedProtocol); + // Device capabilities from store const capabilities = useUIPlaygroundStore((s) => s.capabilities); const setCapabilities = useUIPlaygroundStore((s) => s.setCapabilities); + // Show ChatGPT Apps controls when: no protocol selected (default) or openai-apps + const showChatGPTControls = + selectedProtocol === null || selectedProtocol === "openai-apps"; + // Show MCP Apps controls when mcp-apps protocol is selected + const showMCPAppsControls = selectedProtocol === "mcp-apps"; + // Check if thread is empty const isThreadEmpty = !messages.some( (msg) => msg.role === "user" || msg.role === "assistant", @@ -452,168 +466,346 @@ export function PlaygroundMain({
{/* Device frame header */}
- {/* Device type toggle - flex-1 to balance with right section */} -
- v && onDeviceTypeChange?.(v as DeviceType)} - className="gap-0.5" - > - - - - + {showChatGPTControls && ( + v && onDeviceTypeChange?.(v as DeviceType)} + className="gap-0.5" > - - - - - - + + + + + + + + + + + )} + {showMCPAppsControls && ( + <> + v && onDeviceTypeChange?.(v as DeviceType)} + className="gap-0.5" + > + + + + + + + + + + + + SEP-1865 + + + )}
- {/* Device label, locale, and theme */} + {/* Center section - Protocol-specific controls */}
-
- - {deviceConfig.label} - - ({deviceConfig.width}×{deviceConfig.height}) - -
+ {/* ChatGPT Apps: Device label and locale */} + {showChatGPTControls && ( + <> +
+ + {deviceConfig.label} + + ({deviceConfig.width}×{deviceConfig.height}) + +
- {/* Locale selector */} - - -
- + {/* Locale selector */} + + +
+ +
+
+ +

Locale

+
+
+ + {/* CSP mode selector (ChatGPT Apps) */} + + +
+ +
+
+ +

CSP

+
+
+ + {/* Capabilities toggles */} +
+ + + + + +

Hover

+

+ {capabilities.hover ? "Enabled" : "Disabled"} +

+
+
+ + + + + +

Touch

+

+ {capabilities.touch ? "Enabled" : "Disabled"} +

+
+
- - -

Locale

-
- - {/* CSP mode selector */} - - -
- + {/* Safe area editor */} + + + )} + + {/* MCP Apps controls */} + {showMCPAppsControls && ( + <> + {/* Device info */} +
+ + {deviceConfig.label} + + ({deviceConfig.width}×{deviceConfig.height}) +
- - -

CSP

-
- - {/* Capabilities toggles */} -
- - - - - -

Hover

-

- {capabilities.hover ? "Enabled" : "Disabled"} -

-
-
- - - - - -

Touch

-

- {capabilities.touch ? "Enabled" : "Disabled"} -

-
-
-
+ {/* Locale selector */} + + +
+ +
+
+ +

Locale

+
+
+ + {/* CSP mode selector */} + + +
+ +
+
+ +

CSP Mode

+

+ Content Security Policy for MCP Apps +

+
+
+ + {/* Device capabilities toggles */} +
+ + + + + +

Hover

+

+ {capabilities.hover ? "Enabled" : "Disabled"} +

+
+
+ + + + + +

Touch

+

+ {capabilities.touch ? "Enabled" : "Disabled"} +

+
+
+
- {/* Safe area editor */} - + {/* Safe area editor */} + + + )} - {/* Theme toggle */} + {/* Theme toggle - shown for both protocols */}
From 9245d2ff93bed78b28e81f26d447fb54fe6c055e Mon Sep 17 00:00:00 2001 From: marcelo Date: Tue, 9 Dec 2025 16:34:58 -0800 Subject: [PATCH 3/7] add permissive csp --- .../components/chat-v2/mcp-apps-renderer.tsx | 139 ++++- client/src/components/chat-v2/thread.tsx | 51 +- .../ui-playground/PlaygroundMain.tsx | 505 ++++++++++++------ client/src/components/ui/sandboxed-iframe.tsx | 13 +- client/src/stores/widget-debug-store.ts | 22 + server/routes/mcp/apps.ts | 14 +- server/routes/mcp/sandbox-proxy.html | 80 ++- 7 files changed, 604 insertions(+), 220 deletions(-) diff --git a/client/src/components/chat-v2/mcp-apps-renderer.tsx b/client/src/components/chat-v2/mcp-apps-renderer.tsx index d7a061e59..5c619c360 100644 --- a/client/src/components/chat-v2/mcp-apps-renderer.tsx +++ b/client/src/components/chat-v2/mcp-apps-renderer.tsx @@ -86,6 +86,16 @@ export function MCPAppsRenderer({ const playgroundCspMode = useUIPlaygroundStore((s) => s.mcpAppsCspMode); const cspMode: CspMode = isPlaygroundActive ? playgroundCspMode : "permissive"; + // Get locale and timeZone from playground store when active, fallback to browser defaults + const playgroundLocale = useUIPlaygroundStore((s) => s.globals.locale); + const playgroundTimeZone = useUIPlaygroundStore((s) => s.globals.timeZone); + const locale = isPlaygroundActive + ? playgroundLocale + : navigator.language || "en-US"; + const timeZone = isPlaygroundActive + ? playgroundTimeZone + : Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const [displayMode, setDisplayMode] = useState("inline"); const [contentHeight, setContentHeight] = useState(400); const [maxHeight] = useState(800); @@ -95,6 +105,8 @@ export function MCPAppsRenderer({ const [widgetCsp, setWidgetCsp] = useState( undefined, ); + const [widgetPermissive, setWidgetPermissive] = useState(false); + const [loadedCspMode, setLoadedCspMode] = useState(null); const pendingRequests = useRef< Map< @@ -106,10 +118,11 @@ export function MCPAppsRenderer({ > >(new Map()); - // Fetch widget HTML when tool output is available + // Fetch widget HTML when tool output is available or CSP mode changes useEffect(() => { if (toolState !== "output-available") return; - if (widgetHtml) return; + // Re-fetch if CSP mode changed (widget needs to reload with new CSP policy) + if (widgetHtml && loadedCspMode === cspMode) return; const fetchWidgetHtml = async () => { try { @@ -148,9 +161,11 @@ export function MCPAppsRenderer({ ); } - const { html, csp } = await contentResponse.json(); + const { html, csp, permissive } = await contentResponse.json(); setWidgetHtml(html); setWidgetCsp(csp); + setWidgetPermissive(permissive ?? false); + setLoadedCspMode(cspMode); } catch (err) { setLoadError( err instanceof Error ? err.message : "Failed to prepare widget", @@ -163,6 +178,7 @@ export function MCPAppsRenderer({ toolState, toolCallId, widgetHtml, + loadedCspMode, serverId, resourceUri, toolInput, @@ -178,6 +194,15 @@ export function MCPAppsRenderer({ // Widget debug store const setWidgetDebugInfo = useWidgetDebugStore((s) => s.setWidgetDebugInfo); const setWidgetGlobals = useWidgetDebugStore((s) => s.setWidgetGlobals); + const addCspViolation = useWidgetDebugStore((s) => s.addCspViolation); + const clearCspViolations = useWidgetDebugStore((s) => s.clearCspViolations); + + // Clear CSP violations when CSP mode changes (stale data from previous mode) + useEffect(() => { + if (loadedCspMode !== null && loadedCspMode !== cspMode) { + clearCspViolations(toolCallId); + } + }, [cspMode, loadedCspMode, toolCallId, clearCspViolations]); // Initialize widget debug info useEffect(() => { @@ -189,7 +214,8 @@ export function MCPAppsRenderer({ theme: themeMode, displayMode, maxHeight, - locale: navigator.language, + locale, + timeZone, }, }); }, [ @@ -199,6 +225,8 @@ export function MCPAppsRenderer({ themeMode, displayMode, maxHeight, + locale, + timeZone, ]); // Update globals in debug store when they change @@ -207,8 +235,10 @@ export function MCPAppsRenderer({ theme: themeMode, displayMode, maxHeight, + locale, + timeZone, }); - }, [toolCallId, themeMode, displayMode, maxHeight, setWidgetGlobals]); + }, [toolCallId, themeMode, displayMode, maxHeight, locale, timeZone, setWidgetGlobals]); // JSON-RPC helpers const postMessage = useCallback( @@ -252,6 +282,47 @@ export function MCPAppsRenderer({ // Handle messages from guest UI (via SandboxedIframe) const handleMessage = useCallback( async (event: MessageEvent) => { + // Handle CSP violation messages (not JSON-RPC) + if (event.data?.type === "mcp-apps:csp-violation") { + const { + directive, + blockedUri, + sourceFile, + lineNumber, + columnNumber, + effectiveDirective, + timestamp, + } = event.data; + + // Log incoming CSP violation + addUiLog({ + widgetId: toolCallId, + serverId, + direction: "ui-to-host", + protocol: "mcp-apps", + method: "csp-violation", + message: event.data, + }); + + // Add violation to widget debug store for display in CSP panel + addCspViolation(toolCallId, { + directive, + effectiveDirective, + blockedUri, + sourceFile, + lineNumber, + columnNumber, + timestamp: timestamp || Date.now(), + }); + + // Also log to console for developers + console.warn( + `[MCP Apps CSP Violation] ${directive}: Blocked ${blockedUri}`, + sourceFile ? `at ${sourceFile}:${lineNumber}:${columnNumber}` : "", + ); + return; + } + const { jsonrpc, id, method, params, result, error } = event.data as JSONRPCMessage; @@ -295,7 +366,8 @@ export function MCPAppsRenderer({ theme: themeMode, displayMode, viewport: { width: 400, height: contentHeight, maxHeight }, - locale: navigator.language, + locale, + timeZone, platform: "web", }, }); @@ -426,6 +498,8 @@ export function MCPAppsRenderer({ displayMode, contentHeight, maxHeight, + locale, + timeZone, onCallTool, onSendFollowUp, serverId, @@ -436,31 +510,55 @@ export function MCPAppsRenderer({ sendResponse, sendNotification, addUiLog, + addCspViolation, ], ); - // Track previous theme to avoid sending redundant notifications on mount - // (theme is already included in McpUiInitializeResult.hostContext) - const prevThemeRef = useRef(null); + // Track previous host context to send only changed fields (per SEP-1865) + const prevHostContextRef = useRef<{ + theme: string; + displayMode: DisplayMode; + locale: string; + timeZone: string; + maxHeight: number; + } | null>(null); - // Send theme updates only when theme actually changes (not on initial mount) + // Send host-context-changed notifications when any context field changes useEffect(() => { if (!isReady) return; - // Skip initial mount - theme was already sent in initialize response - if (prevThemeRef.current === null) { - prevThemeRef.current = themeMode; + const currentContext = { theme: themeMode, displayMode, locale, timeZone, maxHeight }; + + // Skip initial mount - context was already sent in initialize response + if (prevHostContextRef.current === null) { + prevHostContextRef.current = currentContext; return; } - // Only send notification if theme actually changed - if (prevThemeRef.current !== themeMode) { - prevThemeRef.current = themeMode; - sendNotification("ui/notifications/host-context-changed", { - theme: themeMode, - }); + // Build partial update with only changed fields (per SEP-1865 spec) + const changedFields: Record = {}; + if (prevHostContextRef.current.theme !== themeMode) { + changedFields.theme = themeMode; + } + if (prevHostContextRef.current.displayMode !== displayMode) { + changedFields.displayMode = displayMode; + } + if (prevHostContextRef.current.locale !== locale) { + changedFields.locale = locale; + } + if (prevHostContextRef.current.timeZone !== timeZone) { + changedFields.timeZone = timeZone; + } + if (prevHostContextRef.current.maxHeight !== maxHeight) { + changedFields.viewport = { maxHeight }; + } + + // Only send notification if something changed + if (Object.keys(changedFields).length > 0) { + prevHostContextRef.current = currentContext; + sendNotification("ui/notifications/host-context-changed", changedFields); } - }, [themeMode, isReady, sendNotification]); + }, [themeMode, displayMode, locale, timeZone, maxHeight, isReady, sendNotification]); // Loading states if (toolState !== "output-available") { @@ -525,6 +623,7 @@ export function MCPAppsRenderer({ html={widgetHtml} sandbox="allow-scripts allow-same-origin allow-forms allow-popups" csp={widgetCsp} + permissive={widgetPermissive} onMessage={handleMessage} title={`MCP App: ${toolName}`} className="w-full border border-border/40 rounded-md bg-background transition-[height] duration-200 ease-out overflow-auto" diff --git a/client/src/components/chat-v2/thread.tsx b/client/src/components/chat-v2/thread.tsx index ec3966260..a34c1ae87 100644 --- a/client/src/components/chat-v2/thread.tsx +++ b/client/src/components/chat-v2/thread.tsx @@ -11,7 +11,7 @@ import { remoteTextDefinition, } from "@mcp-ui/client"; import { UITools, ToolUIPart, DynamicToolUIPart } from "ai"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { ChevronDown, MessageCircle, @@ -291,7 +291,7 @@ function PartSwitch({ if (uiResource) { return ( <> - + - +
Failed to load server id or resource uri for MCP App.
@@ -314,7 +314,7 @@ function PartSwitch({ return ( <> - + - +
Waiting for tool to finish executing...
@@ -352,7 +352,7 @@ function PartSwitch({ if (!serverId) { return ( <> - +
Failed to load tool server id.
@@ -364,6 +364,7 @@ function PartSwitch({ <> ; + return ; } if (isDataPart(part)) { @@ -436,6 +437,7 @@ function TextPart({ text, role }: { text: string; role: UIMessage["role"] }) { function ToolPart({ part, + uiType, displayMode, onDisplayModeChange, onRequestFullscreen, @@ -444,6 +446,7 @@ function ToolPart({ onExitPip, }: { part: ToolUIPart | DynamicToolUIPart; + uiType?: UIType | null; displayMode?: DisplayMode; onDisplayModeChange?: (mode: DisplayMode) => void; onRequestFullscreen?: (toolCallId: string) => void; @@ -496,21 +499,33 @@ function ToolPart({ { mode: "fullscreen", icon: Maximize2, label: "Fullscreen" }, ]; - const debugOptions: { - tab: "data" | "state" | "csp"; - icon: typeof Database; - label: string; - badge?: number; - }[] = [ - { tab: "data", icon: Database, label: "Data" }, - { tab: "state", icon: Box, label: "Widget State" }, - { + // Debug options - filter based on protocol + // Widget State is only available for ChatGPT Apps (OpenAI SDK), not for MCP Apps + const debugOptions = useMemo(() => { + const options: { + tab: "data" | "state" | "csp"; + icon: typeof Database; + label: string; + badge?: number; + }[] = [ + { tab: "data", icon: Database, label: "Data" }, + ]; + + // Only show Widget State for ChatGPT Apps (OpenAI SDK) + // MCP Apps (SEP-1865) don't support persistent widget state + if (uiType === UIType.OPENAI_SDK) { + options.push({ tab: "state", icon: Box, label: "Widget State" }); + } + + options.push({ tab: "csp", icon: Shield, label: "CSP", badge: widgetDebugInfo?.csp?.violations?.length, - }, - ]; + }); + + return options; + }, [uiType, widgetDebugInfo?.csp?.violations?.length]); const handleDebugClick = (tab: "data" | "state" | "csp") => { if (activeDebugTab === tab) { diff --git a/client/src/components/ui-playground/PlaygroundMain.tsx b/client/src/components/ui-playground/PlaygroundMain.tsx index f169b9995..44814c73d 100644 --- a/client/src/components/ui-playground/PlaygroundMain.tsx +++ b/client/src/components/ui-playground/PlaygroundMain.tsx @@ -57,6 +57,7 @@ import { type DeviceType, type DisplayMode, type CspMode, + type AppProtocol, } from "@/stores/ui-playground-store"; import { SafeAreaEditor } from "./SafeAreaEditor"; import { usePostHog } from "posthog-js/react"; @@ -261,14 +262,31 @@ export function PlaygroundMain({ return () => setPlaygroundActive(false); }, [setPlaygroundActive]); - // CSP mode from store + // CSP mode from store (ChatGPT Apps) const cspMode = useUIPlaygroundStore((s) => s.cspMode); const setCspMode = useUIPlaygroundStore((s) => s.setCspMode); + // CSP mode for MCP Apps (SEP-1865) + const mcpAppsCspMode = useUIPlaygroundStore((s) => s.mcpAppsCspMode); + const setMcpAppsCspMode = useUIPlaygroundStore((s) => s.setMcpAppsCspMode); + + // Currently selected protocol (detected from tool metadata) + const selectedProtocol = useUIPlaygroundStore((s) => s.selectedProtocol); + + // Protocol-aware CSP mode: use the correct store based on detected protocol + const activeCspMode = selectedProtocol === "mcp-apps" ? mcpAppsCspMode : cspMode; + const setActiveCspMode = selectedProtocol === "mcp-apps" ? setMcpAppsCspMode : setCspMode; + // Device capabilities from store const capabilities = useUIPlaygroundStore((s) => s.capabilities); const setCapabilities = useUIPlaygroundStore((s) => s.setCapabilities); + // Show ChatGPT Apps controls when: no protocol selected (default) or openai-apps + const showChatGPTControls = + selectedProtocol === null || selectedProtocol === "openai-apps"; + // Show MCP Apps controls when mcp-apps protocol is selected + const showMCPAppsControls = selectedProtocol === "mcp-apps"; + // Check if thread is empty const isThreadEmpty = !messages.some( (msg) => msg.role === "user" || msg.role === "assistant", @@ -468,170 +486,347 @@ export function PlaygroundMain({
{/* All controls centered */}
- {/* Device type selector */} - - -
- onDeviceTypeChange?.(v as DeviceType)} + > + + + {deviceConfig.label} + + + {(Object.entries(DEVICE_CONFIGS) as [DeviceType, typeof deviceConfig][]).map( + ([type, config]) => { + const Icon = config.icon; + return ( + + + + {config.label} + + ({config.width}×{config.height}) + + + + ); + } + )} + + +
+
+ +

Device

+
+
+ + {/* Locale selector */} + + +
+ +
+
+ +

Locale

+
+
+ + {/* CSP mode selector - uses protocol-aware store */} + + +
+ +
+
+ +

CSP

+
+
+ + {/* Capabilities toggles */} +
+ + +
- - -

Device

-
- - {/* Locale selector */} - - -
- -
-
- -

Locale

-
-
- - {/* CSP mode selector */} - - -
- + className="h-7 w-7" + > + + + + +

Hover

+

+ {capabilities.hover ? "Enabled" : "Disabled"} +

+
+ + + + + + +

Touch

+

+ {capabilities.touch ? "Enabled" : "Disabled"} +

+
+
-
- -

CSP

-
-
- {/* Capabilities toggles */} -
- - - - - -

Hover

-

- {capabilities.hover ? "Enabled" : "Disabled"} -

-
-
- - - - - -

Touch

-

- {capabilities.touch ? "Enabled" : "Disabled"} -

-
-
-
+ {/* Safe area editor */} + + +
+ +
+
+ +

Safe Area

+
+
+ + )} - {/* Safe area editor */} - - -
- + {/* MCP Apps controls (SEP-1865) */} + {showMCPAppsControls && ( + <> + {/* Device type selector */} + + +
+ +
+
+ +

Device

+
+
+ + {/* Locale selector */} + + +
+ +
+
+ +

Locale

+
+
+ + {/* CSP mode selector */} + + +
+ +
+
+ +

CSP

+
+
+ + {/* Capabilities toggles */} +
+ + + + + +

Hover

+

+ {capabilities.hover ? "Enabled" : "Disabled"} +

+
+
+ + + + + +

Touch

+

+ {capabilities.touch ? "Enabled" : "Disabled"} +

+
+
- - -

Safe Area

-
- + + {/* Safe area editor */} + + +
+ +
+
+ +

Safe Area

+
+
+ + )} {/* Theme toggle */} diff --git a/client/src/components/ui/sandboxed-iframe.tsx b/client/src/components/ui/sandboxed-iframe.tsx index 6627dc589..e6b70d39a 100644 --- a/client/src/components/ui/sandboxed-iframe.tsx +++ b/client/src/components/ui/sandboxed-iframe.tsx @@ -40,6 +40,8 @@ interface SandboxedIframeProps { sandbox?: string; /** CSP metadata from resource _meta.ui.csp (SEP-1865) */ csp?: UIResourceCSP; + /** Skip CSP injection entirely (for permissive/testing mode) */ + permissive?: boolean; /** Callback when sandbox proxy is ready */ onProxyReady?: () => void; /** Callback for messages from guest UI (excluding sandbox-internal messages) */ @@ -68,6 +70,7 @@ export const SandboxedIframe = forwardRef< html, sandbox = "allow-scripts allow-same-origin allow-forms allow-popups", csp, + permissive, onProxyReady, onMessage, className, @@ -126,6 +129,12 @@ export const SandboxedIframe = forwardRef< } if (event.source !== outerRef.current?.contentWindow) return; + // CSP violation messages (not JSON-RPC) - forward directly + if (event.data?.type === "mcp-apps:csp-violation") { + onMessage(event); + return; + } + const { jsonrpc, method } = (event.data as { jsonrpc?: string; method?: string }) || {}; if (jsonrpc !== "2.0") return; @@ -158,11 +167,11 @@ export const SandboxedIframe = forwardRef< { jsonrpc: "2.0", method: "ui/notifications/sandbox-resource-ready", - params: { html, sandbox, csp }, + params: { html, sandbox, csp, permissive }, }, sandboxProxyOrigin, ); - }, [proxyReady, html, sandbox, csp, sandboxProxyOrigin]); + }, [proxyReady, html, sandbox, csp, permissive, sandboxProxyOrigin]); return (