Skip to content

Commit 6a8f503

Browse files
antonpk1claude
andauthored
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 <[email protected]>
1 parent 9d21d8b commit 6a8f503

File tree

8 files changed

+133
-61
lines changed

8 files changed

+133
-61
lines changed

examples/basic-host/sandbox.html

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,8 @@
22
<html>
33
<head>
44
<meta charset="utf-8" />
5-
<!-- Permissive CSP so nested content is not constrained by host CSP -->
6-
<meta http-equiv="Content-Security-Policy" content="
7-
default-src 'self';
8-
img-src * data: blob: 'unsafe-inline';
9-
media-src * blob: data:;
10-
font-src * blob: data:;
11-
script-src 'self'
12-
'wasm-unsafe-eval'
13-
'unsafe-inline'
14-
'unsafe-eval'
15-
blob: data: http://localhost:* https://localhost:*;
16-
style-src * blob: data: 'unsafe-inline';
17-
connect-src *;
18-
frame-src * blob: data: http://localhost:* https://localhost:*;
19-
base-uri 'self';
20-
" />
5+
<!-- CSP is set by serve.ts HTTP header - no meta tag needed here
6+
The inner iframe's CSP is dynamically injected based on resource metadata -->
217
<title>MCP-UI Proxy</title>
228
<style>
239
html,

examples/basic-host/src/implementation.ts

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,25 @@ export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
4040
}
4141

4242

43+
interface UiResourceData {
44+
html: string;
45+
csp?: {
46+
connectDomains?: string[];
47+
resourceDomains?: string[];
48+
};
49+
}
50+
4351
export interface ToolCallInfo {
4452
serverInfo: ServerInfo;
4553
tool: Tool;
4654
input: Record<string, unknown>;
4755
resultPromise: Promise<CallToolResult>;
48-
appHtmlPromise?: Promise<string>;
56+
appResourcePromise?: Promise<UiResourceData>;
4957
}
5058

5159

5260
export function hasAppHtml(toolCallInfo: ToolCallInfo): toolCallInfo is Required<ToolCallInfo> {
53-
return !!toolCallInfo.appHtmlPromise;
61+
return !!toolCallInfo.appResourcePromise;
5462
}
5563

5664

