Skip to content

Commit d38cfc7

Browse files
authored
feat: send render data to the iframe (#51)
1 parent dd8b62f commit d38cfc7

5 files changed

Lines changed: 180 additions & 24 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ It accepts the following props:
8888
- **`htmlProps`**: Optional props for the internal `<HTMLResourceRenderer>`
8989
- **`style`**: Optional custom styles for the iframe
9090
- **`iframeProps`**: Optional props passed to the iframe element
91+
- **`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.
9192
- **`remoteDomProps`**: Optional props for the internal `<RemoteDOMResourceRenderer>`
9293
- **`library`**: Optional component library for Remote DOM resources (defaults to `basicComponentLibrary`)
9394
- **`remoteElements`**: remote element definitions for Remote DOM resources.

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ interface UIResourceRendererProps {
5050
- **`proxy`**: Optional. A URL to a static "proxy" script for rendering external URLs. See [Using a Proxy for External URLs](./using-a-proxy.md) for details.
5151
- **`iframeProps`**: Optional props passed to iframe elements (for HTML/URL resources)
5252
- **`ref`**: Optional React ref to access the underlying iframe element
53+
- **`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.
5354
- **`remoteDomProps`**: Optional props for the `<RemoteDOMResourceRenderer>`
5455
- **`library`**: Optional component library for Remote DOM resources (defaults to `basicComponentLibrary`)
5556
- **`remoteElements`**: Optional remote element definitions for Remote DOM resources. REQUIRED for Remote DOM snippets.
@@ -138,6 +139,63 @@ function App({ mcpResource }) {
138139
/>
139140
```
140141

142+
### Passing Render-Time Data to Iframes
143+
144+
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.
145+
146+
When `iframeRenderData` is provided:
147+
1. The iframe's URL will automatically include `?waitForRenderData=true`. The iframe's internal script can use this to know it should wait for data instead of immediately rendering.
148+
2. The data is sent to the iframe via `postMessage` using a dual-mechanism approach to ensure reliable delivery:
149+
- **On Load**: A `ui-lifecycle-iframe-render-data` message is sent as soon as the iframe's `onLoad` event fires.
150+
- **On Ready**: If the iframe sends a `ui-lifecycle-iframe-ready` message, the parent will respond with the same `ui-lifecycle-iframe-render-data` payload.
151+
152+
This ensures the data is delivered whether the iframe is ready immediately or needs to perform setup work first.
153+
154+
```tsx
155+
<UIResourceRenderer
156+
resource={mcpResource.resource}
157+
htmlProps={{
158+
iframeRenderData: {
159+
theme: 'dark',
160+
user: { id: '123', name: 'John Doe' }
161+
}
162+
}}
163+
onUIAction={handleUIAction}
164+
/>
165+
```
166+
167+
Inside the iframe, you can listen for this data:
168+
169+
```javascript
170+
// In the iframe's script
171+
172+
// If the iframe needs to do async work, it can tell the parent when it's ready
173+
const urlParams = new URLSearchParams(window.location.search);
174+
if (urlParams.get('waitForRenderData') === 'true') {
175+
let customRenderData = null;
176+
177+
// The parent will send this message on load or when we notify it we're ready
178+
window.addEventListener('message', (event) => {
179+
// Add origin checks for security
180+
if (event.data.type === 'ui-lifecycle-iframe-render-data') {
181+
// If the iframe has already received data, we don't need to do anything
182+
if(customRenderData) {
183+
return;
184+
} else {
185+
customRenderData = event.data.payload.renderData;
186+
// Now you can render the UI with the received data
187+
renderUI(renderData);
188+
}
189+
}
190+
});
191+
// We can let the parent know we're ready to receive data
192+
window.parent.postMessage({ type: 'ui-lifecycle-iframe-ready' }, '*');
193+
} else {
194+
// If the iframe doesn't need to wait for data, we can render the default UI immediately
195+
renderUI();
196+
}
197+
```
198+
141199
### Accessing the Iframe Element
142200

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

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

Lines changed: 78 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react';
1+
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
22
import type { Resource } from '@modelcontextprotocol/sdk/types.js';
33
import { UIActionResult } from '../types';
44
import { processHTMLResource } from '../utils/processResource';
@@ -8,6 +8,7 @@ export type HTMLResourceRendererProps = {
88
onUIAction?: (result: UIActionResult) => Promise<unknown>;
99
style?: React.CSSProperties;
1010
proxy?: string;
11+
iframeRenderData?: Record<string, unknown>;
1112
iframeProps?: Omit<React.HTMLAttributes<HTMLIFrameElement>, 'src' | 'srcDoc' | 'style'> & {
1213
ref?: React.RefObject<HTMLIFrameElement>;
1314
};
@@ -17,13 +18,21 @@ const InternalMessageType = {
1718
UI_ACTION_RECEIVED: 'ui-action-received',
1819
UI_ACTION_RESPONSE: 'ui-action-response',
1920
UI_ACTION_ERROR: 'ui-action-error',
21+
22+
UI_LIFECYCLE_IFRAME_READY: 'ui-lifecycle-iframe-ready',
23+
UI_LIFECYCLE_IFRAME_RENDER_DATA: 'ui-lifecycle-iframe-render-data',
24+
} as const;
25+
26+
export const ReservedUrlParams = {
27+
WAIT_FOR_RENDER_DATA: 'waitForRenderData',
2028
} as const;
2129

2230
export const HTMLResourceRenderer = ({
2331
resource,
2432
onUIAction,
2533
style,
2634
proxy,
35+
iframeRenderData,
2736
iframeProps,
2837
}: HTMLResourceRendererProps) => {
2938
const iframeRef = useRef<HTMLIFrameElement | null>(null);
@@ -34,27 +43,72 @@ export const HTMLResourceRenderer = ({
3443
[resource, proxy],
3544
);
3645

46+
const iframeSrcToRender = useMemo(() => {
47+
if (iframeSrc && iframeRenderData) {
48+
const iframeUrl = new URL(iframeSrc);
49+
iframeUrl.searchParams.set(ReservedUrlParams.WAIT_FOR_RENDER_DATA, 'true');
50+
return iframeUrl.toString();
51+
}
52+
return iframeSrc;
53+
}, [iframeSrc, iframeRenderData]);
54+
55+
const onIframeLoad = useCallback(
56+
(event: React.SyntheticEvent<HTMLIFrameElement>) => {
57+
if (iframeRenderData) {
58+
const iframeWindow = event.currentTarget.contentWindow;
59+
const iframeOrigin = iframeSrcToRender ? new URL(iframeSrcToRender).origin : '*';
60+
postToFrame(
61+
InternalMessageType.UI_LIFECYCLE_IFRAME_RENDER_DATA,
62+
iframeWindow,
63+
iframeOrigin,
64+
undefined,
65+
{
66+
renderData: iframeRenderData,
67+
},
68+
);
69+
}
70+
iframeProps?.onLoad?.(event);
71+
},
72+
[iframeRenderData, iframeSrcToRender, iframeProps?.onLoad],
73+
);
74+
3775
useEffect(() => {
3876
async function handleMessage(event: MessageEvent) {
77+
const { source, origin, data } = event;
3978
// Only process the message if it came from this specific iframe
40-
if (iframeRef.current && event.source === iframeRef.current.contentWindow) {
41-
const uiActionResult = event.data as UIActionResult;
79+
if (iframeRef.current && source === iframeRef.current.contentWindow) {
80+
// if the iframe is ready, send the render data to the iframe
81+
if (data?.type === InternalMessageType.UI_LIFECYCLE_IFRAME_READY && iframeRenderData) {
82+
postToFrame(
83+
InternalMessageType.UI_LIFECYCLE_IFRAME_RENDER_DATA,
84+
source,
85+
origin,
86+
undefined,
87+
{
88+
renderData: iframeRenderData,
89+
},
90+
);
91+
return;
92+
}
93+
94+
const uiActionResult = data as UIActionResult;
4295
if (!uiActionResult) {
4396
return;
4497
}
4598

4699
// return the "ui-action-received" message only if the onUIAction callback is provided
47100
// otherwise we cannot know that the message was received by the client
48101
if (onUIAction) {
49-
postToFrame(InternalMessageType.UI_ACTION_RECEIVED, event, uiActionResult);
102+
const messageId = uiActionResult.messageId;
103+
postToFrame(InternalMessageType.UI_ACTION_RECEIVED, source, origin, messageId);
50104
try {
51105
const response = await onUIAction(uiActionResult);
52-
postToFrame(InternalMessageType.UI_ACTION_RESPONSE, event, uiActionResult, {
106+
postToFrame(InternalMessageType.UI_ACTION_RESPONSE, source, origin, messageId, {
53107
response,
54108
});
55109
} catch (err) {
56110
console.error('Error handling UI action result in HTMLResourceRenderer:', err);
57-
postToFrame(InternalMessageType.UI_ACTION_ERROR, event, uiActionResult, {
111+
postToFrame(InternalMessageType.UI_ACTION_ERROR, source, origin, messageId, {
58112
error: err,
59113
});
60114
}
@@ -82,23 +136,26 @@ export const HTMLResourceRenderer = ({
82136
title="MCP HTML Resource (Embedded Content)"
83137
{...iframeProps}
84138
ref={iframeRef}
139+
onLoad={onIframeLoad}
85140
/>
86141
);
87142
} else if (iframeRenderMode === 'src') {
88-
if (iframeSrc === null || iframeSrc === undefined) {
143+
if (iframeSrcToRender === null || iframeSrcToRender === undefined) {
89144
if (!error) {
90145
return <p className="text-orange-500">No URL provided for HTML resource.</p>;
91146
}
92147
return null;
93148
}
149+
94150
return (
95151
<iframe
96-
src={iframeSrc}
152+
src={iframeSrcToRender}
97153
sandbox="allow-scripts allow-same-origin"
98154
style={{ width: '100%', minHeight: 200, ...style }}
99155
title="MCP HTML Resource (URL)"
100156
{...iframeProps}
101157
ref={iframeRef}
158+
onLoad={onIframeLoad}
102159
/>
103160
);
104161
}
@@ -110,21 +167,19 @@ HTMLResourceRenderer.displayName = 'HTMLResourceRenderer';
110167

111168
function postToFrame(
112169
type: (typeof InternalMessageType)[keyof typeof InternalMessageType],
113-
event: MessageEvent,
114-
uiActionResult: UIActionResult,
170+
source: Window | null,
171+
origin: string,
172+
originalMessageId?: string,
115173
payload?: unknown,
116174
) {
117-
if (uiActionResult.messageId) {
118-
event.source?.postMessage(
119-
{
120-
type,
121-
messageId: uiActionResult.messageId,
122-
payload,
123-
},
124-
{
125-
// in case the iframe is srcdoc, the origin is null
126-
targetOrigin: event.origin && event.origin !== 'null' ? event.origin : '*',
127-
},
128-
);
129-
}
175+
// in case the iframe is srcdoc, the origin is null
176+
const targetOrigin = origin && origin !== 'null' ? origin : '*';
177+
source?.postMessage(
178+
{
179+
type,
180+
messageId: originalMessageId ?? undefined,
181+
payload,
182+
},
183+
targetOrigin,
184+
);
130185
}

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
22
import '@testing-library/jest-dom';
3-
import { HTMLResourceRenderer, HTMLResourceRendererProps } from '../HTMLResourceRenderer';
3+
import {
4+
HTMLResourceRenderer,
5+
HTMLResourceRendererProps,
6+
ReservedUrlParams,
7+
} from '../HTMLResourceRenderer';
48
import { vi } from 'vitest';
59
import type { Resource } from '@modelcontextprotocol/sdk/types.js';
610
import { UIActionResult } from '../../types.js';
@@ -322,6 +326,37 @@ describe('HTMLResource iframe communication', () => {
322326
expect(localMockOnUIAction).not.toHaveBeenCalled();
323327
expect(mockOnUIAction).not.toHaveBeenCalled(); // also check the describe-scoped one
324328
});
329+
330+
it('should hang the proper query params in the iframe src', async () => {
331+
const resource = {
332+
mimeType: 'text/uri-list',
333+
text: 'https://example.com/app',
334+
};
335+
const iframeRenderData = { foo: 'bar' };
336+
const ref = React.createRef<HTMLIFrameElement>();
337+
render(
338+
<HTMLResourceRenderer
339+
resource={resource}
340+
iframeProps={{ ref }}
341+
iframeRenderData={iframeRenderData}
342+
/>,
343+
);
344+
expect(ref.current).toBeInTheDocument();
345+
expect(ref.current?.src).toContain(`${ReservedUrlParams.WAIT_FOR_RENDER_DATA}=true`);
346+
});
347+
348+
it('shouldnt hang the query param if there is no render data', async () => {
349+
const resource = {
350+
mimeType: 'text/uri-list',
351+
text: 'https://example.com/app',
352+
};
353+
const ref = React.createRef<HTMLIFrameElement>();
354+
render(
355+
<HTMLResourceRenderer resource={resource} iframeProps={{ ref }} iframeRenderData={undefined} />,
356+
);
357+
expect(ref.current).toBeInTheDocument();
358+
expect(ref.current?.src).not.toContain(`${ReservedUrlParams.WAIT_FOR_RENDER_DATA}=true`);
359+
});
325360
});
326361

327362
// Helper to dispatch a message event

sdks/typescript/server/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,15 @@ export const InternalMessageType = {
139139
UI_ACTION_RECEIVED: 'ui-action-received',
140140
UI_ACTION_RESPONSE: 'ui-action-response',
141141
UI_ACTION_ERROR: 'ui-action-error',
142+
143+
UI_LIFECYCLE_IFRAME_READY: 'ui-lifecycle-iframe-ready',
144+
UI_LIFECYCLE_IFRAME_RENDER_DATA: 'ui-lifecycle-iframe-render-data',
142145
};
143146

147+
export const ReservedUrlParams = {
148+
WAIT_FOR_RENDER_DATA: 'waitForRenderData',
149+
} as const;
150+
144151
export function uiActionResultToolCall(
145152
toolName: string,
146153
params: Record<string, unknown>,

0 commit comments

Comments
 (0)