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;