diff --git a/client/public/mcp-sandbox.html b/client/public/mcp-sandbox.html index dedc5577c1..fab8ae301d 100644 --- a/client/public/mcp-sandbox.html +++ b/client/public/mcp-sandbox.html @@ -48,6 +48,13 @@ return window.location.origin; })(); + // A dedicated sandbox origin (parentOrigin differs from ours) isolates the inner frame from + // the host origin, so allow-same-origin can be granted; same-origin deployments cannot. + const dedicatedOrigin = window.location.origin !== trustedOrigin; + // Opt-in stricter CSP that drops unsafe-eval/wasm/blob/data from script-src for deployments + // that do not need them. + const strictCsp = new URLSearchParams(window.location.search).get('strictCsp') === '1'; + function notifyReady() { window.parent.postMessage( { jsonrpc: '2.0', method: 'ui/notifications/sandbox-proxy-ready', params: {} }, @@ -131,15 +138,19 @@ innerFrame = null; }); - // Strip allow-same-origin from the inner frame regardless of what the host requested. - // Full cross-origin isolation requires the sandbox proxy to be served from a different - // origin than the host; stripping allow-same-origin provides partial mitigation when - // the proxy runs same-origin. - const requestedTokens = (params.sandbox || 'allow-scripts allow-forms') - .split(/\s+/) - .filter(Boolean); - innerFrame.sandbox = - requestedTokens.filter((t) => t !== 'allow-same-origin').join(' ') || 'allow-scripts'; + // On a dedicated sandbox origin, grant allow-same-origin so storage-backed apps work + // (the spec's dedicated-origin model); the distinct origin keeps it away from the host. + // When the sandbox runs same-origin as the host, allow-same-origin would expose the host + // origin, so it is stripped regardless of what the host requested. + const sandboxTokens = new Set( + (params.sandbox || 'allow-scripts allow-forms').split(/\s+/).filter(Boolean), + ); + if (dedicatedOrigin) { + sandboxTokens.add('allow-same-origin'); + } else { + sandboxTokens.delete('allow-same-origin'); + } + innerFrame.sandbox = Array.from(sandboxTokens).join(' ') || 'allow-scripts'; const allowParts = []; if (permissions && typeof permissions === 'object') { @@ -202,12 +213,14 @@ const connectDomains = toDomainList(csp.connectDomains) || "'none'"; const frameDomains = toDomainList(csp.frameDomains) || "'none'"; + const scriptSrc = strictCsp + ? "script-src 'unsafe-inline' " + resourceDomains + : "script-src 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob: data: " + + resourceDomains; + return [ "default-src 'none'", - ( - "script-src 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob: data: " + - resourceDomains - ).trim(), + scriptSrc.trim(), ("style-src 'unsafe-inline' " + resourceDomains).trim(), 'connect-src ' + connectDomains, // form-action does not fall back to default-src, so with allow-forms a form could post to diff --git a/client/src/hooks/MCP/useAppBridge.ts b/client/src/hooks/MCP/useAppBridge.ts index 7a70ebfee2..d170d5fb05 100644 --- a/client/src/hooks/MCP/useAppBridge.ts +++ b/client/src/hooks/MCP/useAppBridge.ts @@ -7,6 +7,7 @@ import { PostMessageTransport, buildAllowAttribute, } from '@modelcontextprotocol/ext-apps/app-bridge'; +import type { McpUiStyles } from '@modelcontextprotocol/ext-apps/app-bridge'; import type { UIResource } from 'librechat-data-provider'; import type { AppToolResult } from '~/utils/mcpApps'; import { @@ -24,6 +25,41 @@ type MessageContentBlock = { type?: string; text?: string }; type SizeParams = { width?: number; height?: number }; +/** Maps the MCP Apps standard host style tokens onto LibreChat's theme CSS variables. Apps keep + * their own fallbacks for anything omitted, so a partial set is intentional. */ +const HOST_STYLE_VAR_MAP: Record = { + '--color-background-primary': '--surface-primary', + '--color-background-secondary': '--surface-secondary', + '--color-background-tertiary': '--surface-tertiary', + '--color-background-danger': '--surface-destructive', + '--color-text-primary': '--text-primary', + '--color-text-secondary': '--text-secondary', + '--color-text-tertiary': '--text-tertiary', + '--color-text-danger': '--text-destructive', + '--color-text-warning': '--text-warning', + '--color-border-primary': '--border-medium', + '--color-border-secondary': '--border-light', + '--color-border-danger': '--border-destructive', +}; + +function readHostTheme(): 'light' | 'dark' { + return document.documentElement.classList.contains('dark') ? 'dark' : 'light'; +} + +function buildHostStyleVariables(): McpUiStyles { + const computed = getComputedStyle(document.documentElement); + const variables: Record = {}; + for (const [specVar, lcVar] of Object.entries(HOST_STYLE_VAR_MAP)) { + const value = computed.getPropertyValue(lcVar).trim(); + if (value) { + variables[specVar] = value; + } + } + // The token record is optional/partial by design (apps fall back on any we omit); the generated + // type requires every key, so assert the mapped subset. + return variables as McpUiStyles; +} + export function useAppBridge( iframeRef: React.RefObject, resource: UIResource, @@ -76,7 +112,7 @@ export function useAppBridge( const transport = new PostMessageTransport(iframe.contentWindow, iframe.contentWindow); - const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light'; + const theme = readHostTheme(); const { locale, timeZone } = Intl.DateTimeFormat().resolvedOptions(); // Display-only views advertise no host-bound action capabilities so a well-behaved app // disables those affordances rather than issuing calls the host ignores. @@ -98,6 +134,7 @@ export function useAppBridge( timeZone, displayMode: 'inline', availableDisplayModes: ['inline'], + styles: { variables: buildHostStyleVariables() }, }, }, ); @@ -240,8 +277,34 @@ export function useAppBridge( iframe.addEventListener('load', handleLoad, { once: true }); iframe.src = iframe.getAttribute('data-sandbox-url') ?? ''; + // Host context is captured once at init, so push theme (and the derived style tokens) to the + // app when the user toggles light/dark while it is open. + let lastTheme = readHostTheme(); + const themeObserver = new MutationObserver(() => { + const nextTheme = readHostTheme(); + if (nextTheme === lastTheme) { + return; + } + lastTheme = nextTheme; + const activeBridge = bridgeRef.current; + if (!activeBridge) { + return; + } + Promise.resolve( + activeBridge.sendHostContextChange({ + theme: nextTheme, + styles: { variables: buildHostStyleVariables() }, + }), + ).catch((err: unknown) => logger.error('[MCP App] sendHostContextChange failed', err)); + }); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }); + return () => { cancelled = true; + themeObserver.disconnect(); iframe.removeEventListener('load', handleLoad); bridgeRef.current?.teardownResource({}).catch(() => {}); bridgeRef.current?.close(); diff --git a/client/src/utils/mcpApps.ts b/client/src/utils/mcpApps.ts index 69306cff48..78d16781f5 100644 --- a/client/src/utils/mcpApps.ts +++ b/client/src/utils/mcpApps.ts @@ -50,11 +50,16 @@ export function buildAppToolResult(resource: UIResource): AppToolResult | undefi } export function getMCPSandboxUrl(): string { - const configured = (import.meta.env as Record).VITE_MCP_SANDBOX_URL; - const base = configured ?? `${apiBaseUrl()}/api/mcp/sandbox`; + const env = import.meta.env as Record; + const base = env.VITE_MCP_SANDBOX_URL ?? `${apiBaseUrl()}/api/mcp/sandbox`; + const strictCsp = + env.VITE_MCP_SANDBOX_STRICT_CSP === 'true' || env.VITE_MCP_SANDBOX_STRICT_CSP === '1'; try { const url = new URL(base, window.location.origin); url.searchParams.set('parentOrigin', window.location.origin); + if (strictCsp) { + url.searchParams.set('strictCsp', '1'); + } return url.toString(); } catch { return base;