Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 38 additions & 10 deletions docs/src/guide/client/using-a-proxy.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
# Using a Proxy Script for External URLs
# Using a Proxy Script for External URLs and Raw HTML

When rendering external URLs (`text/uri-list`), 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 external content in a nested iframe.
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.

When `proxy` is set, the external URL is encoded and appended to the proxy URL. For example, if `proxy` is `https://my-proxy.com/`, the final URL will be `https://my-proxy.com/?url=<encoded_original_url>`.
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 via `postMessage` after the proxy iframe signals it's ready.

::: tip 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 external URL content within an iframe. This process occurs locally in the user's browser. User data never reaches a remote server.
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
Expand All @@ -28,6 +31,28 @@ Please verify that the host whitelists `https://proxy.mcpui.dev` as a `frame-src

You can find a complete example for a site with restrictive CSP that uses the hosted proxy at `examples/external-url-demo`.

## Architecture

```mermaid
sequenceDiagram
participant Host as Host Page
participant Proxy as Proxy iframe
participant Inner as Inner iframe (UI widget)
Host->>Proxy: Load proxy (with "?url" or "?contentType=rawhtml")
alt External URL
Proxy->>Inner: Create with src = decoded url
else rawHtml
Proxy-->>Host: ui-proxy-iframe-ready message
Host->>Proxy: ui-html-content message ({ html, sandbox })
Proxy->>Inner: Create with sandbox
Proxy->>Inner: Set srcDoc to HTML
end
Inner-->>Proxy: Messages (e.g., UI actions)
Proxy-->>Host: Relay (Inner -> Host)
Host-->>Proxy: Message responses
Proxy-->>Inner: Relay (Host -> Inner)
```

## 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.
Expand All @@ -38,14 +63,17 @@ If you prefer to host your own proxy script, you can create a simple HTML file w

A valid proxy script must:

1. **Accept a `url` query parameter**: The script should retrieve the target URL from the `url` query parameter in its own URL.
2. **Validate the URL**: It must validate that the provided URL is a valid `http:` or `https:` URL to prevent abuse.
3. **Render in an Iframe**: The script should dynamically create an iframe and set its `src` to the validated target URL.
4. **Sandbox the Iframe**: The iframe must be sandboxed to restrict its capabilities. A minimal sandbox policy would be `allow-scripts allow-same-origin`.
5. **Forward `postMessage` Events**: To allow communication between the host application and the embedded external URL, the proxy needs to forward `message` events between `window.parent` and the iframe's `contentWindow`. For security, it's critical to use a specific `targetOrigin` instead of `*` in `postMessage` calls whenever possible. The `targetOrigin` for messages to the iframe should be the external URL's origin; Messages to the parent will resort to `*`.
1. **External URLs (`url` query parameter)**: Retrieve `url` from the query string, validate it as `http:`/`https:`, and render it in a nested iframe.
2. **Raw HTML (`contentType=rawhtml`)**: When `contentType=rawhtml` is present, the proxy must:
- Create a nested iframe and emit a ready signal (`ui-proxy-iframe-ready`) to `window.parent`.
- Receive a single `postMessage` with `{ html: string, sandbox?: string }` (message type e.g. `ui-html-content`).
- Apply `sandbox` to the inner iframe, then set the inner iframe `srcdoc` to the provided HTML.
3. **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 is `allow-scripts` unless you explicitly need additional capabilities.
4. **Forward `postMessage` Events**: To allow communication between the host application and the embedded external URL, the proxy needs to forward `message` events between `window.parent` and the iframe's `contentWindow`. For security, it's critical to use a specific `targetOrigin` instead of `*` in `postMessage` calls whenever possible. The `targetOrigin` for messages to the iframe should be the external URL's origin; Messages to the parent will default to `*`.
5. **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. You can find this file in `sdks/typescript/client/scripts/proxy/index.html`.
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`.

<<< @/../../sdks/typescript/client/scripts/proxy/index.html
17 changes: 17 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions sdks/typescript/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"devDependencies": {
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@types/jsdom": "^27.0.0",
"@types/react": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
"esbuild": "^0.25.5",
Expand Down
61 changes: 48 additions & 13 deletions sdks/typescript/client/scripts/proxy/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<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,
Expand All @@ -28,7 +30,9 @@
</head>
<body>
<script>
const target = new URLSearchParams(location.search).get('url');
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) {
Expand All @@ -40,27 +44,58 @@
}
}

