Skip to content

Commit 76c867a

Browse files
authored
feat: auto resize with the autoResizeIframe prop (#56)
1 parent 2b3ffb8 commit 76c867a

6 files changed

Lines changed: 152 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ It accepts the following props:
8989
- **`style`**: Optional custom styles for the iframe
9090
- **`iframeProps`**: Optional props passed to the iframe element
9191
- **`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.
92+
- **`autoResizeIframe`**: Optional `boolean | { width?: boolean; height?: boolean }` to automatically resize the iframe to the size of the content.
9293
- **`remoteDomProps`**: Optional props for the internal `<RemoteDOMResourceRenderer>`
9394
- **`library`**: Optional component library for Remote DOM resources (defaults to `basicComponentLibrary`)
9495
- **`remoteElements`**: remote element definitions for Remote DOM resources.

docs/src/guide/client/html-resource.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export interface HTMLResourceRendererProps {
1212
onUIAction?: (result: UIActionResult) => Promise<any>;
1313
style?: React.CSSProperties;
1414
proxy?: string;
15+
iframeRenderData?: Record<string, unknown>;
16+
autoResizeIframe?: boolean | { width?: boolean; height?: boolean };
1517
iframeProps?: Omit<React.HTMLAttributes<HTMLIFrameElement>, 'src' | 'srcDoc' | 'ref' | 'style'>;
1618
}
1719
```
@@ -38,6 +40,7 @@ The component accepts the following props:
3840
- **`style`**: (Optional) Custom styles for the iframe.
3941
- **`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`).
4042
- **`iframeProps`**: (Optional) Custom props for the iframe.
43+
- **`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.
4144

4245
## How It Works
4346

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

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

73+
## Auto-Resizing the Iframe
74+
75+
To make the iframe auto-resize, two things need to happen:
76+
1. The `autoResizeIframe` prop must be set in `htmlProps`when rendering `<UIResourceRenderer />`).
77+
2. The content inside the iframe must send a `ui-size-change` message to the parent window when its size changes.
78+
79+
The payload of the message should be an object with `width` and/or `height` properties.
80+
81+
### Example Iframe Implementation
82+
83+
Here is an example of how you can use a `ResizeObserver` within your iframe's content to notify the host application of size changes:
84+
85+
```javascript
86+
const resizeObserver = new ResizeObserver((entries) => {
87+
entries.forEach((entry) => {
88+
window.parent.postMessage(
89+
{
90+
type: "ui-size-change",
91+
payload: {
92+
height: entry.contentRect.height,
93+
},
94+
},
95+
"*",
96+
);
97+
});
98+
});
99+
100+
resizeObserver.observe(document.documentElement)
101+
```
102+
103+
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.
104+
70105
## Security Notes
71106

72107
- **`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.)

