fix: proper CSP handling in basic-host sandboxing (HTTP headers, worker-src, document.write) (#234)

* fix: Move CSP to HTTP headers + add worker-src, frameDomains, baseUriDomains, permissions

Security improvements:
- CSP is now set via HTTP headers in serve.ts instead of meta tags
  (meta tag CSP can be tampered with by same-origin content)
- CSP passed as query param to sandbox.html for header-based enforcement

New CSP/permissions features (borrowed from PR #158):
- frameDomains: control frame-src directive for nested iframes
- baseUriDomains: control base-uri directive
- permissions: camera, microphone, geolocation via iframe allow attribute

WebGL fix:
- Use document.write() instead of srcdoc for inner iframe content
  (srcdoc creates opaque origin that breaks WebGL canvas updates)
- Add worker-src directive with blob: support (critical for WebGL apps
  like CesiumJS/Three.js that use workers for tile decoding, terrain
  processing, image processing)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: Remove permissions support (defer to PR #158)

Keep this PR focused on CSP security fixes only.
Permissions (camera, microphone, geolocation) will be
handled in #158.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* cleanup McpUiResourceCsp type usage / duplicate defs

* fix: guard against CSP injection in domain parameters

Validate CSP domain entries to reject characters that could:
- Break out of CSP directives (semicolons, newlines)
- Inject CSP keywords like 'unsafe-eval' (quotes)
- Inject multiple sources in one entry (spaces)

This prevents injection attacks where malicious domains could
override the security policy.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Olivier Chafik 2026-01-11 14:32:48 +00:00 • committed by GitHub
commit 8b09f4278c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 572 additions and 155 deletions

View file

@ -3,8 +3,8 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<!-- CSP is set by serve.ts HTTP header - no meta tag needed here <!-- CSP is set via HTTP headers by serve.ts (based on ?csp= query param).
The inner iframe's CSP is dynamically injected based on resource metadata --> The inner iframe inherits this CSP since we use document.write(). -->
<title>MCP-UI Proxy</title> <title>MCP-UI Proxy</title>
<style> <style>
html, html,

View file

@ -2,15 +2,19 @@
/** /**
* HTTP servers for the MCP UI example: * HTTP servers for the MCP UI example:
* - Host server (port 8080): serves host HTML files (React and Vanilla examples) * - Host server (port 8080): serves host HTML files (React and Vanilla examples)
* - Sandbox server (port 8081): serves sandbox.html with permissive CSP * - Sandbox server (port 8081): serves sandbox.html with CSP headers
* *
* Running on separate ports ensures proper origin isolation for security. * Running on separate ports ensures proper origin isolation for security.
*
* Security: CSP is set via HTTP headers based on ?csp= query param.
* This ensures content cannot tamper with CSP (unlike meta tags).
*/ */
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { dirname, join } from "path"; import { dirname, join } from "path";
import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@ -50,26 +54,74 @@ hostApp.get("/", (_req, res) => {
const sandboxApp = express(); const sandboxApp = express();
sandboxApp.use(cors()); sandboxApp.use(cors());
// Permissive CSP for sandbox content // Validate CSP domain entries to prevent injection attacks.
sandboxApp.use((_req, res, next) => { // Rejects entries containing characters that could:
const csp = [ // - `;` or newlines: break out to new CSP directive
"default-src 'self'", // - quotes: inject CSP keywords like 'unsafe-eval'
"img-src * data: blob: 'unsafe-inline'", // - space: inject multiple sources in one entry
"style-src * blob: data: 'unsafe-inline'", function sanitizeCspDomains(domains?: string[]): string[] {
"script-src * blob: data: 'unsafe-inline' 'unsafe-eval'", if (!domains) return [];
"connect-src *", return domains.filter((d) => typeof d === "string" && !/[;\r\n'" ]/.test(d));
"font-src * blob: data:", }
"media-src * blob: data:",
"frame-src * blob: data:", function buildCspHeader(csp?: McpUiResourceCsp): string {
].join("; "); const resourceDomains = sanitizeCspDomains(csp?.resourceDomains).join(" ");
res.setHeader("Content-Security-Policy", csp); const connectDomains = sanitizeCspDomains(csp?.connectDomains).join(" ");
const frameDomains = sanitizeCspDomains(csp?.frameDomains).join(" ") || null;
const baseUriDomains =
sanitizeCspDomains(csp?.baseUriDomains).join(" ") || null;
const directives = [
// Default: allow same-origin + inline styles/scripts (needed for bundled apps)
"default-src 'self' 'unsafe-inline'",
// Scripts: same-origin + inline + eval (some libs need eval) + blob (workers) + specified domains
`script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resourceDomains}`.trim(),
// Styles: same-origin + inline + specified domains
`style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(),
// Images: same-origin + data/blob URIs + specified domains
`img-src 'self' data: blob: ${resourceDomains}`.trim(),
// Fonts: same-origin + data/blob URIs + specified domains
`font-src 'self' data: blob: ${resourceDomains}`.trim(),
// Network requests: same-origin + specified API/tile domains
`connect-src 'self' ${connectDomains}`.trim(),
// Workers: same-origin + blob (dynamic workers) + specified domains
// This is critical for WebGL apps (CesiumJS, Three.js) that use workers for:
// - Tile decoding and terrain processing
// - Image processing and texture loading
// - Physics and geometry calculations
`worker-src 'self' blob: ${resourceDomains}`.trim(),
// Nested iframes: use frameDomains if provided, otherwise block all
frameDomains ? `frame-src ${frameDomains}` : "frame-src 'none'",
// Plugins: always blocked (defense in depth)
"object-src 'none'",
// Base URI: use baseUriDomains if provided, otherwise block all
baseUriDomains ? `base-uri ${baseUriDomains}` : "base-uri 'none'",
];
return directives.join("; ");
}
// Serve sandbox.html with CSP from query params
sandboxApp.get(["/", "/sandbox.html"], (req, res) => {
// Parse CSP config from query param: ?csp=<url-encoded-json>
let cspConfig: McpUiResourceCsp | undefined;
if (typeof req.query.csp === "string") {
try {
cspConfig = JSON.parse(req.query.csp);
} catch (e) {
console.warn("[Sandbox] Invalid CSP query param:", e);
}
}
// Set CSP via HTTP header - tamper-proof unlike meta tags
const cspHeader = buildCspHeader(cspConfig);
res.setHeader("Content-Security-Policy", cspHeader);
// Prevent caching to ensure fresh CSP on each load
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache"); res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0"); res.setHeader("Expires", "0");
next();
});
sandboxApp.get(["/", "/sandbox.html"], (_req, res) => {
res.sendFile(join(DIRECTORY, "sandbox.html")); res.sendFile(join(DIRECTORY, "sandbox.html"));
}); });

View file

@ -1,10 +1,10 @@
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge"; import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge";
import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
const SANDBOX_PROXY_URL = new URL("http://localhost:8081/sandbox.html"); const SANDBOX_PROXY_BASE_URL = "http://localhost:8081/sandbox.html";
const IMPLEMENTATION = { name: "MCP Apps Host", version: "1.0.0" }; const IMPLEMENTATION = { name: "MCP Apps Host", version: "1.0.0" };
@ -42,10 +42,7 @@ export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
interface UiResourceData { interface UiResourceData {
html: string; html: string;
csp?: { csp?: McpUiResourceCsp;
connectDomains?: string[];
resourceDomains?: string[];
};
} }
export interface ToolCallInfo { export interface ToolCallInfo {
@ -120,7 +117,10 @@ async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiRes
} }
export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise<boolean> { export function loadSandboxProxy(
iframe: HTMLIFrameElement,
csp?: McpUiResourceCsp,
): Promise<boolean> {
// Prevent reload // Prevent reload
if (iframe.src) return Promise.resolve(false); if (iframe.src) return Promise.resolve(false);
@ -140,8 +140,14 @@ export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise<boolean> {
window.addEventListener("message", listener); window.addEventListener("message", listener);
}); });
log.info("Loading sandbox proxy..."); // Build sandbox URL with CSP query param for HTTP header-based CSP
iframe.src = SANDBOX_PROXY_URL.href; const sandboxUrl = new URL(SANDBOX_PROXY_BASE_URL);
if (csp) {
sandboxUrl.searchParams.set("csp", JSON.stringify(csp));
}
log.info("Loading sandbox proxy...", csp ? `(CSP: ${JSON.stringify(csp)})` : "");
iframe.src = sandboxUrl.href;
return readyPromise; return readyPromise;
} }

View file

@ -263,16 +263,21 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
useEffect(() => { useEffect(() => {
const iframe = iframeRef.current!; const iframe = iframeRef.current!;
loadSandboxProxy(iframe).then((firstTime) => {
// The `firstTime` check guards against React Strict Mode's double // First get CSP from resource, then load sandbox with CSP in query param
// invocation (mount → unmount → remount simulation in development). // This ensures CSP is set via HTTP headers (tamper-proof)
// Outside of Strict Mode, this `useEffect` runs only once per toolCallInfo.appResourcePromise.then(({ csp }) => {
// `toolCallInfo`. loadSandboxProxy(iframe, csp).then((firstTime) => {
if (firstTime) { // The `firstTime` check guards against React Strict Mode's double
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe); // invocation (mount → unmount → remount simulation in development).
appBridgeRef.current = appBridge; // Outside of Strict Mode, this `useEffect` runs only once per
initializeApp(iframe, appBridge, toolCallInfo); // `toolCallInfo`.
} if (firstTime) {
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe);
appBridgeRef.current = appBridge;
initializeApp(iframe, appBridge, toolCallInfo);
}
});
}); });
}, [toolCallInfo]); }, [toolCallInfo]);

View file

@ -62,26 +62,9 @@ const PROXY_READY_NOTIFICATION: McpUiSandboxProxyReadyNotification["method"] =
// Special case: The "ui/notifications/sandbox-proxy-ready" message is // Special case: The "ui/notifications/sandbox-proxy-ready" message is
// intercepted here (not relayed) because the Sandbox uses it to configure and // intercepted here (not relayed) because the Sandbox uses it to configure and
// load the inner iframe with the Guest UI HTML content. // load the inner iframe with the Guest UI HTML content.
// Build CSP meta tag from domains //
function buildCspMetaTag(csp?: { connectDomains?: string[]; resourceDomains?: string[] }): string { // Security: CSP is enforced via HTTP headers on sandbox.html (set by serve.ts
const resourceDomains = csp?.resourceDomains?.join(" ") ?? ""; // based on ?csp= query param). This is tamper-proof unlike meta tags.
const connectDomains = csp?.connectDomains?.join(" ") ?? "";
// Base CSP directives
const directives = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resourceDomains}`.trim(),
`style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(),
`img-src 'self' data: blob: ${resourceDomains}`.trim(),
`font-src 'self' data: blob: ${resourceDomains}`.trim(),
`connect-src 'self' ${connectDomains}`.trim(),
"frame-src 'none'",
"object-src 'none'",
"base-uri 'self'",
];
return `<meta http-equiv="Content-Security-Policy" content="${directives.join("; ")}">`;
}
window.addEventListener("message", async (event) => { window.addEventListener("message", async (event) => {
if (event.source === window.parent) { if (event.source === window.parent) {
@ -98,29 +81,26 @@ window.addEventListener("message", async (event) => {
} }
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) { if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
const { html, sandbox, csp } = event.data.params; const { html, sandbox } = event.data.params;
if (typeof sandbox === "string") { if (typeof sandbox === "string") {
inner.setAttribute("sandbox", sandbox); inner.setAttribute("sandbox", sandbox);
} }
if (typeof html === "string") { if (typeof html === "string") {
// Inject CSP meta tag at the start of <head> if CSP is provided // Use document.write instead of srcdoc for WebGL compatibility.
console.log("[Sandbox] Received CSP:", csp); // srcdoc creates an opaque origin which prevents WebGL canvas updates
let modifiedHtml = html; // from being displayed properly. document.write preserves the sandbox
if (csp) { // origin, allowing WebGL to work correctly.
const cspMetaTag = buildCspMetaTag(csp); // CSP is enforced via HTTP headers on this page (sandbox.html).
console.log("[Sandbox] Injecting CSP meta tag:", cspMetaTag); const doc = inner.contentDocument || inner.contentWindow?.document;
// Insert after <head> tag if present, otherwise prepend if (doc) {
if (modifiedHtml.includes("<head>")) { doc.open();
modifiedHtml = modifiedHtml.replace("<head>", `<head>\n${cspMetaTag}`); doc.write(html);
} else if (modifiedHtml.includes("<head ")) { doc.close();
modifiedHtml = modifiedHtml.replace(/<head[^>]*>/, `$&\n${cspMetaTag}`);
} else {
modifiedHtml = cspMetaTag + modifiedHtml;
}
} else { } else {
console.log("[Sandbox] No CSP provided, using default"); // Fallback to srcdoc if document is not accessible
console.warn("[Sandbox] document.write not available, falling back to srcdoc");
inner.srcdoc = html;
} }
inner.srcdoc = modifiedHtml;
} }
} else { } else {
if (inner && inner.contentWindow) { if (inner && inner.contentWindow) {

View file

@ -0,0 +1,325 @@
/**
* Tamper Detection Module for MCP-UI Sandbox
*
* Generates a script that monitors security-sensitive JavaScript APIs
* and emits notifications when guest code accesses them.
*/
export type TamperRuleTarget =
| "window"
| "Element.prototype"
| "Document.prototype"
| "Node.prototype"
| "HTMLElement.prototype"
| "EventTarget.prototype"
| "History.prototype"
| "Location.prototype";
export type TamperRuleType = "getter" | "method" | "both";
export type TamperSeverity = "low" | "medium" | "high" | "critical";
export interface TamperRule {
target: TamperRuleTarget;
property: string;
type: TamperRuleType;
severity: TamperSeverity;
}
export const TAMPER_DETECTED_NOTIFICATION =
"ui/notifications/tamper-detected" as const;
/**
* Default rules for security-sensitive APIs.
* - critical: Access is blocked (returns undefined / throws)
* - high/medium/low: Access is allowed but an alert is emitted
*/
export const DEFAULT_TAMPER_RULES: TamperRule[] = [
// === CRITICAL (blocked) ===
// Escape attempts
{ target: "window", property: "top", type: "getter", severity: "critical" },
{
target: "window",
property: "parent",
type: "getter",
severity: "critical",
},
{
target: "window",
property: "opener",
type: "getter",
severity: "critical",
},
{
target: "window",
property: "frameElement",
type: "getter",
severity: "critical",
},
// Code execution
{ target: "window", property: "eval", type: "method", severity: "critical" },
// === HIGH (allowed with alert) ===
{ target: "window", property: "Function", type: "getter", severity: "high" },
// Network/data exfiltration
{ target: "window", property: "fetch", type: "method", severity: "high" },
{
target: "window",
property: "XMLHttpRequest",
type: "getter",
severity: "high",
},
{ target: "window", property: "WebSocket", type: "getter", severity: "high" },
{
target: "window",
property: "EventSource",
type: "getter",
severity: "high",
},
// DOM injection (high-risk methods)
{
target: "Document.prototype",
property: "write",
type: "method",
severity: "high",
},
{
target: "Document.prototype",
property: "writeln",
type: "method",
severity: "high",
},
{
target: "Element.prototype",
property: "insertAdjacentHTML",
type: "method",
severity: "high",
},
// === MEDIUM (allowed with alert) ===
// Storage access
{
target: "window",
property: "localStorage",
type: "getter",
severity: "medium",
},
{
target: "window",
property: "sessionStorage",
type: "getter",
severity: "medium",
},
{
target: "window",
property: "indexedDB",
type: "getter",
severity: "medium",
},
{ target: "window", property: "caches", type: "getter", severity: "medium" },
// DOM injection (property-based)
{
target: "Element.prototype",
property: "innerHTML",
type: "both",
severity: "medium",
},
{
target: "Element.prototype",
property: "outerHTML",
type: "both",
severity: "medium",
},
// DOM traversal
{
target: "Node.prototype",
property: "parentNode",
type: "getter",
severity: "medium",
},
{
target: "Node.prototype",
property: "parentElement",
type: "getter",
severity: "medium",
},
{
target: "Element.prototype",
property: "closest",
type: "method",
severity: "medium",
},
// History manipulation
{ target: "window", property: "history", type: "getter", severity: "medium" },
{
target: "window",
property: "location",
type: "getter",
severity: "medium",
},
// === LOW (allowed with alert) ===
// Message passing
{
target: "window",
property: "postMessage",
type: "method",
severity: "low",
},
// DOM queries
{
target: "Document.prototype",
property: "querySelector",
type: "method",
severity: "low",
},
{
target: "Document.prototype",
property: "querySelectorAll",
type: "method",
severity: "low",
},
{
target: "Document.prototype",
property: "getElementById",
type: "method",
severity: "low",
},
];
/**
* Generates the tamper detection script as a string.
* This script should be injected into guest HTML before any other scripts run.
*/
export function generateTamperDetectionScript(
rules: TamperRule[] = DEFAULT_TAMPER_RULES,
): string {
const rulesJson = JSON.stringify(rules);
return `(function() {
'use strict';
var NOTIFICATION_METHOD = '${TAMPER_DETECTED_NOTIFICATION}';
var rules = ${rulesJson};
function emitTamperAlert(rule, accessType, stackTrace) {
try {
window.parent.postMessage({
jsonrpc: '2.0',
method: NOTIFICATION_METHOD,
params: {
property: rule.target + '.' + rule.property,
accessType: accessType,
severity: rule.severity,
blocked: rule.severity === 'critical',
timestamp: Date.now(),
stack: stackTrace
}
}, '*');
} catch (e) {
// Silently fail if postMessage is unavailable
}
}
function shouldBlock(rule) {
return rule.severity === 'critical';
}
function wrapProperty(target, prop, rule) {
var descriptor = Object.getOwnPropertyDescriptor(target, prop);
if (!descriptor) return false;
var newDescriptor = {
configurable: false,
enumerable: descriptor.enumerable
};
var wrapped = false;
if (rule.type === 'getter' || rule.type === 'both') {
var originalGetter = descriptor.get || (function() { return descriptor.value; });
newDescriptor.get = function() {
emitTamperAlert(rule, 'read', new Error().stack);
if (shouldBlock(rule)) {
return undefined;
}
return originalGetter.call(this);
};
wrapped = true;
}
if (rule.type === 'method' && typeof descriptor.value === 'function') {
var originalMethod = descriptor.value;
newDescriptor.value = function() {
emitTamperAlert(rule, 'call', new Error().stack);
if (shouldBlock(rule)) {
throw new Error('Access to ' + rule.target + '.' + rule.property + ' is blocked');
}
return originalMethod.apply(this, arguments);
};
wrapped = true;
}
if (rule.type === 'both' && descriptor.set) {
var originalSetter = descriptor.set;
newDescriptor.set = function(value) {
emitTamperAlert(rule, 'write', new Error().stack);
if (shouldBlock(rule)) {
return;
}
return originalSetter.call(this, value);
};
}
if (wrapped) {
try {
Object.defineProperty(target, prop, newDescriptor);
return true;
} catch (e) {
return false;
}
}
return false;
}
function getTargetObject(targetName) {
switch (targetName) {
case 'window': return window;
case 'Element.prototype': return Element.prototype;
case 'Document.prototype': return Document.prototype;
case 'Node.prototype': return Node.prototype;
case 'HTMLElement.prototype': return HTMLElement.prototype;
case 'EventTarget.prototype': return EventTarget.prototype;
case 'History.prototype': return History.prototype;
case 'Location.prototype': return Location.prototype;
default: return null;
}
}
// Install monitoring
for (var i = 0; i < rules.length; i++) {
var rule = rules[i];
var target = getTargetObject(rule.target);
if (target) {
wrapProperty(target, rule.property, rule);
}
}
})();`;
}
/**
* Injects a script into HTML content, ensuring it runs before any other scripts.
*/
export function injectScriptIntoHtml(html: string, script: string): string {
const scriptTag = `<script>${script}</script>`;
// Try <head> first
if (html.includes("<head>")) {
return html.replace("<head>", `<head>${scriptTag}`);
}
// Try <body>
if (html.includes("<body>")) {
return html.replace("<body>", `<body>${scriptTag}`);
}
// Fallback: prepend
return scriptTag + html;
}

View file

@ -109,16 +109,9 @@ interface UIResource {
} }
} }
interface UIResourceMeta { interface McpUiResourceCsp {
/**
* Content Security Policy configuration
*
* Servers declare which external origins their UI needs to access.
* Hosts use this to enforce appropriate CSP headers.
*/
csp?: {
/** /**
* Origins for network requests * Origins for network requests (fetch/XHR/WebSocket)
* *
* - Empty or omitted = no external connections (secure default) * - Empty or omitted = no external connections (secure default)
* - Maps to CSP `connect-src` directive * - Maps to CSP `connect-src` directive
@ -138,7 +131,24 @@ interface UIResourceMeta {
* ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"] * ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"]
*/ */
resourceDomains?: string[], resourceDomains?: string[],
}, /**
* Origins for nested iframes (frame-src directive).
*/
frameDomains?: string[],
/**
* Allowed base URIs for the document (base-uri directive).
*/
baseUriDomains?: string[],
}
interface UIResourceMeta {
/**
* Content Security Policy configuration
*
* Servers declare which external origins their UI needs to access.
* Hosts use this to enforce appropriate CSP headers.
*/
csp?: McpUiResourceCsp,
/** /**
* Dedicated origin for widget * Dedicated origin for widget
* *
@ -178,8 +188,10 @@ The resource content is returned via `resources/read`:
_meta?: { _meta?: {
ui?: { ui?: {
csp?: { csp?: {
connectDomains?: string[]; // Origins for fetch/XHR/WebSocket connectDomains?: string[]; // Origins for network requests (fetch/XHR/WebSocket).
resourceDomains?: string[]; // Origins for images, scripts, etc resourceDomains?: string[]; // Origins for static resources (scripts, images, styles, fonts).
frameDomains?: string[]; // Origins for nested iframes (frame-src directive).
baseUriDomains?: string[]; // Allowed base URIs for the document (base-uri directive).
}; };
domain?: string; domain?: string;
prefersBorder?: boolean; prefersBorder?: boolean;

View file

@ -3475,6 +3475,20 @@
"items": { "items": {
"type": "string" "type": "string"
} }
},
"frameDomains": {
"description": "Origins for nested iframes (frame-src directive).",
"type": "array",
"items": {
"type": "string"
}
},
"baseUriDomains": {
"description": "Allowed base URIs for the document (base-uri directive).",
"type": "array",
"items": {
"type": "string"
}
} }
}, },
"additionalProperties": false "additionalProperties": false
@ -3500,6 +3514,20 @@
"items": { "items": {
"type": "string" "type": "string"
} }
},
"frameDomains": {
"description": "Origins for nested iframes (frame-src directive).",
"type": "array",
"items": {
"type": "string"
}
},
"baseUriDomains": {
"description": "Allowed base URIs for the document (base-uri directive).",
"type": "array",
"items": {
"type": "string"
}
} }
}, },
"additionalProperties": false "additionalProperties": false
@ -3593,6 +3621,20 @@
"items": { "items": {
"type": "string" "type": "string"
} }
},
"frameDomains": {
"description": "Origins for nested iframes (frame-src directive).",
"type": "array",
"items": {
"type": "string"
}
},
"baseUriDomains": {
"description": "Allowed base URIs for the document (base-uri directive).",
"type": "array",
"items": {
"type": "string"
}
} }
}, },
"additionalProperties": false "additionalProperties": false

View file

@ -43,8 +43,8 @@ export type McpUiSandboxProxyReadyNotificationSchemaInferredType = z.infer<
typeof generated.McpUiSandboxProxyReadyNotificationSchema typeof generated.McpUiSandboxProxyReadyNotificationSchema
>; >;
export type McpUiSandboxResourceReadyNotificationSchemaInferredType = z.infer< export type McpUiResourceCspSchemaInferredType = z.infer<
typeof generated.McpUiSandboxResourceReadyNotificationSchema typeof generated.McpUiResourceCspSchema
>; >;
export type McpUiSizeChangedNotificationSchemaInferredType = z.infer< export type McpUiSizeChangedNotificationSchemaInferredType = z.infer<
@ -91,10 +91,6 @@ export type McpUiInitializedNotificationSchemaInferredType = z.infer<
typeof generated.McpUiInitializedNotificationSchema typeof generated.McpUiInitializedNotificationSchema
>; >;
export type McpUiResourceCspSchemaInferredType = z.infer<
typeof generated.McpUiResourceCspSchema
>;
export type McpUiResourceMetaSchemaInferredType = z.infer< export type McpUiResourceMetaSchemaInferredType = z.infer<
typeof generated.McpUiResourceMetaSchema typeof generated.McpUiResourceMetaSchema
>; >;
@ -119,6 +115,10 @@ export type McpUiMessageRequestSchemaInferredType = z.infer<
typeof generated.McpUiMessageRequestSchema typeof generated.McpUiMessageRequestSchema
>; >;
export type McpUiSandboxResourceReadyNotificationSchemaInferredType = z.infer<
typeof generated.McpUiSandboxResourceReadyNotificationSchema
>;
export type McpUiToolResultNotificationSchemaInferredType = z.infer< export type McpUiToolResultNotificationSchemaInferredType = z.infer<
typeof generated.McpUiToolResultNotificationSchema typeof generated.McpUiToolResultNotificationSchema
>; >;
@ -171,12 +171,8 @@ expectType<spec.McpUiSandboxProxyReadyNotification>(
expectType<McpUiSandboxProxyReadyNotificationSchemaInferredType>( expectType<McpUiSandboxProxyReadyNotificationSchemaInferredType>(
{} as spec.McpUiSandboxProxyReadyNotification, {} as spec.McpUiSandboxProxyReadyNotification,
); );
expectType<spec.McpUiSandboxResourceReadyNotification>( expectType<spec.McpUiResourceCsp>({} as McpUiResourceCspSchemaInferredType);
{} as McpUiSandboxResourceReadyNotificationSchemaInferredType, expectType<McpUiResourceCspSchemaInferredType>({} as spec.McpUiResourceCsp);
);
expectType<McpUiSandboxResourceReadyNotificationSchemaInferredType>(
{} as spec.McpUiSandboxResourceReadyNotification,
);
expectType<spec.McpUiSizeChangedNotification>( expectType<spec.McpUiSizeChangedNotification>(
{} as McpUiSizeChangedNotificationSchemaInferredType, {} as McpUiSizeChangedNotificationSchemaInferredType,
); );
@ -235,8 +231,6 @@ expectType<spec.McpUiInitializedNotification>(
expectType<McpUiInitializedNotificationSchemaInferredType>( expectType<McpUiInitializedNotificationSchemaInferredType>(
{} as spec.McpUiInitializedNotification, {} as spec.McpUiInitializedNotification,
); );
expectType<spec.McpUiResourceCsp>({} as McpUiResourceCspSchemaInferredType);
expectType<McpUiResourceCspSchemaInferredType>({} as spec.McpUiResourceCsp);
expectType<spec.McpUiResourceMeta>({} as McpUiResourceMetaSchemaInferredType); expectType<spec.McpUiResourceMeta>({} as McpUiResourceMetaSchemaInferredType);
expectType<McpUiResourceMetaSchemaInferredType>({} as spec.McpUiResourceMeta); expectType<McpUiResourceMetaSchemaInferredType>({} as spec.McpUiResourceMeta);
expectType<spec.McpUiRequestDisplayModeRequest>( expectType<spec.McpUiRequestDisplayModeRequest>(
@ -265,6 +259,12 @@ expectType<spec.McpUiMessageRequest>(
expectType<McpUiMessageRequestSchemaInferredType>( expectType<McpUiMessageRequestSchemaInferredType>(
{} as spec.McpUiMessageRequest, {} as spec.McpUiMessageRequest,
); );
expectType<spec.McpUiSandboxResourceReadyNotification>(
{} as McpUiSandboxResourceReadyNotificationSchemaInferredType,
);
expectType<McpUiSandboxResourceReadyNotificationSchemaInferredType>(
{} as spec.McpUiSandboxResourceReadyNotification,
);
expectType<spec.McpUiToolResultNotification>( expectType<spec.McpUiToolResultNotification>(
{} as McpUiToolResultNotificationSchemaInferredType, {} as McpUiToolResultNotificationSchemaInferredType,
); );

View file

@ -186,39 +186,29 @@ export const McpUiSandboxProxyReadyNotificationSchema = z.object({
}); });
/** /**
* @description Notification containing HTML resource for the sandbox proxy to load. * @description Content Security Policy configuration for UI resources.
* @internal
* @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#sandbox-proxy
*/ */
export const McpUiSandboxResourceReadyNotificationSchema = z.object({ export const McpUiResourceCspSchema = z.object({
method: z.literal("ui/notifications/sandbox-resource-ready"), /** @description Origins for network requests (fetch/XHR/WebSocket). */
params: z.object({ connectDomains: z
/** @description HTML content to load into the inner iframe. */ .array(z.string())
html: z.string().describe("HTML content to load into the inner iframe."), .optional()
/** @description Optional override for the inner iframe's sandbox attribute. */ .describe("Origins for network requests (fetch/XHR/WebSocket)."),
sandbox: z /** @description Origins for static resources (scripts, images, styles, fonts). */
.string() resourceDomains: z
.optional() .array(z.string())
.describe("Optional override for the inner iframe's sandbox attribute."), .optional()
/** @description CSP configuration from resource metadata. */ .describe("Origins for static resources (scripts, images, styles, fonts)."),
csp: z /** @description Origins for nested iframes (frame-src directive). */
.object({ frameDomains: z
/** @description Origins for network requests (fetch/XHR/WebSocket). */ .array(z.string())
connectDomains: z .optional()
.array(z.string()) .describe("Origins for nested iframes (frame-src directive)."),
.optional() /** @description Allowed base URIs for the document (base-uri directive). */
.describe("Origins for network requests (fetch/XHR/WebSocket)."), baseUriDomains: z
/** @description Origins for static resources (scripts, images, styles, fonts). */ .array(z.string())
resourceDomains: z .optional()
.array(z.string()) .describe("Allowed base URIs for the document (base-uri directive)."),
.optional()
.describe(
"Origins for static resources (scripts, images, styles, fonts).",
),
})
.optional()
.describe("CSP configuration from resource metadata."),
}),
}); });
/** /**
@ -407,22 +397,6 @@ export const McpUiInitializedNotificationSchema = z.object({
params: z.object({}).optional(), params: z.object({}).optional(),
}); });
/**
* @description Content Security Policy configuration for UI resources.
*/
export const McpUiResourceCspSchema = z.object({
/** @description Origins for network requests (fetch/XHR/WebSocket). */
connectDomains: z
.array(z.string())
.optional()
.describe("Origins for network requests (fetch/XHR/WebSocket)."),
/** @description Origins for static resources (scripts, images, styles, fonts). */
resourceDomains: z
.array(z.string())
.optional()
.describe("Origins for static resources (scripts, images, styles, fonts)."),
});
/** /**
* @description UI Resource metadata for security and rendering configuration. * @description UI Resource metadata for security and rendering configuration.
*/ */
@ -521,6 +495,28 @@ export const McpUiMessageRequestSchema = z.object({
}), }),
}); });
/**
* @description Notification containing HTML resource for the sandbox proxy to load.
* @internal
* @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#sandbox-proxy
*/
export const McpUiSandboxResourceReadyNotificationSchema = z.object({
method: z.literal("ui/notifications/sandbox-resource-ready"),
params: z.object({
/** @description HTML content to load into the inner iframe. */
html: z.string().describe("HTML content to load into the inner iframe."),
/** @description Optional override for the inner iframe's sandbox attribute. */
sandbox: z
.string()
.optional()
.describe("Optional override for the inner iframe's sandbox attribute."),
/** @description CSP configuration from resource metadata. */
csp: McpUiResourceCspSchema.optional().describe(
"CSP configuration from resource metadata.",
),
}),
});
/** /**
* @description Notification containing tool execution result (Host -> Guest UI). * @description Notification containing tool execution result (Host -> Guest UI).
*/ */

View file

@ -219,12 +219,7 @@ export interface McpUiSandboxResourceReadyNotification {
/** @description Optional override for the inner iframe's sandbox attribute. */ /** @description Optional override for the inner iframe's sandbox attribute. */
sandbox?: string; sandbox?: string;
/** @description CSP configuration from resource metadata. */ /** @description CSP configuration from resource metadata. */
csp?: { csp?: McpUiResourceCsp;
/** @description Origins for network requests (fetch/XHR/WebSocket). */
connectDomains?: string[];
/** @description Origins for static resources (scripts, images, styles, fonts). */
resourceDomains?: string[];
};
}; };
} }
@ -497,6 +492,10 @@ export interface McpUiResourceCsp {
connectDomains?: string[]; connectDomains?: string[];
/** @description Origins for static resources (scripts, images, styles, fonts). */ /** @description Origins for static resources (scripts, images, styles, fonts). */
resourceDomains?: string[]; resourceDomains?: string[];
/** @description Origins for nested iframes (frame-src directive). */
frameDomains?: string[];
/** @description Allowed base URIs for the document (base-uri directive). */
baseUriDomains?: string[];
} }
/** /**