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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ It accepts the following props:
- **`style`**: Optional custom styles for the iframe
- **`iframeProps`**: Optional props passed to the 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.
- **`remoteDomProps`**: Optional props for the internal `<RemoteDOMResourceRenderer>`
- **`library`**: Optional component library for Remote DOM resources (defaults to `basicComponentLibrary`)
- **`remoteElements`**: remote element definitions for Remote DOM resources.
Expand Down
35 changes: 35 additions & 0 deletions docs/src/guide/client/html-resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface HTMLResourceRendererProps {
onUIAction?: (result: UIActionResult) => Promise<any>;
style?: React.CSSProperties;
proxy?: string;
iframeRenderData?: Record<string, unknown>;
autoResizeIframe?: boolean | { width?: boolean; height?: boolean };
iframeProps?: Omit<React.HTMLAttributes<HTMLIFrameElement>, 'src' | 'srcDoc' | 'ref' | 'style'>;
}
```
Expand All @@ -38,6 +40,7 @@ The component accepts the following props:
- **`style`**: (Optional) Custom styles for the iframe.
- **`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.

## How It Works

Expand Down Expand Up @@ -67,6 +70,38 @@ By default, the iframe stretches to 100% width and is at least 200px tall. You c

See [Client SDK Usage & Examples](./usage-examples.md) for examples using the recommended `<UIResourceRenderer />` component.

## Auto-Resizing the Iframe

To make the iframe auto-resize, two things need to happen:
1. The `autoResizeIframe` prop must be set in `htmlProps`when rendering `<UIResourceRenderer />`).
2. The content inside the iframe must send a `ui-size-change` message to the parent window when its size changes.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Namespacing


The payload of the message should be an object with `width` and/or `height` properties.

### Example Iframe Implementation

Here is an example of how you can use a `ResizeObserver` within your iframe's content to notify the host application of size changes:

```javascript
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
window.parent.postMessage(
{
type: "ui-size-change",
Copy link
Collaborator

Choose a reason for hiding this comment

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

Perhaps mcp-ui-size-change for namespacing

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

note that all the other events start with ui-, though:

export const InternalMessageType = {
  UI_ACTION_RECEIVED: 'ui-action-received',
  UI_ACTION_RESPONSE: 'ui-action-response',
  UI_ACTION_ERROR: 'ui-action-error',

  UI_SIZE_CHANGE: 'ui-size-change',

  UI_LIFECYCLE_IFRAME_READY: 'ui-lifecycle-iframe-ready',
  UI_LIFECYCLE_IFRAME_RENDER_DATA: 'ui-lifecycle-iframe-render-data',
};

payload: {
height: entry.contentRect.height,
},
},
"*",
);
});
});

resizeObserver.observe(document.documentElement)
```

This will observe the root `<html>` element and send a message whenever its height changes. The `<HTMLResourceRenderer />` will catch this message and adjust the iframe's height accordingly. You can also include `width` in the payload if you need to resize the width.

## Security Notes

- **`sandbox` attribute**: Restricts what the iframe can do. `allow-scripts` is required for JS execution. In the external URL content type, `allow-same-origin` is needed for external apps. Other than these inclusions, all other capabilities are restricted (e.g., no parent access, top-level navigations, modals, forms, etc.)
Expand Down
44 changes: 44 additions & 0 deletions docs/src/guide/client/resource-renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ interface UIResourceRendererProps {
- **`iframeProps`**: Optional props passed to iframe elements (for HTML/URL resources)
- **`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.
- **`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 @@ -196,6 +197,49 @@ if (urlParams.get('waitForRenderData') === 'true') {
}
```

### Automatically Resizing the Iframe

The `autoResizeIframe` prop allows you to automatically resize the iframe to the size of the content.

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

The `autoResizeIframe` prop can be a boolean or an object with the following properties:

- **`width`**: Optional boolean to automatically resize the iframe's width to the size of the content.
- **`height`**: Optional boolean to automatically resize the iframe's height to the size of the content.

If `autoResizeIframe` is a boolean, the iframe will be resized to the size of the content.

Inside the iframe, you can listen for the `ui-size-change` message and resize the iframe to the size of the content.

```javascript
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
window.parent.postMessage(
{
type: "ui-size-change",
payload: {
height: entry.contentRect.height,
},
},
"*",
);
});
});

