diff --git a/client/src/components/chat-v2/mcp-apps-renderer.tsx b/client/src/components/chat-v2/mcp-apps-renderer.tsx index ed3e810c4..9b87715ca 100644 --- a/client/src/components/chat-v2/mcp-apps-renderer.tsx +++ b/client/src/components/chat-v2/mcp-apps-renderer.tsx @@ -9,8 +9,9 @@ * Uses SandboxedIframe for DRY double-iframe setup. */ -import { useRef, useState, useEffect, useCallback } from "react"; +import { useRef, useState, useEffect, useCallback, useMemo } from "react"; import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; +import { useUIPlaygroundStore, DEVICE_VIEWPORT_CONFIGS, type CspMode, type DeviceType } from "@/stores/ui-playground-store"; import { X } from "lucide-react"; import { SandboxedIframe, @@ -53,6 +54,12 @@ interface MCPAppsRendererProps { pipWidgetId?: string | null; onRequestPip?: (toolCallId: string) => void; onExitPip?: (toolCallId: string) => void; + /** Controlled display mode - when provided, component uses this instead of internal state */ + displayMode?: DisplayMode; + /** Callback when display mode changes - required when displayMode is controlled */ + onDisplayModeChange?: (mode: DisplayMode) => void; + onRequestFullscreen?: (toolCallId: string) => void; + onExitFullscreen?: (toolCallId: string) => void; } interface JSONRPCMessage { @@ -72,23 +79,130 @@ export function MCPAppsRenderer({ toolInput, toolOutput, resourceUri, + toolMetadata, onSendFollowUp, onCallTool, pipWidgetId, + onRequestPip, onExitPip, + displayMode: displayModeProp, + onDisplayModeChange, + onRequestFullscreen, + onExitFullscreen, }: MCPAppsRendererProps) { const sandboxRef = useRef(null); const themeMode = usePreferencesStore((s) => s.themeMode); - const [displayMode, setDisplayMode] = useState("inline"); + // 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"; + + // 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"; + + // Get displayMode from playground store when active (SEP-1865) + const playgroundDisplayMode = useUIPlaygroundStore((s) => s.displayMode); + + // Get device capabilities from playground store (SEP-1865) + const playgroundCapabilities = useUIPlaygroundStore((s) => s.capabilities); + const deviceCapabilities = useMemo( + () => + isPlaygroundActive + ? playgroundCapabilities + : { hover: true, touch: false }, // Desktop defaults + [isPlaygroundActive, playgroundCapabilities], + ); + + // Get safe area insets from playground store (SEP-1865) + const playgroundSafeAreaInsets = useUIPlaygroundStore((s) => s.safeAreaInsets); + const safeAreaInsets = useMemo( + () => + isPlaygroundActive + ? playgroundSafeAreaInsets + : { top: 0, right: 0, bottom: 0, left: 0 }, + [isPlaygroundActive, playgroundSafeAreaInsets], + ); + + // Get device type from playground store for platform/viewport derivation (SEP-1865) + const playgroundDeviceType = useUIPlaygroundStore((s) => s.deviceType); + + // Derive platform from device type per SEP-1865 (web | desktop | mobile) + const platform = useMemo((): "web" | "desktop" | "mobile" => { + if (!isPlaygroundActive) return "web"; + switch (playgroundDeviceType) { + case "mobile": + case "tablet": + return "mobile"; + case "desktop": + default: + return "web"; + } + }, [isPlaygroundActive, playgroundDeviceType]); + + // Derive viewport dimensions from device type (using shared config) + const viewportWidth = useMemo(() => { + if (!isPlaygroundActive) return 400; + return DEVICE_VIEWPORT_CONFIGS[playgroundDeviceType]?.width ?? 400; + }, [isPlaygroundActive, playgroundDeviceType]); + + const viewportHeight = useMemo(() => { + if (!isPlaygroundActive) return 400; + return DEVICE_VIEWPORT_CONFIGS[playgroundDeviceType]?.height ?? 400; + }, [isPlaygroundActive, playgroundDeviceType]); + + // Display mode: controlled (via props) or uncontrolled (internal state) + const isControlled = displayModeProp !== undefined; + const [internalDisplayMode, setInternalDisplayMode] = useState( + isPlaygroundActive ? playgroundDisplayMode : "inline" + ); + const displayMode = isControlled ? displayModeProp : internalDisplayMode; + const setDisplayMode = useCallback( + (mode: DisplayMode) => { + if (isControlled) { + onDisplayModeChange?.(mode); + } else { + setInternalDisplayMode(mode); + } + + // Notify parent about fullscreen state changes regardless of controlled mode + if (mode === "fullscreen") { + onRequestFullscreen?.(toolCallId); + } else if (displayMode === "fullscreen") { + onExitFullscreen?.(toolCallId); + } + }, + [ + isControlled, + onDisplayModeChange, + toolCallId, + onRequestFullscreen, + onExitFullscreen, + displayMode, + ], + ); const [contentHeight, setContentHeight] = useState(400); - const [maxHeight] = useState(800); + + // maxHeight should match the device's viewport height (max available space) + const maxHeight = useMemo(() => { + if (!isPlaygroundActive) return 800; + return DEVICE_VIEWPORT_CONFIGS[playgroundDeviceType]?.height ?? 800; + }, [isPlaygroundActive, playgroundDeviceType]); const [isReady, setIsReady] = useState(false); const [loadError, setLoadError] = useState(null); const [widgetHtml, setWidgetHtml] = useState(null); const [widgetCsp, setWidgetCsp] = useState( undefined, ); + const [widgetPermissive, setWidgetPermissive] = useState(false); + const [loadedCspMode, setLoadedCspMode] = useState(null); const pendingRequests = useRef< Map< @@ -100,10 +214,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 { @@ -120,6 +235,7 @@ export function MCPAppsRenderer({ toolName, theme: themeMode, protocol: "mcp-apps", + cspMode, // Pass CSP mode preference }), }); @@ -131,7 +247,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(() => ({})); @@ -141,9 +257,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", @@ -156,12 +274,14 @@ export function MCPAppsRenderer({ toolState, toolCallId, widgetHtml, + loadedCspMode, serverId, resourceUri, toolInput, toolOutput, toolName, themeMode, + cspMode, ]); // UI logging @@ -170,6 +290,23 @@ 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]); + + // Sync displayMode from playground store when it changes (SEP-1865) + // Only sync when not in controlled mode (parent controls displayMode via props) + useEffect(() => { + if (isPlaygroundActive && !isControlled) { + setInternalDisplayMode(playgroundDisplayMode); + } + }, [isPlaygroundActive, playgroundDisplayMode, isControlled]); // Initialize widget debug info useEffect(() => { @@ -181,7 +318,10 @@ export function MCPAppsRenderer({ theme: themeMode, displayMode, maxHeight, - locale: navigator.language, + locale, + timeZone, + deviceCapabilities, + safeAreaInsets, }, }); }, [ @@ -191,6 +331,10 @@ export function MCPAppsRenderer({ themeMode, displayMode, maxHeight, + locale, + timeZone, + deviceCapabilities, + safeAreaInsets, ]); // Update globals in debug store when they change @@ -199,8 +343,22 @@ export function MCPAppsRenderer({ theme: themeMode, displayMode, maxHeight, + locale, + timeZone, + deviceCapabilities, + safeAreaInsets, }); - }, [toolCallId, themeMode, displayMode, maxHeight, setWidgetGlobals]); + }, [ + toolCallId, + themeMode, + displayMode, + maxHeight, + locale, + timeZone, + deviceCapabilities, + safeAreaInsets, + setWidgetGlobals, + ]); // JSON-RPC helpers const postMessage = useCallback( @@ -244,6 +402,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; @@ -286,9 +485,22 @@ export function MCPAppsRenderer({ hostContext: { theme: themeMode, displayMode, - viewport: { width: 400, height: contentHeight, maxHeight }, - locale: navigator.language, - platform: "web", + availableDisplayModes: ["inline", "pip", "fullscreen"], + viewport: { width: viewportWidth, height: viewportHeight, maxHeight }, + locale, + timeZone, + platform, + userAgent: navigator.userAgent, + deviceCapabilities, + safeAreaInsets, + toolInfo: { + id: toolCallId, + tool: { + name: toolName, + inputSchema: (toolMetadata?.inputSchema as object) ?? { type: "object" }, + ...(toolMetadata?.description && { description: toolMetadata.description }), + }, + }, }, }); setIsReady(true); @@ -399,7 +611,9 @@ export function MCPAppsRenderer({ } break; + case "ui/notifications/size-changed": // SEP-1865 spec case "ui/notifications/size-change": { + // Support both for backwards compatibility const sizeParams = params as { height?: number }; if (typeof sizeParams.height === "number") { setContentHeight(Math.min(sizeParams.height, maxHeight)); @@ -418,6 +632,14 @@ export function MCPAppsRenderer({ displayMode, contentHeight, maxHeight, + locale, + timeZone, + platform, + viewportWidth, + deviceCapabilities, + safeAreaInsets, + toolName, + toolMetadata, onCallTool, onSendFollowUp, serverId, @@ -428,31 +650,94 @@ 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); - - // Send theme updates only when theme actually changes (not on initial mount) + // Track previous host context to send only changed fields (per SEP-1865) + const prevHostContextRef = useRef<{ + theme: string; + displayMode: DisplayMode; + locale: string; + timeZone: string; + platform: "web" | "desktop" | "mobile"; + viewportWidth: number; + viewportHeight: number; + maxHeight: number; + deviceCapabilities: { touch?: boolean; hover?: boolean }; + safeAreaInsets: { top: number; right: number; bottom: number; left: number }; + } | null>(null); + + // 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, + platform, + viewportWidth, + viewportHeight, + maxHeight, + deviceCapabilities, + safeAreaInsets, + }; + + // 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.platform !== platform) { + changedFields.platform = platform; + } + // Send full viewport object when any viewport property changes + if ( + prevHostContextRef.current.viewportWidth !== viewportWidth || + prevHostContextRef.current.viewportHeight !== viewportHeight || + prevHostContextRef.current.maxHeight !== maxHeight + ) { + changedFields.viewport = { width: viewportWidth, height: viewportHeight, maxHeight }; + } + // Compare deviceCapabilities (simple object with booleans) + const prevCaps = prevHostContextRef.current.deviceCapabilities; + if (prevCaps.touch !== deviceCapabilities.touch || prevCaps.hover !== deviceCapabilities.hover) { + changedFields.deviceCapabilities = deviceCapabilities; + } + // Compare safeAreaInsets (simple object with numbers) + const prevInsets = prevHostContextRef.current.safeAreaInsets; + if ( + prevInsets.top !== safeAreaInsets.top || + prevInsets.right !== safeAreaInsets.right || + prevInsets.bottom !== safeAreaInsets.bottom || + prevInsets.left !== safeAreaInsets.left + ) { + changedFields.safeAreaInsets = safeAreaInsets; } - }, [themeMode, isReady, sendNotification]); + + // Only send notification if something changed + if (Object.keys(changedFields).length > 0) { + prevHostContextRef.current = currentContext; + sendNotification("ui/notifications/host-context-changed", changedFields); + } + }, [themeMode, displayMode, locale, timeZone, platform, viewportWidth, viewportHeight, maxHeight, deviceCapabilities, safeAreaInsets, isReady, sendNotification]); // Loading states if (toolState !== "output-available") { @@ -479,7 +764,9 @@ export function MCPAppsRenderer({ ); } - const isPip = displayMode === "pip" && pipWidgetId === toolCallId; + const isPip = + displayMode === "pip" && + (isControlled || pipWidgetId === toolCallId); const isFullscreen = displayMode === "fullscreen"; // Apply maxHeight constraint, but no minimum - let widget control its size const appliedHeight = Math.min(contentHeight, maxHeight); @@ -502,7 +789,10 @@ export function MCPAppsRenderer({ + + +

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

+
+
+ + {/* Timezone selector (SEP-1865) */} + + +
+ +
+
+ +

Timezone

+
+
+ + {/* 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-playground/UIPlaygroundTab.tsx b/client/src/components/ui-playground/UIPlaygroundTab.tsx index 207bd4994..798998834 100644 --- a/client/src/components/ui-playground/UIPlaygroundTab.tsx +++ b/client/src/components/ui-playground/UIPlaygroundTab.tsx @@ -57,6 +57,7 @@ export function UIPlaygroundTab({ displayMode, globals, isSidebarVisible, + selectedProtocol, setTools, setSelectedTool, setFormFields, @@ -71,6 +72,7 @@ export function UIPlaygroundTab({ setDisplayMode, updateGlobal, toggleSidebar, + setSelectedProtocol, reset, } = useUIPlaygroundStore(); @@ -87,6 +89,14 @@ export function UIPlaygroundTab({ [updateGlobal], ); + // Timezone change handler (SEP-1865) + const handleTimeZoneChange = useCallback( + (timeZone: string) => { + updateGlobal("timeZone", timeZone); + }, + [updateGlobal], + ); + // Log when App Builder tab is viewed useEffect(() => { posthog.capture("app_builder_tab_viewed", { @@ -195,6 +205,46 @@ export function UIPlaygroundTab({ } }, [selectedTool, tools, setFormFields]); + // Detect app protocol - from selected tool OR from server's available tools + useEffect(() => { + // If a specific tool is selected, detect its protocol + if (selectedTool) { + const meta = toolsMetadata[selectedTool]; + if (meta?.["openai/outputTemplate"] != null) { + setSelectedProtocol("openai-apps"); + } else if (meta?.["ui/resourceUri"] != null) { + setSelectedProtocol("mcp-apps"); + } else { + setSelectedProtocol(null); + } + return; + } + + // No tool selected - detect predominant protocol from all tools + const toolMetaEntries = Object.values(toolsMetadata); + if (toolMetaEntries.length === 0) { + setSelectedProtocol(null); + return; + } + + const hasOpenAI = toolMetaEntries.some( + (meta) => meta?.["openai/outputTemplate"] != null, + ); + const hasMCPApps = toolMetaEntries.some( + (meta) => meta?.["ui/resourceUri"] != null, + ); + + // If server only has one protocol type, use that + if (hasMCPApps && !hasOpenAI) { + setSelectedProtocol("mcp-apps"); + } else if (hasOpenAI && !hasMCPApps) { + setSelectedProtocol("openai-apps"); + } else { + // Mixed or no app tools - default to null (shows ChatGPT controls) + setSelectedProtocol(null); + } + }, [selectedTool, toolsMetadata, setSelectedProtocol]); + // Get invoking message from tool metadata const invokingMessage = useMemo(() => { if (!selectedTool) return null; @@ -288,6 +338,8 @@ export function UIPlaygroundTab({ onDisplayModeChange={setDisplayMode} locale={globals.locale} onLocaleChange={handleLocaleChange} + timeZone={globals.timeZone} + onTimeZoneChange={handleTimeZoneChange} /> 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 (