Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/src/guide/client/html-resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface HTMLResourceRendererProps {
proxy?: string;
iframeRenderData?: Record<string, unknown>;
autoResizeIframe?: boolean | { width?: boolean; height?: boolean };
useSrcDoc?: boolean;
sandboxPermissions?: string;
iframeProps?: Omit<React.HTMLAttributes<HTMLIFrameElement>, 'src' | 'srcDoc' | 'ref' | 'style'>;
}
Expand Down Expand Up @@ -41,6 +42,7 @@ The component accepts the following props:
- **`proxy`**: (Optional) A URL to a proxy script. This is useful for hosts with a strict Content Security Policy (CSP). When provided, external URLs will be rendered in a nested iframe hosted at this URL. For example, if `proxy` is `https://my-proxy.com/`, the final URL will be `https://my-proxy.com/?url=<encoded_original_url>`. For your convenience, mcp-ui hosts a proxy script at `https://proxy.mcpui.dev`, which you can use as a the prop value without any setup (see `examples/external-url-demo`).
- **`iframeProps`**: (Optional) Custom props for the iframe.
- **`autoResizeIframe`**: (Optional) When enabled, the iframe will automatically resize based on messages from the iframe's content. This prop can be a boolean (to enable both width and height resizing) or an object (`{width?: boolean, height?: boolean}`) to control dimensions independently.
- **`useSrcDoc`**: (Optional) Defaults to `false`. When `false` (default), HTML content is rendered using a blob URL set as the iframe's `src` attribute. When `true`, HTML content is rendered using the `srcDoc` attribute with the raw HTML string. Setting this to `true` can be useful in environments with strict Content Security Policies that block blob URLs.
- **`sandboxPermissions`**: (Optional) Additional iframe sandbox permissions to add to the defaults. These are merged with:
- External URLs (`text/uri-list`): `'allow-scripts allow-same-origin'`
- Raw HTML content (`text/html`): `'allow-scripts'`
Expand Down
15 changes: 15 additions & 0 deletions docs/src/guide/client/resource-renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ interface UIResourceRendererProps {
- **`ref`**: Optional React ref to access the underlying iframe element
- **`iframeRenderData`**: Optional `Record<string, unknown>` to pass data to the iframe upon rendering. This enables advanced use cases where the parent application needs to provide initial state or configuration to the sandboxed iframe content.
- **`autoResizeIframe`**: Optional `boolean | { width?: boolean; height?: boolean }` to automatically resize the iframe to the size of the content.
- **`useSrcDoc`**: Optional `boolean` (defaults to `false`). When `false` (default), HTML content is rendered using a blob URL set as the iframe's `src` attribute. When `true`, HTML content is rendered using the `srcDoc` attribute with the raw HTML string. Useful in environments with strict Content Security Policies that block blob URLs.
- **`remoteDomProps`**: Optional props for the `<RemoteDOMResourceRenderer>`
- **`library`**: Optional component library for Remote DOM resources (defaults to `basicComponentLibrary`)
- **`remoteElements`**: Optional remote element definitions for Remote DOM resources. REQUIRED for Remote DOM snippets.
Expand Down Expand Up @@ -173,6 +174,20 @@ The function provides type narrowing, so TypeScript will understand that `conten
/>
```

### Using srcDoc Instead of Blob URLs

By default, HTML content is rendered using blob URLs for better security. If you need to use `srcDoc` instead (e.g., for environments with strict CSP policies that block blob URLs):

```tsx
<UIResourceRenderer
resource={mcpResource.resource}
htmlProps={{
useSrcDoc: true
}}
onUIAction={handleUIAction}
/>
```

### Passing Render-Time Data to Iframes

The `iframeRenderData` prop allows you to send a data payload to an iframe as it renders. This is useful for initializing the iframe with dynamic data from the parent application.
Expand Down
24 changes: 23 additions & 1 deletion sdks/typescript/client/src/components/HTMLResourceRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type HTMLResourceRendererProps = {
proxy?: string;
iframeRenderData?: Record<string, unknown>;
autoResizeIframe?: boolean | { width?: boolean; height?: boolean };
useSrcDoc?: boolean;
sandboxPermissions?: string;
iframeProps?: Omit<React.HTMLAttributes<HTMLIFrameElement>, 'src' | 'srcDoc' | 'style'> & {
ref?: React.RefObject<HTMLIFrameElement>;
Expand Down Expand Up @@ -37,11 +38,13 @@ export const HTMLResourceRenderer = ({
proxy,
iframeRenderData,
autoResizeIframe,
useSrcDoc,
sandboxPermissions,
iframeProps,
}: HTMLResourceRendererProps) => {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
useImperativeHandle(iframeProps?.ref, () => iframeRef.current as HTMLIFrameElement);
const iframeSrcBlobUrl = useRef<string | null>(null);

const { error, iframeSrc, iframeRenderMode, htmlString } = useMemo(
() => processHTMLResource(resource, proxy),
Expand Down Expand Up @@ -72,6 +75,10 @@ export const HTMLResourceRenderer = ({
},
);
}
if (iframeSrcBlobUrl.current && typeof window?.URL?.revokeObjectURL === 'function') {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you managed to create the blob since the create function existed, it necessarily translates to the revoke function existing as well, therefore check might be redundant (nit)

window.URL.revokeObjectURL(iframeSrcBlobUrl.current);
iframeSrcBlobUrl.current = null;
}
iframeProps?.onLoad?.(event);
},
[iframeRenderData, iframeSrcToRender, iframeProps?.onLoad],
Expand Down Expand Up @@ -159,9 +166,24 @@ export const HTMLResourceRenderer = ({
}
return null;
}
let iframeSrcProp: {
srcDoc?: string;
} | {
src?: string;
} = {
srcDoc: htmlString,
}
if (!useSrcDoc && typeof URL.createObjectURL === 'function') {
const blob = new Blob([htmlString], { type: 'text/html' });
const blobUrl = URL.createObjectURL(blob);
iframeSrcBlobUrl.current = blobUrl;
iframeSrcProp = {
src: blobUrl,
};
}
return (
<iframe
srcDoc={htmlString}
{...iframeSrcProp}
sandbox={sandbox}
style={{ width: '100%', height: '100%', ...style }}
title="MCP HTML Resource (Embedded Content)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,49 @@ describe('HTMLResource component', () => {
expect(iframe.srcdoc).toContain(html);
});

it('sets blob HTML src without useSrcDoc', () => {
const originalCreateObjectURL = window.URL.createObjectURL;
window.URL.createObjectURL = (blob: Blob) => {
return `blob:${blob.size}`;
}
const html = '<p>Blob Content</p>';
const encodedHtml = Buffer.from(html).toString('base64');
const props: HTMLResourceRendererProps = {
resource: {
uri: 'ui://blob-test',
mimeType: 'text/html',
blob: encodedHtml,
},
onUIAction: mockOnUIAction,
};
render(<HTMLResourceRenderer {...props} />);
window.URL.createObjectURL = originalCreateObjectURL;
const iframe = screen.getByTitle('MCP HTML Resource (Embedded Content)') as HTMLIFrameElement;
expect(iframe.src).toContain(`blob:${new Blob([html]).size}`);
})

it('respects useSrcDoc', () => {
const originalCreateObjectURL = window.URL.createObjectURL;
window.URL.createObjectURL = (blob: Blob) => {
return `blob:${blob.size}`;
}
const html = '<p>Blob Content</p>';
const encodedHtml = Buffer.from(html).toString('base64');
const props: HTMLResourceRendererProps = {
resource: {
uri: 'ui://blob-test',
mimeType: 'text/html',
blob: encodedHtml,
},
onUIAction: mockOnUIAction,
useSrcDoc: true,
};
render(<HTMLResourceRenderer {...props} />);
window.URL.createObjectURL = originalCreateObjectURL;
const iframe = screen.getByTitle('MCP HTML Resource (Embedded Content)') as HTMLIFrameElement;
expect(iframe.srcdoc).toContain(html);
})

it('decodes URL from blob for ui:// resource with text/uri-list mimetype', () => {
const url = 'https://example.com/blob-app';
const encodedUrl = Buffer.from(url).toString('base64');
Expand All @@ -108,7 +151,7 @@ describe('HTMLResource component', () => {
});

it('handles multiple URLs in uri-list format and uses the first one', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
const uriList =
'https://example.com/first\nhttps://example.com/second\nhttps://example.com/third';
const props: HTMLResourceRendererProps = {
Expand Down Expand Up @@ -219,7 +262,7 @@ describe('HTMLResource iframe communication', () => {
};

const mockOnUIAction = vi.fn<[UIActionResult], Promise<unknown>>();
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });

const renderComponentForUIActionTests = (props: Partial<HTMLResourceRendererProps> = {}) => {
return render(
Expand Down