resizeObserver.observe(document.documentElement)
```

See [Automatically Resizing the Iframe](./html-resource.md#automatically-resizing-the-iframe) for a more detailed example.

### Accessing the Iframe Element

You can pass a ref through `iframeProps` to access the underlying iframe element:
Expand Down
22 changes: 22 additions & 0 deletions sdks/typescript/client/src/components/HTMLResourceRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type HTMLResourceRendererProps = {
style?: React.CSSProperties;
proxy?: string;
iframeRenderData?: Record<string, unknown>;
autoResizeIframe?: boolean | { width?: boolean; height?: boolean };
iframeProps?: Omit<React.HTMLAttributes<HTMLIFrameElement>, 'src' | 'srcDoc' | 'style'> & {
ref?: React.RefObject<HTMLIFrameElement>;
};
Expand All @@ -19,6 +20,8 @@ const InternalMessageType = {
UI_ACTION_RESPONSE: 'ui-action-response',
UI_ACTION_ERROR: 'ui-action-error',

UI_SIZE_CHANGE: 'ui-size-change',

UI_LIFECYCLE_IFRAME_READY: 'ui-lifecycle-iframe-ready',
UI_LIFECYCLE_IFRAME_RENDER_DATA: 'ui-lifecycle-iframe-render-data',
} as const;
Expand All @@ -33,6 +36,7 @@ export const HTMLResourceRenderer = ({
style,
proxy,
iframeRenderData,
autoResizeIframe,
iframeProps,
}: HTMLResourceRendererProps) => {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
Expand Down Expand Up @@ -91,6 +95,24 @@ export const HTMLResourceRenderer = ({
return;
}

if (data?.type === InternalMessageType.UI_SIZE_CHANGE) {
const { width, height } = data.payload as { width?: number; height?: number };
if (autoResizeIframe && iframeRef.current) {
const shouldAdjustHeight =
(typeof autoResizeIframe === 'boolean' || autoResizeIframe.height) && height;
const shouldAdjustWidth =
(typeof autoResizeIframe === 'boolean' || autoResizeIframe.width) && width;

if (shouldAdjustHeight) {
iframeRef.current.style.height = `${height}px`;
}
if (shouldAdjustWidth) {
iframeRef.current.style.width = `${width}px`;
}
}
return;
}

const uiActionResult = data as UIActionResult;
if (!uiActionResult) {
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render } from '@testing-library/react';
import { fireEvent, render } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { Resource } from '@modelcontextprotocol/sdk/types.js';
Expand All @@ -7,12 +7,58 @@ import { UIResourceRenderer } from '../UIResourceRenderer';
describe('UIResourceRenderer', () => {
const testResource: Partial<Resource> = {
mimeType: 'text/html',
text: '<html><body><h1>Test Content</h1><script>console.log("iframe script loaded for onUIAction tests")</script></body></html>',
text: `<html><body><h1>Test Content</h1><script>
console.log("iframe script loaded for onUIAction tests");
</script></body></html>`,
uri: 'ui://test-resource',
};
it('should pass ref to HTMLResourceRenderer', () => {
const ref = React.createRef<HTMLIFrameElement>();
render(<UIResourceRenderer resource={testResource} htmlProps={{ iframeProps: { ref } }} />);
expect(ref.current).toBeInTheDocument();
});

it('should respect a ui-size-change message', () => {
const ref = React.createRef<HTMLIFrameElement>();
render(
<UIResourceRenderer
resource={testResource}
htmlProps={{ iframeProps: { ref }, autoResizeIframe: true }}
/>,
);
expect(ref.current).toBeInTheDocument();
dispatchMessage(ref.current?.contentWindow ?? null, {
type: 'ui-size-change',
payload: { width: 100, height: 100 },
});
expect(ref.current?.style.width).toBe('100px');
expect(ref.current?.style.height).toBe('100px');
});

it('should respect a limited ui-size-change prop', () => {
const ref = React.createRef<HTMLIFrameElement>();
render(
<UIResourceRenderer
resource={testResource}
htmlProps={{ iframeProps: { ref }, autoResizeIframe: { width: true, height: false } }}
/>,
);
expect(ref.current).toBeInTheDocument();
dispatchMessage(ref.current?.contentWindow ?? null, {
type: 'ui-size-change',
payload: { width: 100, height: 100 },
});
expect(ref.current?.style.width).toBe('100px');
expect(ref.current?.style.height).toBe('');
});
});

const dispatchMessage = (source: Window | null, data: Record<string, unknown> | null) => {
fireEvent(
window,
new MessageEvent('message', {
data,
source,
}),
);
};
2 changes: 2 additions & 0 deletions sdks/typescript/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ export const InternalMessageType = {
UI_ACTION_RESPONSE: 'ui-action-response',
UI_ACTION_ERROR: 'ui-action-error',

UI_SIZE_CHANGE: 'ui-size-change',
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
UI_SIZE_CHANGE: 'ui-size-change',
UI_SIZE_CHANGE: 'mcp-ui-size-change',


UI_LIFECYCLE_IFRAME_READY: 'ui-lifecycle-iframe-ready',
UI_LIFECYCLE_IFRAME_RENDER_DATA: 'ui-lifecycle-iframe-render-data',
};
Expand Down