@@ -71,7 +79,7 @@ export function callTool(
7179

7280
const uiResourceUri = getUiResourceUri(tool);
7381
if (uiResourceUri) {
74-
toolCallInfo.appHtmlPromise = getUiResourceHtml(serverInfo, uiResourceUri);
82+
toolCallInfo.appResourcePromise = getUiResource(serverInfo, uiResourceUri);
7583
}
7684

7785
return toolCallInfo;
@@ -88,13 +96,7 @@ function getUiResourceUri(tool: Tool): string | undefined {
8896
}
8997

9098

91-
async function getUiResourceHtml(serverInfo: ServerInfo, uri: string): Promise<string> {
92-
let html = serverInfo.appHtmlCache.get(uri);
93-
if (html) {
94-
log.info("Read UI resource from cache:", uri);
95-
return html;
96-
}
97-
99+
async function getUiResource(serverInfo: ServerInfo, uri: string): Promise<UiResourceData> {
98100
log.info("Reading UI resource:", uri);
99101
const resource = await serverInfo.client.readResource({ uri });
100102

@@ -114,9 +116,17 @@ async function getUiResourceHtml(serverInfo: ServerInfo, uri: string): Promise<s
114116
throw new Error(`Unsupported MIME type: ${content.mimeType}`);
115117
}
116118

117-
html = "blob" in content ? atob(content.blob) : content.text;
118-
serverInfo.appHtmlCache.set(uri, html);
119-
return html;
119+
const html = "blob" in content ? atob(content.blob) : content.text;
120+
121+
// Extract CSP metadata from resource content._meta.ui.csp (or content.meta for Python SDK)
122+
log.info("Resource content keys:", Object.keys(content));
123+
log.info("Resource content._meta:", (content as any)._meta);
124+
125+
// Try both _meta (spec) and meta (Python SDK quirk)
126+
const contentMeta = (content as any)._meta || (content as any).meta;
127+
const csp = contentMeta?.ui?.csp;
128+
129+
return { html, csp };
120130
}
121131

122132

@@ -150,7 +160,7 @@ export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise<boolean> {
150160
export async function initializeApp(
151161
iframe: HTMLIFrameElement,
152162
appBridge: AppBridge,
153-
{ input, resultPromise, appHtmlPromise }: Required<ToolCallInfo>,
163+
{ input, resultPromise, appResourcePromise }: Required<ToolCallInfo>,
154164
): Promise<void> {
155165
const appInitializedPromise = hookInitializedCallback(appBridge);
156166

@@ -162,9 +172,10 @@ export async function initializeApp(
162172
new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!),
163173
);
164174

165-
// Load inner iframe HTML
166-
log.info("Sending UI resource HTML to MCP App");
167-
await appBridge.sendSandboxResourceReady({ html: await appHtmlPromise });
175+
// Load inner iframe HTML with CSP metadata
176+
const { html, csp } = await appResourcePromise;
177+
log.info("Sending UI resource HTML to MCP App", csp ? `(CSP: ${JSON.stringify(csp)})` : "");
178+
await appBridge.sendSandboxResourceReady({ html, csp });
168179

169180
// Wait for inner iframe to be ready
170181
log.info("Waiting for MCP App to initialize...");

examples/basic-host/src/sandbox.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,55 @@ const PROXY_READY_NOTIFICATION: McpUiSandboxProxyReadyNotification["method"] =
5656
// Special case: The "ui/notifications/sandbox-proxy-ready" message is
5757
// intercepted here (not relayed) because the Sandbox uses it to configure and
5858
// load the inner iframe with the Guest UI HTML content.
59+
// Build CSP meta tag from domains
60+
function buildCspMetaTag(csp?: { connectDomains?: string[]; resourceDomains?: string[] }): string {
61+
const resourceDomains = csp?.resourceDomains?.join(" ") ?? "";
62+
const connectDomains = csp?.connectDomains?.join(" ") ?? "";
63+
64+
// Base CSP directives
65+
const directives = [
66+
"default-src 'self'",
67+
`script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resourceDomains}`.trim(),
68+
`style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(),
69+
`img-src 'self' data: blob: ${resourceDomains}`.trim(),
70+
`font-src 'self' data: blob: ${resourceDomains}`.trim(),
71+
`connect-src 'self' ${connectDomains}`.trim(),
72+
"frame-src 'none'",
73+
"object-src 'none'",
74+
"base-uri 'self'",
75+
];
76+
77+
return `<meta http-equiv="Content-Security-Policy" content="${directives.join("; ")}">`;
78+
}
79+
5980
window.addEventListener("message", async (event) => {
6081
if (event.source === window.parent) {
6182
// NOTE: In production you'll also want to validate `event.origin` against
6283
// your Host domain.
6384
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
64-
const { html, sandbox } = event.data.params;
85+
const { html, sandbox, csp } = event.data.params;
6586
if (typeof sandbox === "string") {
6687
inner.setAttribute("sandbox", sandbox);
6788
}
6889
if (typeof html === "string") {
69-
inner.srcdoc = html;
90+
// Inject CSP meta tag at the start of <head> if CSP is provided
91+
console.log("[Sandbox] Received CSP:", csp);
92+
let modifiedHtml = html;
93+
if (csp) {
94+
const cspMetaTag = buildCspMetaTag(csp);
95+
console.log("[Sandbox] Injecting CSP meta tag:", cspMetaTag);
96+
// Insert after <head> tag if present, otherwise prepend
97+
if (modifiedHtml.includes("<head>")) {
98+
modifiedHtml = modifiedHtml.replace("<head>", `<head>\n${cspMetaTag}`);
99+
} else if (modifiedHtml.includes("<head ")) {
100+
modifiedHtml = modifiedHtml.replace(/<head[^>]*>/, `$&\n${cspMetaTag}`);
101+
} else {
102+
modifiedHtml = cspMetaTag + modifiedHtml;
103+
}
104+
} else {
105+
console.log("[Sandbox] No CSP provided, using default");
106+
}
107+
inner.srcdoc = modifiedHtml;
70108
}
71109
} else {
72110
if (inner && inner.contentWindow) {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
mcp[cli]>=1.0.0
1+
mcp[cli]>=1.23.3
22
qrcode[pil]>=7.4

examples/qr-server/server.py

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import qrcode
1111
import uvicorn
1212
from mcp.server.fastmcp import FastMCP
13-
from mcp.types import ImageContent
13+
from mcp import types
1414
from starlette.middleware.cors import CORSMiddleware
1515

1616
WIDGET_URI = "ui://qr-server/widget.html"
@@ -28,7 +28,7 @@ def generate_qr(
2828
error_correction: str = "M",
2929
fill_color: str = "black",
3030
back_color: str = "white",
31-
) -> list[ImageContent]:
31+
) -> list[types.ImageContent]:
3232
"""Generate a QR code from text.
3333
3434
Args:
@@ -59,31 +59,55 @@ def generate_qr(
5959
buffer = io.BytesIO()
6060
img.save(buffer, format="PNG")
6161
b64 = base64.b64encode(buffer.getvalue()).decode()
62-
return [ImageContent(type="image", data=b64, mimeType="image/png")]
62+
return [types.ImageContent(type="image", data=b64, mimeType="image/png")]
6363

6464

65-
# IMPORTANT: resourceDomains needed for CSP to allow loading SDK from unpkg.com
66-
# Without this, hosts enforcing CSP will block the external script import
67-
@mcp.resource(WIDGET_URI, mime_type="text/html")
68-
def widget() -> dict:
65+
# Register widget resource using FastMCP decorator (returns HTML string)
66+
@mcp.resource(WIDGET_URI, mime_type="text/html;profile=mcp-app")
67+
def widget() -> str:
68+
return Path(__file__).parent.joinpath("widget.html").read_text()
69+
70+
71+
# Override the read_resource handler to inject _meta into the response
72+
# This is needed because FastMCP doesn't support custom _meta on resources
73+
_low_level_server = mcp._mcp_server
74+
75+
76+
async def _read_resource_with_meta(req: types.ReadResourceRequest):
77+
"""Custom handler that injects CSP metadata for the widget resource."""
78+
uri = str(req.params.uri)
6979
html = Path(__file__).parent.joinpath("widget.html").read_text()
70-
return {
71-
"text": html,
72-
"_meta": {
73-
"ui": {
74-
"csp": {
75-
"resourceDomains": ["https://unpkg.com"]
76-
}
77-
}
78-
}
79-
}
8080

81-
# HACK: Bypass SDK's restrictive mime_type validation
82-
# The SDK pattern doesn't allow ";profile=mcp-app" but MCP spec requires it for widgets
83-
# https://github.com/modelcontextprotocol/python-sdk/pull/1755
84-
for resource in mcp._resource_manager._resources.values():
85-
if str(resource.uri) == WIDGET_URI:
86-
object.__setattr__(resource, 'mime_type', 'text/html;profile=mcp-app')
81+
if uri == WIDGET_URI:
82+
# NOTE: Must use model_validate with '_meta' key (not 'meta') due to Pydantic alias behavior
83+
content = types.TextResourceContents.model_validate({
84+
"uri": WIDGET_URI,
85+
"mimeType": "text/html;profile=mcp-app",
86+
"text": html,
87+
# IMPORTANT: all the external domains used by app must be listed
88+
# in the _meta.ui.csp.resourceDomains - otherwise they will be blocked by CSP policy
89+
"_meta": {"ui": {"csp": {"resourceDomains": ["https://unpkg.com"]}}}
90+
})
91+
return types.ServerResult(
92+
types.ReadResourceResult(contents=[content])
93+
)
94+
95+
# Fallback for other resources (shouldn't happen for this server)
96+
return types.ServerResult(
97+
types.ReadResourceResult(
98+
contents=[
99+
types.TextResourceContents(
100+
uri=uri,
101+
mimeType="text/plain",
102+
text="Resource not found"
103+
)
104+
]
105+
)
106+
)
107+
108+
109+
# Replace the handler after FastMCP has registered its own
110+
_low_level_server.request_handlers[types.ReadResourceRequest] = _read_resource_with_meta
87111

88112
if __name__ == "__main__":
89113
if "--stdio" in sys.argv:

examples/qr-server/widget.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
<body>
2727
<div id="qr"></div>
2828
<script type="module">
29-
import { App, PostMessageTransport } from "https://unpkg.com/@modelcontextprotocol/[email protected].4";
29+
import { App, PostMessageTransport } from "https://unpkg.com/@modelcontextprotocol/[email protected].1";
3030

3131
const app = new App({ name: "QR Widget", version: "1.0.0" });
3232

src/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export const RESOURCE_URI_META_KEY = "ui/resourceUri";
7979
/**
8080
* MIME type for MCP UI resources.
8181
*/
82-
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp";
82+
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
8383

8484
/**
8585
* Options for configuring App behavior.

src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,13 @@ export interface McpUiSandboxResourceReadyNotification {
248248
html: string;
249249
/** Optional override for the inner iframe's sandbox attribute */
250250
sandbox?: string;
251+
/** CSP configuration from resource metadata */
252+
csp?: {
253+
/** Origins for network requests (fetch/XHR/WebSocket) */
254+
connectDomains?: string[];
255+
/** Origins for static resources (scripts, images, styles, fonts) */
256+
resourceDomains?: string[];
257+
};
251258
};
252259
}
253260

@@ -260,6 +267,12 @@ export const McpUiSandboxResourceReadyNotificationSchema = z.object({
260267
params: z.object({
261268
html: z.string(),
262269
sandbox: z.string().optional(),
270+
csp: z
271+
.object({
272+
connectDomains: z.array(z.string()).optional(),
273+
resourceDomains: z.array(z.string()).optional(),
274+
})
275+
.optional(),
263276
}),
264277
});
265278

0 commit comments

Comments
 (0)