Fix mimeType, CSP settings and QR code example (#111)

- Fix RESOURCE_MIME_TYPE to match spec: "text/html;profile=mcp-app"
- Add CSP field to McpUiSandboxResourceReadyNotification type and schema
- Update basic-host to extract and pass CSP metadata from resources
- Update sandbox.ts to dynamically inject CSP meta tag into inner iframe
- Remove static CSP from sandbox.html (now dynamic based on resource metadata)
- Update QR server to use custom read_resource handler for _meta injection
- Update requirements.txt to require mcp[cli]>=1.23.3

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

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Anton Pidkuiko 2025-12-09 19:26:34 +00:00 • committed by GitHub
commit 6a8f5031be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 133 additions and 61 deletions

View file

@ -2,22 +2,8 @@
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<!-- Permissive CSP so nested content is not constrained by host CSP --> <!-- CSP is set by serve.ts HTTP header - no meta tag needed here
<meta http-equiv="Content-Security-Policy" content=" The inner iframe's CSP is dynamically injected based on resource metadata -->
default-src 'self';
img-src * data: blob: 'unsafe-inline';
media-src * blob: data:;
font-src * blob: data:;
script-src 'self'
'wasm-unsafe-eval'
'unsafe-inline'
'unsafe-eval'
blob: data: http://localhost:* https://localhost:*;
style-src * blob: data: 'unsafe-inline';
connect-src *;
frame-src * blob: data: http://localhost:* https://localhost:*;
base-uri 'self';
" />
<title>MCP-UI Proxy</title> <title>MCP-UI Proxy</title>
<style> <style>
html, html,

View file

@ -40,17 +40,25 @@ export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
} }
interface UiResourceData {
html: string;
csp?: {
connectDomains?: string[];
resourceDomains?: string[];
};
}
export interface ToolCallInfo { export interface ToolCallInfo {
serverInfo: ServerInfo; serverInfo: ServerInfo;
tool: Tool; tool: Tool;
input: Record<string, unknown>; input: Record<string, unknown>;
resultPromise: Promise<CallToolResult>; resultPromise: Promise<CallToolResult>;
appHtmlPromise?: Promise<string>; appResourcePromise?: Promise<UiResourceData>;
} }
export function hasAppHtml(toolCallInfo: ToolCallInfo): toolCallInfo is Required<ToolCallInfo> { export function hasAppHtml(toolCallInfo: ToolCallInfo): toolCallInfo is Required<ToolCallInfo> {
return !!toolCallInfo.appHtmlPromise; return !!toolCallInfo.appResourcePromise;
} }
@ -71,7 +79,7 @@ export function callTool(
const uiResourceUri = getUiResourceUri(tool); const uiResourceUri = getUiResourceUri(tool);
if (uiResourceUri) { if (uiResourceUri) {
toolCallInfo.appHtmlPromise = getUiResourceHtml(serverInfo, uiResourceUri); toolCallInfo.appResourcePromise = getUiResource(serverInfo, uiResourceUri);
} }
return toolCallInfo; return toolCallInfo;
@ -88,13 +96,7 @@ function getUiResourceUri(tool: Tool): string | undefined {
} }
async function getUiResourceHtml(serverInfo: ServerInfo, uri: string): Promise<string> { async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiResourceData> {
let html = serverInfo.appHtmlCache.get(uri);
if (html) {
log.info("Read UI resource from cache:", uri);
return html;
}
log.info("Reading UI resource:", uri); log.info("Reading UI resource:", uri);
const resource = await serverInfo.client.readResource({ uri }); const resource = await serverInfo.client.readResource({ uri });
@ -114,9 +116,17 @@ async function getUiResourceHtml(serverInfo: ServerInfo, uri: string): Promise<s
throw new Error(`Unsupported MIME type: ${content.mimeType}`); throw new Error(`Unsupported MIME type: ${content.mimeType}`);
} }
html = "blob" in content ? atob(content.blob) : content.text; const html = "blob" in content ? atob(content.blob) : content.text;
serverInfo.appHtmlCache.set(uri, html);
return html; // Extract CSP metadata from resource content._meta.ui.csp (or content.meta for Python SDK)
log.info("Resource content keys:", Object.keys(content));
log.info("Resource content._meta:", (content as any)._meta);
// Try both _meta (spec) and meta (Python SDK quirk)
const contentMeta = (content as any)._meta || (content as any).meta;
const csp = contentMeta?.ui?.csp;
return { html, csp };
} }
@ -150,7 +160,7 @@ export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise<boolean> {
export async function initializeApp( export async function initializeApp(
iframe: HTMLIFrameElement, iframe: HTMLIFrameElement,
appBridge: AppBridge, appBridge: AppBridge,
{ input, resultPromise, appHtmlPromise }: Required<ToolCallInfo>, { input, resultPromise, appResourcePromise }: Required<ToolCallInfo>,
): Promise<void> { ): Promise<void> {
const appInitializedPromise = hookInitializedCallback(appBridge); const appInitializedPromise = hookInitializedCallback(appBridge);
@ -162,9 +172,10 @@ export async function initializeApp(
new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!), new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!),
); );
// Load inner iframe HTML // Load inner iframe HTML with CSP metadata
log.info("Sending UI resource HTML to MCP App"); const { html, csp } = await appResourcePromise;
await appBridge.sendSandboxResourceReady({ html: await appHtmlPromise }); log.info("Sending UI resource HTML to MCP App", csp ? `(CSP: ${JSON.stringify(csp)})` : "");
await appBridge.sendSandboxResourceReady({ html, csp });
// Wait for inner iframe to be ready // Wait for inner iframe to be ready
log.info("Waiting for MCP App to initialize..."); log.info("Waiting for MCP App to initialize...");

