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:
parent
9d21d8bc08
commit
6a8f5031be
8 changed files with 133 additions and 61 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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...");
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
mcp[cli]>=1.0.0
|
mcp[cli]>=1.23.3
|
||||||
qrcode[pil]>=7.4
|
qrcode[pil]>=7.4
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
13
src/types.ts
13
src/types.ts
|
|
@ -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(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue