Using a Proxy Script for External URLs and Raw HTML
When rendering external URLs (text/uri-list) or raw HTML (text/html), you may need to use a "proxy" to comply with your host's restrictive Content Security Policy (CSP). The proxy domain must be whitelisted as a frame-src. The proxy prop on <UIResourceRenderer> allows you to specify a URL for a proxy script that will render the content in a nested iframe on a different origin.
There are two proxy flows:
- External URLs: the external URL is encoded and appended as
?url=<encoded_original_url>to the proxy URL. For example:https://my-proxy.com/?url=<encoded_original_url>. - Raw HTML: the proxy is loaded with
?contentType=rawhtml, and the HTML and inner iframe sandbox are delivered viapostMessageafter the proxy iframe signals it's ready.
Important
The term "proxy" in this context does not refer to a real proxy server. It is a static, client-side script that nests the UI resource's iframe within a "proxy" iframe. This process occurs locally in the user's browser. User data never reaches a remote server.
Using the Hosted Proxy
For convenience, mcp-ui provides a hosted proxy script at https://proxy.mcpui.dev. You can use this URL directly as the proxy prop value without any additional setup.
import { UIResourceRenderer } from '@mcp-ui/client';
<UIResourceRenderer
resource={mcpResource.resource}
htmlProps={{
proxy: 'https://proxy.mcpui.dev'
}}
onUIAction={handleUIAction}
/>2
3
4
5
6
7
8
9
Please verify that the host whitelists https://proxy.mcpui.dev as a frame-src in the CSP.
You can find a complete example for a site with restrictive CSP that uses the hosted proxy at examples/external-url-demo.
Architecture
Self-Hosting the Proxy Script
If you prefer to host your own proxy script, you can create a simple HTML file with embedded JavaScript. This is a useful alternative to the hosted version when you want more control or a custom domain.
IMPORTANT: For security reasons, you MUST NOT host the proxy script on the same origin as your main application. mcp-ui/client will automatically log an error and fallback to direct iframe if the same origin is detected.
Proxy Script Requirements
A valid proxy script must:
- External URLs (
urlquery parameter): Retrieveurlfrom the query string, validate it ashttp:/https:, and render it in a nested iframe. - Raw HTML (
contentType=rawhtml): WhencontentType=rawhtmlis present, the proxy must:- Create a nested iframe and emit a ready signal (
ui-proxy-iframe-ready) towindow.parent. - Receive a single
postMessagewith{ html: string, sandbox?: string }(message type e.g.ui-html-content). - Apply
sandboxto the inner iframe, then set the inner iframesrcdocto the provided HTML.
- Create a nested iframe and emit a ready signal (
- Sandbox the Iframe: The nested iframe must be sandboxed to restrict capabilities. For external URLs a minimal policy is
allow-scripts allow-same-origin; for raw HTML a minimal policy isallow-scriptsunless you explicitly need additional capabilities. - Forward
postMessageEvents: To allow communication between the host application and the embedded external URL, the proxy needs to forwardmessageevents betweenwindow.parentand the iframe'scontentWindow. For security, it's critical to use a specifictargetOrigininstead of*inpostMessagecalls whenever possible. ThetargetOriginfor messages to the iframe should be the external URL's origin; Messages to the parent will default to*. - Permissive Proxy CSP: Serve the proxy page with a permissive CSP that does not block nested iframe content (e.g., allowing scripts, styles, images) since the host CSP is intentionally not applied on the proxy origin.
Example Self-Hosted Proxy
Here is an example of a self-hosted proxy script that meets these requirements (supports both url for external URLs and contentType=rawhtml + postMessage for raw HTML). You can find this file in sdks/typescript/client/scripts/proxy/index.html.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<!-- Permissive CSP so nested content is not constrained by host CSP -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' 'unsafe-eval' blob: https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://unpkg.com; style-src * 'unsafe-inline'; connect-src *; frame-src 'none'; base-uri 'self'; upgrade-insecure-requests;" />
<title>MCP-UI Proxy</title>
<style>
html,
body {
margin: 0;
height: 100vh;
width: 100vw;
}
body {
display: flex;
flex-direction: column;
}
* {
box-sizing: border-box;
}
iframe {
background-color: transparent;
border: 0px none transparent;
padding: 0px;
overflow: hidden;
flex-grow: 1;
}
</style>
</head>
<body>
<script>
const params = new URLSearchParams(location.search);
const contentType = params.get('contentType');
const target = params.get('url');
// Validate that the URL is a valid HTTP or HTTPS URL
function isValidHttpUrl(string) {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch (error) {
return false;
}
}
if (contentType === 'rawhtml') {
// Double-iframe raw HTML mode (HTML sent via postMessage)
const inner = document.createElement('iframe');
inner.style = 'width:100%; height:100%; border:none;';
// sandbox will be set from postMessage payload; default minimal before html arrives
inner.setAttribute('sandbox', 'allow-scripts');
document.body.appendChild(inner);
// Wait for HTML content from parent
window.addEventListener('message', (event) => {
if (event.source === window.parent) {
if (event.data && event.data.type === 'ui-html-content') {
const payload = event.data.payload || {};
const html = payload.html;
const sandbox = payload.sandbox;
if (typeof sandbox === 'string') {
inner.setAttribute('sandbox', sandbox);
}
if (typeof html === 'string') {
inner.srcdoc = html;
}
} else {
if (inner && inner.contentWindow) {
inner.contentWindow.postMessage(event.data, '*');
}
}
} else if (event.source === inner.contentWindow) {
// Relay messages from inner to parent
window.parent.postMessage(event.data, '*');
}
});
// Notify parent that proxy is ready to receive HTML (distinct event)
window.parent.postMessage({ type: 'ui-proxy-iframe-ready' }, '*');
} else if (target) {
if (!isValidHttpUrl(target)) {
document.body.textContent = 'Error: invalid URL. Only HTTP and HTTPS URLs are allowed.';
} else {
const inner = document.createElement('iframe');
inner.src = target;
inner.style = 'width:100%; height:100%; border:none;';
// Default external URL sandbox; can be adjusted later by protocol if needed
inner.setAttribute('sandbox', 'allow-same-origin allow-scripts');
document.body.appendChild(inner);
const urlOrigin = new URL(target).origin;
window.addEventListener('message', (event) => {
if (event.source === window.parent) {
// listen for messages from the parent and send them to the iframe
inner.contentWindow.postMessage(event.data, urlOrigin);
} else if (event.source === inner.contentWindow) {
// listen for messages from the iframe and send them to the parent
window.parent.postMessage(event.data, '*');
}
});
}
} else {
document.body.textContent = 'Error: missing url or html parameter';
}
</script>
</body>
</html>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108