if (!target) {
document.body.textContent = 'Error: missing url parameter';
} else if (!isValidHttpUrl(target)) {
document.body.textContent = 'Error: invalid URL. Only HTTP and HTTPS URLs are allowed.';
} else {
if (contentType === 'rawhtml') {
// Double-iframe raw HTML mode (HTML sent via postMessage)
const inner = document.createElement('iframe');
inner.src = target;
inner.style = 'width:100%; height:100%; border:none;';
inner.sandbox = 'allow-same-origin allow-scripts';
// sandbox will be set from postMessage payload; default minimal before html arrives
inner.setAttribute('sandbox', 'allow-scripts');
document.body.appendChild(inner);
const urlOrigin = new URL(target).origin;

// Wait for HTML content from parent
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);
if (event.source === window.parent && 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 (event.source === inner.contentWindow) {
// listen for messages from the iframe and send them to the parent
// 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>
Expand Down
71 changes: 52 additions & 19 deletions sdks/typescript/client/src/components/HTMLResourceRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export const InternalMessageType = {
UI_LIFECYCLE_IFRAME_READY: 'ui-lifecycle-iframe-ready',
UI_LIFECYCLE_IFRAME_RENDER_DATA: 'ui-lifecycle-iframe-render-data',
UI_REQUEST_RENDER_DATA: 'ui-request-render-data',

// Proxy-only lifecycle for outer iframe (distinct from widget readiness)
UI_PROXY_IFRAME_READY: 'ui-proxy-iframe-ready',
// Content transport for raw HTML when using proxy
UI_HTML_CONTENT: 'ui-html-content',
} as const;

export const ReservedUrlParams = {
Expand Down Expand Up @@ -88,27 +93,61 @@ export const HTMLResourceRenderer = ({
},
);
}

iframeProps?.onLoad?.(event);
},
[initialRenderData, iframeSrcToRender, iframeProps?.onLoad],
);

const sandbox = useMemo(() => {
if (iframeRenderMode === 'srcDoc') {
// with raw HTML we don't set allow-same-origin for security reasons
return mergeSandboxPermissions(sandboxPermissions ?? '', 'allow-scripts');
}
return mergeSandboxPermissions(sandboxPermissions ?? '', 'allow-scripts allow-same-origin');
}, [sandboxPermissions, iframeRenderMode]);

useEffect(() => {
async function handleMessage(event: MessageEvent) {
const { source, origin, data } = event;
// Only process the message if it came from this specific iframe
if (iframeRef.current && source === iframeRef.current.contentWindow) {
// if the iframe is ready, send the render data to the iframe
if (data?.type === InternalMessageType.UI_LIFECYCLE_IFRAME_READY && initialRenderData) {
postToFrame(
InternalMessageType.UI_LIFECYCLE_IFRAME_RENDER_DATA,
source,
origin,
undefined,
{
renderData: initialRenderData,
},
);
// if the proxy iframe is ready, send the HTML content
if (data?.type === InternalMessageType.UI_PROXY_IFRAME_READY) {
// Send HTML content if in rawhtml proxy mode
if (
iframeRenderMode === 'src' &&
htmlString &&
iframeSrcToRender?.includes('contentType=rawhtml')
) {
postToFrame(
InternalMessageType.UI_HTML_CONTENT,
source,
origin,
undefined,
{
html: htmlString,
sandbox,
},
);
}
return;
}

// if the widget iframe is ready, send the render data
if (data?.type === InternalMessageType.UI_LIFECYCLE_IFRAME_READY) {
// Send render data if present
if (initialRenderData) {
postToFrame(
InternalMessageType.UI_LIFECYCLE_IFRAME_RENDER_DATA,
source,
origin,
undefined,
{
renderData: initialRenderData,
},
);
}
return;
}

Expand Down Expand Up @@ -170,17 +209,11 @@ export const HTMLResourceRenderer = ({
}
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [onUIAction]);
}, [onUIAction, initialRenderData, iframeRenderMode, htmlString, iframeSrcToRender, sandbox]);

if (error) return <p className="text-red-500">{error}</p>;

const sandbox = useMemo(() => {
if (iframeRenderMode === 'srcDoc') {
// with raw HTML we don't set allow-same-origin for security reasons
return mergeSandboxPermissions(sandboxPermissions ?? '', 'allow-scripts');
}
return mergeSandboxPermissions(sandboxPermissions ?? '', 'allow-scripts allow-same-origin');
}, [sandboxPermissions, iframeRenderMode]);


if (iframeRenderMode === 'srcDoc') {
if (htmlString === null || htmlString === undefined) {
Expand Down
8 changes: 1 addition & 7 deletions sdks/typescript/client/src/components/UIResourceRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,7 @@ export const UIResourceRenderer = (props: UIResourceRendererProps) => {
}

switch (contentType) {
case 'rawHtml': {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { proxy, ...otherHtmlProps } = htmlProps || {};
return (
<HTMLResourceRenderer resource={resource} onUIAction={onUIAction} {...otherHtmlProps} />
);
}
case 'rawHtml':
case 'externalUrl': {
return <HTMLResourceRenderer resource={resource} onUIAction={onUIAction} {...htmlProps} />;
}
Expand Down
Loading
Loading