View file

@ -56,17 +56,55 @@ 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 {
const resourceDomains = csp?.resourceDomains?.join(" ") ?? "";
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) {
// NOTE: In production you'll also want to validate `event.origin` against // NOTE: In production you'll also want to validate `event.origin` against
// your Host domain. // your Host domain.
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) { if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
const { html, sandbox } = event.data.params; const { html, sandbox, csp } = 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") {
inner.srcdoc = html; // Inject CSP meta tag at the start of <head> if CSP is provided
console.log("[Sandbox] Received CSP:", csp);
let modifiedHtml = html;
if (csp) {
const cspMetaTag = buildCspMetaTag(csp);
console.log("[Sandbox] Injecting CSP meta tag:", cspMetaTag);
// Insert after <head> tag if present, otherwise prepend
if (modifiedHtml.includes("<head>")) {
modifiedHtml = modifiedHtml.replace("<head>", `<head>\n${cspMetaTag}`);
} else if (modifiedHtml.includes("<head ")) {
modifiedHtml = modifiedHtml.replace(/<head[^>]*>/, `$&\n${cspMetaTag}`);
} else {
modifiedHtml = cspMetaTag + modifiedHtml;
}
} else {
console.log("[Sandbox] No CSP provided, using default");
}
inner.srcdoc = modifiedHtml;
} }
} else { } else {
if (inner && inner.contentWindow) { if (inner && inner.contentWindow) {

View file

@ -1,2 +1,2 @@
mcp[cli]>=1.0.0 mcp[cli]>=1.23.3
qrcode[pil]>=7.4 qrcode[pil]>=7.4

View file

@ -10,7 +10,7 @@ from pathlib import Path
import qrcode import qrcode
import uvicorn import uvicorn
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from mcp.types import ImageContent from mcp import types
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
WIDGET_URI = "ui://qr-server/widget.html" WIDGET_URI = "ui://qr-server/widget.html"
@ -28,7 +28,7 @@ def generate_qr(
error_correction: str = "M", error_correction: str = "M",
fill_color: str = "black", fill_color: str = "black",
back_color: str = "white", back_color: str = "white",
) -> list[ImageContent]: ) -> list[types.ImageContent]:
"""Generate a QR code from text. """Generate a QR code from text.
Args: Args:
@ -59,31 +59,55 @@ def generate_qr(
buffer = io.BytesIO() buffer = io.BytesIO()
img.save(buffer, format="PNG") img.save(buffer, format="PNG")
b64 = base64.b64encode(buffer.getvalue()).decode() b64 = base64.b64encode(buffer.getvalue()).decode()
return [ImageContent(type="image", data=b64, mimeType="image/png")] return [types.ImageContent(type="image", data=b64, mimeType="image/png")]
# IMPORTANT: resourceDomains needed for CSP to allow loading SDK from unpkg.com # Register widget resource using FastMCP decorator (returns HTML string)
# Without this, hosts enforcing CSP will block the external script import @mcp.resource(WIDGET_URI, mime_type="text/html;profile=mcp-app")
@mcp.resource(WIDGET_URI, mime_type="text/html") def widget() -> str:
def widget() -> dict: return Path(__file__).parent.joinpath("widget.html").read_text()
# Override the read_resource handler to inject _meta into the response
# This is needed because FastMCP doesn't support custom _meta on resources
_low_level_server = mcp._mcp_server
async def _read_resource_with_meta(req: types.ReadResourceRequest):
"""Custom handler that injects CSP metadata for the widget resource."""
uri = str(req.params.uri)
html = Path(__file__).parent.joinpath("widget.html").read_text() html = Path(__file__).parent.joinpath("widget.html").read_text()
return {
"text": html,
"_meta": {
"ui": {
"csp": {
"resourceDomains": ["https://unpkg.com"]
}
}
}
}
# HACK: Bypass SDK's restrictive mime_type validation if uri == WIDGET_URI:
# The SDK pattern doesn't allow ";profile=mcp-app" but MCP spec requires it for widgets # NOTE: Must use model_validate with '_meta' key (not 'meta') due to Pydantic alias behavior
# https://github.com/modelcontextprotocol/python-sdk/pull/1755 content = types.TextResourceContents.model_validate({
for resource in mcp._resource_manager._resources.values(): "uri": WIDGET_URI,
if str(resource.uri) == WIDGET_URI: "mimeType": "text/html;profile=mcp-app",
object.__setattr__(resource, 'mime_type', 'text/html;profile=mcp-app') "text": html,
# IMPORTANT: all the external domains used by app must be listed
# in the _meta.ui.csp.resourceDomains - otherwise they will be blocked by CSP policy
"_meta": {"ui": {"csp": {"resourceDomains": ["https://unpkg.com"]}}}
})
return types.ServerResult(
types.ReadResourceResult(contents=[content])
)
# Fallback for other resources (shouldn't happen for this server)
return types.ServerResult(
types.ReadResourceResult(
contents=[
types.TextResourceContents(
uri=uri,
mimeType="text/plain",
text="Resource not found"
)
]
)
)
# Replace the handler after FastMCP has registered its own
_low_level_server.request_handlers[types.ReadResourceRequest] = _read_resource_with_meta
if __name__ == "__main__": if __name__ == "__main__":
if "--stdio" in sys.argv: if "--stdio" in sys.argv:

View file

@ -26,7 +26,7 @@
<body> <body>
<div id="qr"></div> <div id="qr"></div>
<script type="module"> <script type="module">
import { App, PostMessageTransport } from "https://unpkg.com/@modelcontextprotocol/ext-apps@0.0.4"; import { App, PostMessageTransport } from "https://unpkg.com/@modelcontextprotocol/ext-apps@0.0.1";
const app = new App({ name: "QR Widget", version: "1.0.0" }); const app = new App({ name: "QR Widget", version: "1.0.0" });

View file

@ -79,7 +79,7 @@ export const RESOURCE_URI_META_KEY = "ui/resourceUri";
/** /**
* MIME type for MCP UI resources. * MIME type for MCP UI resources.
*/ */
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp"; export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
/** /**
* Options for configuring App behavior. * Options for configuring App behavior.

View file

@ -248,6 +248,13 @@ export interface McpUiSandboxResourceReadyNotification {
html: string; html: string;
/** Optional override for the inner iframe's sandbox attribute */ /** Optional override for the inner iframe's sandbox attribute */
sandbox?: string; sandbox?: string;
/** CSP configuration from resource metadata */
csp?: {
/** Origins for network requests (fetch/XHR/WebSocket) */
connectDomains?: string[];
/** Origins for static resources (scripts, images, styles, fonts) */
resourceDomains?: string[];
};
}; };
} }
@ -260,6 +267,12 @@ export const McpUiSandboxResourceReadyNotificationSchema = z.object({
params: z.object({ params: z.object({
html: z.string(), html: z.string(),
sandbox: z.string().optional(), sandbox: z.string().optional(),
csp: z
.object({
connectDomains: z.array(z.string()).optional(),
resourceDomains: z.array(z.string()).optional(),
})
.optional(),
}), }),
}); });