diff --git a/README.md b/README.md index 27d795ae..92043235 100644 --- a/README.md +++ b/README.md @@ -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` 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 `` - **`library`**: Optional component library for Remote DOM resources (defaults to `basicComponentLibrary`) - **`remoteElements`**: remote element definitions for Remote DOM resources. diff --git a/docs/src/guide/client/html-resource.md b/docs/src/guide/client/html-resource.md index 01fc7a16..17a6c202 100644 --- a/docs/src/guide/client/html-resource.md +++ b/docs/src/guide/client/html-resource.md @@ -12,6 +12,8 @@ export interface HTMLResourceRendererProps { onUIAction?: (result: UIActionResult) => Promise; style?: React.CSSProperties; proxy?: string; + iframeRenderData?: Record; + autoResizeIframe?: boolean | { width?: boolean; height?: boolean }; iframeProps?: Omit, 'src' | 'srcDoc' | 'ref' | 'style'>; } ``` @@ -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=`. 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 @@ -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 `` 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 ``). +2. The content inside the iframe must send a `ui-size-change` message to the parent window when its size changes. + +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", + payload: { + height: entry.contentRect.height, + }, + }, + "*", + ); + }); +}); + +resizeObserver.observe(document.documentElement) +``` + +This will observe the root `` element and send a message whenever its height changes. The `` 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.) diff --git a/docs/src/guide/client/resource-renderer.md b/docs/src/guide/client/resource-renderer.md index 33e7cdfd..ad8714f8 100644 --- a/docs/src/guide/client/resource-renderer.md +++ b/docs/src/guide/client/resource-renderer.md @@ -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` 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 `` - **`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. @@ -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 + +``` + +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: diff --git a/sdks/typescript/client/src/components/HTMLResourceRenderer.tsx b/sdks/typescript/client/src/components/HTMLResourceRenderer.tsx index 4151ec76..06cc2171 100644 --- a/sdks/typescript/client/src/components/HTMLResourceRenderer.tsx +++ b/sdks/typescript/client/src/components/HTMLResourceRenderer.tsx @@ -9,6 +9,7 @@ export type HTMLResourceRendererProps = { style?: React.CSSProperties; proxy?: string; iframeRenderData?: Record; + autoResizeIframe?: boolean | { width?: boolean; height?: boolean }; iframeProps?: Omit, 'src' | 'srcDoc' | 'style'> & { ref?: React.RefObject; }; @@ -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; @@ -33,6 +36,7 @@ export const HTMLResourceRenderer = ({ style, proxy, iframeRenderData, + autoResizeIframe, iframeProps, }: HTMLResourceRendererProps) => { const iframeRef = useRef(null); @@ -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; diff --git a/sdks/typescript/client/src/components/__tests__/UIResourceRenderer.unmocked.test.tsx b/sdks/typescript/client/src/components/__tests__/UIResourceRenderer.unmocked.test.tsx index 759a745f..33699ab8 100644 --- a/sdks/typescript/client/src/components/__tests__/UIResourceRenderer.unmocked.test.tsx +++ b/sdks/typescript/client/src/components/__tests__/UIResourceRenderer.unmocked.test.tsx @@ -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'; @@ -7,7 +7,9 @@ import { UIResourceRenderer } from '../UIResourceRenderer'; describe('UIResourceRenderer', () => { const testResource: Partial = { mimeType: 'text/html', - text: '

Test Content

', + text: `

Test Content

`, uri: 'ui://test-resource', }; it('should pass ref to HTMLResourceRenderer', () => { @@ -15,4 +17,48 @@ describe('UIResourceRenderer', () => { render(); expect(ref.current).toBeInTheDocument(); }); + + it('should respect a ui-size-change message', () => { + const ref = React.createRef(); + render( + , + ); + 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(); + render( + , + ); + 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 | null) => { + fireEvent( + window, + new MessageEvent('message', { + data, + source, + }), + ); +}; diff --git a/sdks/typescript/server/src/index.ts b/sdks/typescript/server/src/index.ts index 8d3ac5b6..43b55625 100644 --- a/sdks/typescript/server/src/index.ts +++ b/sdks/typescript/server/src/index.ts @@ -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', + UI_LIFECYCLE_IFRAME_READY: 'ui-lifecycle-iframe-ready', UI_LIFECYCLE_IFRAME_RENDER_DATA: 'ui-lifecycle-iframe-render-data', };