Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 1 addition & 49 deletions examples/basic-host/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import cors from "cors";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps";
import { buildCspHeader } from "./src/csp";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -54,55 +55,6 @@ hostApp.get("/", (_req, res) => {
const sandboxApp = express();
sandboxApp.use(cors());

// Validate CSP domain entries to prevent injection attacks.
// Rejects entries containing characters that could:
// - `;` or newlines: break out to new CSP directive
// - quotes: inject CSP keywords like 'unsafe-eval'
// - space: inject multiple sources in one entry
function sanitizeCspDomains(domains?: string[]): string[] {
if (!domains) return [];
return domains.filter((d) => typeof d === "string" && !/[;\r\n'" ]/.test(d));
}

function buildCspHeader(csp?: McpUiResourceCsp): string {
const resourceDomains = sanitizeCspDomains(csp?.resourceDomains).join(" ");
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(),
// Media (audio/video): same-origin + data/blob URIs + specified domains
`media-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>
Expand Down
59 changes: 59 additions & 0 deletions examples/basic-host/src/csp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps";

// Validate CSP domain entries to prevent injection attacks.
// Rejects entries containing characters that could:
// - `;` or newlines: break out to new CSP directive
// - quotes: inject CSP keywords like 'unsafe-eval'
// - space: inject multiple sources in one entry
function sanitizeCspDomains(domains?: string[]): string[] {
if (!domains) return [];
return domains.filter((d) => typeof d === "string" && !/[;\r\n'" ]/.test(d));
}
Comment on lines +8 to +11

export function buildCspHeader(csp?: McpUiResourceCsp): string {
const resourceDomains = sanitizeCspDomains(csp?.resourceDomains).join(" ");
const connectDomains = sanitizeCspDomains(csp?.connectDomains).join(" ");
const frameDomains = sanitizeCspDomains(csp?.frameDomains).join(" ") || null;
const baseUriDomains =
sanitizeCspDomains(csp?.baseUriDomains).join(" ") || null;

const scriptSources = [
"'self'",
"'unsafe-inline'",
"'unsafe-eval'",
...(csp?.wasmUnsafeEval === true ? ["'wasm-unsafe-eval'"] : []),
"blob:",
"data:",
].join(" ");
Comment on lines +20 to +27

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 ${scriptSources} ${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(),
// Media (audio/video): same-origin + data/blob URIs + specified domains
`media-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("; ");
}
35 changes: 35 additions & 0 deletions examples/basic-host/test/csp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from "bun:test";

import { buildCspHeader } from "../src/csp";

function getScriptSrc(cspHeader: string): string {
const scriptSrc = cspHeader
.split("; ")
.find((directive) => directive.startsWith("script-src "));

if (!scriptSrc) {
throw new Error(`Missing script-src directive in CSP: ${cspHeader}`);
}

return scriptSrc;
}

describe("buildCspHeader", () => {
it("adds wasm-unsafe-eval to script-src when requested", () => {
const scriptSrc = getScriptSrc(
buildCspHeader({
resourceDomains: ["https://cdn.example.com"],
wasmUnsafeEval: true,
}),
);

expect(scriptSrc).toContain("'wasm-unsafe-eval'");
expect(scriptSrc).toContain("https://cdn.example.com");
});

it("omits wasm-unsafe-eval from script-src when not requested", () => {
const scriptSrc = getScriptSrc(buildCspHeader());

expect(scriptSrc).not.toContain("'wasm-unsafe-eval'");
});
});
10 changes: 9 additions & 1 deletion specification/2026-01-26/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@ interface McpUiResourceCsp {
* ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"]
*/
resourceDomains?: string[],
/**
* Whether the UI requires WebAssembly compilation
*
* - Empty or false = WebAssembly compilation remains blocked by default
* - Maps to the CSP `script-src` source expression `'wasm-unsafe-eval'`
*/
wasmUnsafeEval?: boolean,
/**
* Origins for nested iframes
*
Expand Down Expand Up @@ -1729,10 +1736,11 @@ Hosts MUST enforce Content Security Policies based on resource metadata.
```typescript
const csp = resource._meta?.ui?.csp; // `resource` is extracted from the `contents` of the `resources/read` result
const permissions = resource._meta?.ui?.permissions;
const wasmUnsafeEval = csp?.wasmUnsafeEval ? "'wasm-unsafe-eval'" : "";

const cspValue = `
default-src 'none';
script-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''};
script-src 'self' 'unsafe-inline' ${wasmUnsafeEval} ${csp?.resourceDomains?.join(' ') || ''};
style-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''};
Comment on lines +1739 to 1744
connect-src 'self' ${csp?.connectDomains?.join(' ') || ''};
img-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''};
Expand Down
10 changes: 9 additions & 1 deletion specification/draft/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ interface McpUiResourceCsp {
* ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"]
*/
resourceDomains?: string[],
/**
* Whether the UI requires WebAssembly compilation
*
* - Empty or false = WebAssembly compilation remains blocked by default
* - Maps to the CSP `script-src` source expression `'wasm-unsafe-eval'`
*/
wasmUnsafeEval?: boolean,
/**
* Origins for nested iframes
*
Expand Down Expand Up @@ -2514,10 +2521,11 @@ Hosts MUST enforce Content Security Policies based on resource metadata.
const uiMeta = resource._meta?.ui ?? listingResource._meta?.ui;
const csp = uiMeta?.csp;
const permissions = uiMeta?.permissions;
const wasmUnsafeEval = csp?.wasmUnsafeEval ? "'wasm-unsafe-eval'" : "";

const cspValue = `
default-src 'none';
script-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''};
script-src 'self' 'unsafe-inline' ${wasmUnsafeEval} ${csp?.resourceDomains?.join(' ') || ''};
style-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''};
Comment on lines +2524 to 2529
connect-src 'self' ${csp?.connectDomains?.join(' ') || ''};
img-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''};
Expand Down
20 changes: 20 additions & 0 deletions src/generated/schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions src/generated/schema.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions src/spec.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,13 @@ export interface McpUiResourceCsp {
* ```
*/
resourceDomains?: string[];
/**
* @description Whether the UI requires WebAssembly compilation.
*
* - Maps to the CSP `script-src` source expression `'wasm-unsafe-eval'`
* - Empty or false → WebAssembly compilation remains blocked by default
*/
wasmUnsafeEval?: boolean;
/**
* @description Origins for nested iframes.
*
Expand Down
Loading