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 content is 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 to
window.parentwith message{ type: 'ui-proxy-iframe-ready' }. - Receive a
postMessagefromwindow.parentwith structure:typescript{ type: 'ui-html-content', payload: { html: string } }1
2
3
4
5
6 - Write the HTML content to the iframe using
document.write()or similar method.
- Create a nested iframe and emit a ready signal to
- Sandbox the Iframe: For external URLs, the nested iframe should be sandboxed with
allow-scripts allow-same-origin. For raw HTML mode, the inner iframe does not use a sandbox attribute—this is intentional becausedocument.write()requires same-origin access to the iframe's document. Security for raw HTML is enforced by the outer iframe's sandbox (controlled by the host) and the double-iframe isolation architecture. - 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" />
<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.id = 'root';
let pendingHtml = null;
// Helper function to write HTML using document.write
const renderHtmlInIframe = (markup) => {
const doc = inner.contentDocument || inner.contentWindow?.document;
if (!doc) return false;
try {
doc.open();
doc.write(markup);
doc.close();
return true;
} catch (error) {
console.error('Failed to write HTML to iframe:', error);
return false;
}
};
// Retry writing pending HTML when iframe finishes loading
inner.addEventListener('load', () => {
if (pendingHtml !== null && renderHtmlInIframe(pendingHtml)) {
pendingHtml = null;
}
});
inner.style = 'width:100%; height:100%; border:none;';
// Set src to about:blank so browser initializes contentDocument
inner.src = 'about:blank';
document.body.appendChild(inner);
// Wait for HTML content from parent
window.addEventListener('message', (event) => {
if (event.source === window.parent && event.data && event.data.type === 'ui-html-content') {
const payload = event.data.payload || {};
const html = payload.html;
if (typeof html === 'string') {
// Try to write immediately; if contentDocument isn't ready, queue for retry
if (!renderHtmlInIframe(html)) {
pendingHtml = html;
}
}
} else if (event.source === window.parent) {
// Forward other messages from parent to inner iframe
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
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130