docs/src/guide/client/resource-renderer.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ interface UIResourceRendererProps {
5151
- **`iframeProps`**: Optional props passed to iframe elements (for HTML/URL resources)
5252
- **`ref`**: Optional React ref to access the underlying iframe element
5353
- **`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.
54+
- **`autoResizeIframe`**: Optional `boolean | { width?: boolean; height?: boolean }` to automatically resize the iframe to the size of the content.
5455
- **`remoteDomProps`**: Optional props for the `<RemoteDOMResourceRenderer>`
5556
- **`library`**: Optional component library for Remote DOM resources (defaults to `basicComponentLibrary`)
5657
- **`remoteElements`**: Optional remote element definitions for Remote DOM resources. REQUIRED for Remote DOM snippets.
@@ -196,6 +197,49 @@ if (urlParams.get('waitForRenderData') === 'true') {
196197
}
197198
```
198199

200+
### Automatically Resizing the Iframe
201+
202+
The `autoResizeIframe` prop allows you to automatically resize the iframe to the size of the content.
203+
204+
```tsx
205+
<UIResourceRenderer
206+
resource={mcpResource.resource}
207+
htmlProps={{
208+
autoResizeIframe: true,
209+
}}
210+
onUIAction={handleUIAction}
211+
/>
212+
```
213+
214+
The `autoResizeIframe` prop can be a boolean or an object with the following properties:
215+
216+
- **`width`**: Optional boolean to automatically resize the iframe's width to the size of the content.
217+
- **`height`**: Optional boolean to automatically resize the iframe's height to the size of the content.
218+
219+
If `autoResizeIframe` is a boolean, the iframe will be resized to the size of the content.
220+
221+
Inside the iframe, you can listen for the `ui-size-change` message and resize the iframe to the size of the content.
222+
223+
```javascript
224+
const resizeObserver = new ResizeObserver((entries) => {
225+
entries.forEach((entry) => {
226+
window.parent.postMessage(
227+
{
228+
type: "ui-size-change",
229+
payload: {
230+
height: entry.contentRect.height,
231+
},
232+
},
233+
"*",
234+
);
235+
});
236+
});
237+
238+
resizeObserver.observe(document.documentElement)
239+
```
240+
241+
See [Automatically Resizing the Iframe](./html-resource.md#automatically-resizing-the-iframe) for a more detailed example.
242+
199243
### Accessing the Iframe Element
200244

201245
You can pass a ref through `iframeProps` to access the underlying iframe element:

sdks/typescript/client/src/components/HTMLResourceRenderer.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type HTMLResourceRendererProps = {
99
style?: React.CSSProperties;
1010
proxy?: string;
1111
iframeRenderData?: Record<string, unknown>;
12+
autoResizeIframe?: boolean | { width?: boolean; height?: boolean };
1213
iframeProps?: Omit<React.HTMLAttributes<HTMLIFrameElement>, 'src' | 'srcDoc' | 'style'> & {
1314
ref?: React.RefObject<HTMLIFrameElement>;
1415
};
@@ -19,6 +20,8 @@ const InternalMessageType = {
1920
UI_ACTION_RESPONSE: 'ui-action-response',
2021
UI_ACTION_ERROR: 'ui-action-error',
2122

23+
UI_SIZE_CHANGE: 'ui-size-change',
24+
2225
UI_LIFECYCLE_IFRAME_READY: 'ui-lifecycle-iframe-ready',
2326
UI_LIFECYCLE_IFRAME_RENDER_DATA: 'ui-lifecycle-iframe-render-data',
2427
} as const;
@@ -33,6 +36,7 @@ export const HTMLResourceRenderer = ({
3336
style,
3437
proxy,
3538
iframeRenderData,
39+
autoResizeIframe,
3640
iframeProps,
3741
}: HTMLResourceRendererProps) => {
3842
const iframeRef = useRef<HTMLIFrameElement | null>(null);
@@ -91,6 +95,24 @@ export const HTMLResourceRenderer = ({
9195
return;
9296
}
9397

98+
if (data?.type === InternalMessageType.UI_SIZE_CHANGE) {
99+
const { width, height } = data.payload as { width?: number; height?: number };
100+
if (autoResizeIframe && iframeRef.current) {
101+
const shouldAdjustHeight =
102+
(typeof autoResizeIframe === 'boolean' || autoResizeIframe.height) && height;
103+
const shouldAdjustWidth =
104+
(typeof autoResizeIframe === 'boolean' || autoResizeIframe.width) && width;
105+
106+
if (shouldAdjustHeight) {
107+
iframeRef.current.style.height = `${height}px`;
108+
}
109+
if (shouldAdjustWidth) {
110+
iframeRef.current.style.width = `${width}px`;
111+
}
112+
}
113+
return;
114+
}
115+
94116
const uiActionResult = data as UIActionResult;
95117
if (!uiActionResult) {
96118
return;
Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render } from '@testing-library/react';
1+
import { fireEvent, render } from '@testing-library/react';
22
import '@testing-library/jest-dom';
33
import React from 'react';
44
import { Resource } from '@modelcontextprotocol/sdk/types.js';
@@ -7,12 +7,58 @@ import { UIResourceRenderer } from '../UIResourceRenderer';
77
describe('UIResourceRenderer', () => {
88
const testResource: Partial<Resource> = {
99
mimeType: 'text/html',
10-
text: '<html><body><h1>Test Content</h1><script>console.log("iframe script loaded for onUIAction tests")</script></body></html>',
10+
text: `<html><body><h1>Test Content</h1><script>
11+
console.log("iframe script loaded for onUIAction tests");
12+
</script></body></html>`,
1113
uri: 'ui://test-resource',
1214
};
1315
it('should pass ref to HTMLResourceRenderer', () => {
1416
const ref = React.createRef<HTMLIFrameElement>();
1517
render(<UIResourceRenderer resource={testResource} htmlProps={{ iframeProps: { ref } }} />);
1618
expect(ref.current).toBeInTheDocument();
1719
});
20+
21+
it('should respect a ui-size-change message', () => {
22+
const ref = React.createRef<HTMLIFrameElement>();
23+
render(
24+
<UIResourceRenderer
25+
resource={testResource}
26+
htmlProps={{ iframeProps: { ref }, autoResizeIframe: true }}
27+
/>,
28+
);
29+
expect(ref.current).toBeInTheDocument();
30+
dispatchMessage(ref.current?.contentWindow ?? null, {
31+
type: 'ui-size-change',
32+
payload: { width: 100, height: 100 },
33+
});
34+
expect(ref.current?.style.width).toBe('100px');
35+
expect(ref.current?.style.height).toBe('100px');
36+
});
37+
38+
it('should respect a limited ui-size-change prop', () => {
39+
const ref = React.createRef<HTMLIFrameElement>();
40+
render(
41+
<UIResourceRenderer
42+
resource={testResource}
43+
htmlProps={{ iframeProps: { ref }, autoResizeIframe: { width: true, height: false } }}
44+
/>,
45+
);
46+
expect(ref.current).toBeInTheDocument();
47+
dispatchMessage(ref.current?.contentWindow ?? null, {
48+
type: 'ui-size-change',
49+
payload: { width: 100, height: 100 },
50+
});
51+
expect(ref.current?.style.width).toBe('100px');
52+
expect(ref.current?.style.height).toBe('');
53+
});
1854
});
55+
56+
const dispatchMessage = (source: Window | null, data: Record<string, unknown> | null) => {
57+
fireEvent(
58+
window,
59+
new MessageEvent('message', {
60+
data,
61+
source,
62+
}),
63+
);
64+
};

sdks/typescript/server/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ export const InternalMessageType = {
140140
UI_ACTION_RESPONSE: 'ui-action-response',
141141
UI_ACTION_ERROR: 'ui-action-error',
142142

143+
UI_SIZE_CHANGE: 'ui-size-change',
144+
143145
UI_LIFECYCLE_IFRAME_READY: 'ui-lifecycle-iframe-ready',
144146
UI_LIFECYCLE_IFRAME_RENDER_DATA: 'ui-lifecycle-iframe-render-data',
145147
};

0 commit comments

Comments
 (0)