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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ It accepts the following props:
- **`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.
- **`mcpContextProps`**: Optional MCP invocation context forwarded to HTML resources (e.g., `toolInput`, `toolOutput`, `toolName`, `toolResponseMetadata`). When `toolOutput` is provided, it is merged with `iframeRenderData` for the sandbox payload (with `toolOutput` fields taking precedence).
- **`clientContextProps`**: Optional host context (e.g., `theme`, `userAgent`, `model`) exposed to sandboxed HTML content. Both context props can also be supplied via `htmlProps` when you need HTML-specific overrides.
- **`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
4 changes: 4 additions & 0 deletions docs/src/guide/client/html-resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ The `<HTMLResourceRenderer />` component is an internal component used by `<UIRe

```typescript
import type { Resource } from '@modelcontextprotocol/sdk/types';
import type { MCPContextProps, ClientContextProps } from '@mcp-ui/client';

export interface HTMLResourceRendererProps {
resource: Partial<Resource>;
onUIAction?: (result: UIActionResult) => Promise<any>;
style?: React.CSSProperties;
proxy?: string;
iframeRenderData?: Record<string, unknown>;
mcpContextProps?: MCPContextProps;
clientContextProps?: ClientContextProps;
autoResizeIframe?: boolean | { width?: boolean; height?: boolean };
sandboxPermissions?: string;
iframeProps?: Omit<React.HTMLAttributes<HTMLIFrameElement>, 'src' | 'srcDoc' | 'ref' | 'style'>;
Expand Down Expand Up @@ -40,6 +43,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.
- **`iframeRenderData`**: (Optional) Additional data merged into the render payload forwarded to the iframe. When `mcpContextProps.toolOutput` is provided, it merges with this data for the sandbox payload (with `toolOutput` fields taking precedence).
- **`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.
- **`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'`
Expand Down
5 changes: 5 additions & 0 deletions docs/src/guide/client/resource-renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,16 @@ The `UIResourceRenderer` automatically detects and uses metadata from resources

```typescript
import type { Resource } from '@modelcontextprotocol/sdk/types';
import type { MCPContextProps, ClientContextProps } from '@mcp-ui/client';

interface UIResourceRendererProps {
resource: Partial<Resource>;
onUIAction?: (result: UIActionResult) => Promise<unknown>;
supportedContentTypes?: ResourceContentType[];
htmlProps?: Omit<HTMLResourceRendererProps, 'resource' | 'onUIAction'>;
remoteDomProps?: Omit<RemoteDOMResourceProps, 'resource' | 'onUIAction'>;
mcpContextProps?: MCPContextProps;
clientContextProps?: ClientContextProps;
}
```

Expand Down Expand Up @@ -76,6 +79,8 @@ 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.
- **`mcpContextProps`**: Optional MCP invocation context forwarded to HTML resources (e.g., `toolInput`, `toolOutput`, `toolName`, `toolResponseMetadata`). When `toolOutput` is provided, it merges with `iframeRenderData` for the sandbox payload (with `toolOutput` fields taking precedence). These can also be provided via `htmlProps` for HTML-only overrides.
- **`clientContextProps`**: Optional host context for HTML resources (e.g., `theme`, `userAgent`, `model`). When unspecified, defaults are used. Like `mcpContextProps`, these can be supplied in `htmlProps` to scope them to HTML resources.
- **`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
2 changes: 2 additions & 0 deletions sdks/typescript/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ It accepts the following props:
- **`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.
- **`mcpContextProps`**: Optional MCP invocation context forwarded to HTML resources (e.g., `toolInput`, `toolOutput`, `toolName`, `toolResponseMetadata`). When `toolOutput` is provided, it merges with `iframeRenderData` for the sandbox payload (with `toolOutput` fields taking precedence).
- **`clientContextProps`**: Optional host context (e.g., `theme`, `userAgent`, `model`) exposed to sandboxed HTML content. Both context props can also be supplied via `htmlProps` when you need HTML-specific overrides.
- **`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
36 changes: 21 additions & 15 deletions sdks/typescript/client/src/components/HTMLResourceRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import type { Resource } from '@modelcontextprotocol/sdk/types.js';
import { UIActionResult, UIMetadataKey } from '../types';
import { UIActionResult, UIMetadataKey, MCPContextProps, ClientContextProps } from '../types';
import { processHTMLResource } from '../utils/processResource';
import { getUIResourceMetadata } from '../utils/metadataUtils';

Expand All @@ -10,9 +10,8 @@ export type HTMLResourceRendererProps = {
style?: React.CSSProperties;
proxy?: string;
iframeRenderData?: Record<string, unknown>;
toolInput?: Record<string, unknown>;
toolName?: string;
toolResponseMetadata?: Record<string, unknown>;
mcpContextProps?: MCPContextProps;
clientContextProps?: ClientContextProps;
autoResizeIframe?: boolean | { width?: boolean; height?: boolean };
sandboxPermissions?: string;
iframeProps?: Omit<React.HTMLAttributes<HTMLIFrameElement>, 'src' | 'srcDoc' | 'style'> & {
Expand Down Expand Up @@ -46,9 +45,8 @@ export const HTMLResourceRenderer = ({
style,
proxy,
iframeRenderData,
toolInput,
toolName,
toolResponseMetadata,
mcpContextProps,
clientContextProps,
autoResizeIframe,
sandboxPermissions,
iframeProps,
Expand All @@ -60,7 +58,7 @@ export const HTMLResourceRenderer = ({
const preferredFrameSize = uiMetadata[UIMetadataKey.PREFERRED_FRAME_SIZE] ?? ['100%', '100%'];
const metadataInitialRenderData = uiMetadata[UIMetadataKey.INITIAL_RENDER_DATA] ?? undefined;

const initialRenderData = useMemo(() => {
const combinedRenderData = useMemo(() => {
if (!iframeRenderData && !metadataInitialRenderData) {
return undefined;
}
Expand All @@ -70,17 +68,25 @@ export const HTMLResourceRenderer = ({
};
}, [iframeRenderData, metadataInitialRenderData]);

const initialRenderData = useMemo(() => {
if (!combinedRenderData && !mcpContextProps?.toolOutput) {
return undefined;
}
return {
...combinedRenderData,
...(mcpContextProps?.toolOutput ?? {}),
};
}, [combinedRenderData, mcpContextProps?.toolOutput]);

const { error, iframeSrc, iframeRenderMode, htmlString } = useMemo(
() =>
processHTMLResource(
resource,
processHTMLResource(resource, {
proxy,
initialRenderData,
toolInput,
toolName,
toolResponseMetadata
),
[resource, proxy]
mcpContextProps,
clientContextProps,
}),
[resource, proxy, initialRenderData, mcpContextProps, clientContextProps]
);


Expand Down
20 changes: 15 additions & 5 deletions sdks/typescript/client/src/components/UIResourceRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { EmbeddedResource } from '@modelcontextprotocol/sdk/types.js';
import { ResourceContentType, UIActionResult } from '../types';
import { ResourceContentType, UIActionResult, ClientContextProps, MCPContextProps } from '../types';
import { HTMLResourceRenderer, HTMLResourceRendererProps } from './HTMLResourceRenderer';
import { RemoteDOMResourceProps, RemoteDOMResourceRenderer } from './RemoteDOMResourceRenderer';
import { basicComponentLibrary } from '../remote-dom/component-libraries/basic';
Expand All @@ -8,8 +8,10 @@ export type UIResourceRendererProps = {
resource: Partial<EmbeddedResource>;
onUIAction?: (result: UIActionResult) => Promise<unknown>;
supportedContentTypes?: ResourceContentType[];
htmlProps?: Omit<HTMLResourceRendererProps, 'resource' | 'onUIAction'>;
remoteDomProps?: Omit<RemoteDOMResourceProps, 'resource' | 'onUIAction'>;
htmlProps?: Omit<HTMLResourceRendererProps, 'resource' | 'onUIAction' | 'mcpContextProps' | 'clientContextProps'>;
remoteDomProps?: RemoteDOMResourceProps;
mcpContextProps?: MCPContextProps;
clientContextProps?: ClientContextProps;
};

function getContentType(
Expand All @@ -34,7 +36,7 @@ function getContentType(
}

export const UIResourceRenderer = (props: UIResourceRendererProps) => {
const { resource, onUIAction, supportedContentTypes, htmlProps, remoteDomProps } = props;
const { resource, onUIAction, supportedContentTypes, htmlProps, remoteDomProps, mcpContextProps, clientContextProps } = props;
const contentType = getContentType(resource);

if (supportedContentTypes && contentType && !supportedContentTypes.includes(contentType)) {
Expand All @@ -45,7 +47,15 @@ export const UIResourceRenderer = (props: UIResourceRendererProps) => {
case 'rawHtml':
case 'skybridge':
case 'externalUrl': {
return <HTMLResourceRenderer resource={resource} onUIAction={onUIAction} {...htmlProps} />;
return (
<HTMLResourceRenderer
resource={resource}
onUIAction={onUIAction}
mcpContextProps={mcpContextProps}
clientContextProps={clientContextProps}
{...htmlProps}
/>
);
}
case 'remoteDom':
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ describe('HTMLResource component', () => {
render(<HTMLResourceRenderer {...props} />);
expect(
screen.getByText(
'Resource must be of type text/html (for HTML content) or text/uri-list (for URL content).',
'Resource must be of type text/html (for HTML content), text/html+skybridge, or text/uri-list (for URL content).',
),
).toBeInTheDocument();
});
Expand Down Expand Up @@ -359,7 +359,7 @@ describe('HTMLResource iframe communication', () => {
// Error message should be displayed
expect(
await screen.findByText(
'Resource must be of type text/html (for HTML content) or text/uri-list (for URL content).',
'Resource must be of type text/html (for HTML content), text/html+skybridge, or text/uri-list (for URL content).',
),
).toBeInTheDocument();

Expand Down Expand Up @@ -534,6 +534,47 @@ describe('HTMLResource metadata', () => {
);
});

it('should merge toolOutput from mcpContextProps with other render data', () => {
const iframeRenderData = { priority: 'iframe', foo: 'bar' };
const metadataInitialRenderData = { priority: 'metadata', baz: 'qux' };
const toolOutput = { priority: 'context', extra: 'value' };
const resource = {
mimeType: 'text/uri-list',
text: 'https://example.com/app',
_meta: { [`${UI_METADATA_PREFIX}initial-render-data`]: metadataInitialRenderData },
};
const ref = React.createRef<HTMLIFrameElement>();
render(
<HTMLResourceRenderer
resource={resource}
iframeProps={{ ref }}
iframeRenderData={iframeRenderData}
mcpContextProps={{ toolOutput }}
/>,
);
expect(ref.current).toBeInTheDocument();
const iframeWindow = ref.current?.contentWindow as Window;
const spy = vi.spyOn(iframeWindow, 'postMessage');
dispatchMessage(iframeWindow, {
type: InternalMessageType.UI_LIFECYCLE_IFRAME_READY,
});
expect(spy).toHaveBeenCalledWith(
{
type: InternalMessageType.UI_LIFECYCLE_IFRAME_RENDER_DATA,
payload: {
renderData: {
priority: 'context',
foo: 'bar',
baz: 'qux',
extra: 'value',
},
},
messageId: undefined,
},
'*',
);
});

it('should respond to ui-request-render-data with render data', () => {
const iframeRenderData = { theme: 'dark', user: { id: '123' } };
const resource = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@ describe('<UIResourceRenderer />', () => {
render(<UIResourceRenderer resource={resource} />);
expect(screen.getByTestId('html-resource')).toBeInTheDocument();
expect(RemoteDOMResourceRenderer).not.toHaveBeenCalled();
expect(HTMLResourceRenderer).toHaveBeenCalledWith({ resource }, {});
expect(HTMLResourceRenderer).toHaveBeenCalledWith(expect.objectContaining({ resource }), {});
});

it('should render HTMLResourceRenderer for "text/uri-list" mimeType', () => {
const resource = { ...baseResource, mimeType: 'text/uri-list' };
render(<UIResourceRenderer resource={resource} />);
expect(screen.getByTestId('html-resource')).toBeInTheDocument();
expect(RemoteDOMResourceRenderer).not.toHaveBeenCalled();
expect(HTMLResourceRenderer).toHaveBeenCalledWith({ resource }, {});
expect(HTMLResourceRenderer).toHaveBeenCalledWith(expect.objectContaining({ resource }), {});
});

it('should render RemoteDOMResourceRenderer for "remote-dom" mimeType', () => {
Expand Down Expand Up @@ -75,7 +75,7 @@ describe('<UIResourceRenderer />', () => {
render(<UIResourceRenderer resource={resource} supportedContentTypes={['rawHtml']} />);
expect(screen.getByTestId('html-resource')).toBeInTheDocument();
expect(RemoteDOMResourceRenderer).not.toHaveBeenCalled();
expect(HTMLResourceRenderer).toHaveBeenCalledWith({ resource }, {});
expect(HTMLResourceRenderer).toHaveBeenCalledWith(expect.objectContaining({ resource }), {});
});

it('should pass proxy prop to HTMLResourceRenderer for external URLs', () => {
Expand All @@ -85,7 +85,7 @@ describe('<UIResourceRenderer />', () => {
);
expect(screen.getByTestId('html-resource')).toBeInTheDocument();
expect(HTMLResourceRenderer).toHaveBeenCalledWith(
{ resource, proxy: 'https://proxy.mcpui.dev/' },
expect.objectContaining({ resource, proxy: 'https://proxy.mcpui.dev/' }),
{},
);
});
Expand All @@ -97,7 +97,41 @@ describe('<UIResourceRenderer />', () => {
);
expect(screen.getByTestId('html-resource')).toBeInTheDocument();
expect(HTMLResourceRenderer).toHaveBeenCalledWith(
{ resource, proxy: 'https://proxy.mcpui.dev/' },
expect.objectContaining({ resource, proxy: 'https://proxy.mcpui.dev/' }),
{},
);
});

it('should forward context props to HTMLResourceRenderer', () => {
const resource = { ...baseResource, mimeType: 'text/html' };
const mcpContextProps = { toolName: 'demo-tool', toolInput: { foo: 'bar' } };
const clientContextProps = { theme: 'light', model: 'gpt-5' };
render(
<UIResourceRenderer
resource={resource}
mcpContextProps={mcpContextProps}
clientContextProps={clientContextProps}
/>,
);
expect(HTMLResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({ resource, mcpContextProps, clientContextProps }),
{},
);
});

it('should use htmlProps context when top-level context is undefined', () => {
const resource = { ...baseResource, mimeType: 'text/html' };
const htmlProps = {
mcpContextProps: { toolName: 'html-only', toolInput: { bar: 'baz' } },
clientContextProps: { theme: 'dark', userAgent: 'jest' },
};
render(<UIResourceRenderer resource={resource} htmlProps={htmlProps} />);
expect(HTMLResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource,
mcpContextProps: htmlProps.mcpContextProps,
clientContextProps: htmlProps.clientContextProps,
}),
{},
);
});
Expand Down
13 changes: 13 additions & 0 deletions sdks/typescript/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,16 @@ export type UIResourceMetadata = {
[UIMetadataKey.PREFERRED_FRAME_SIZE]?: [string, string];
[UIMetadataKey.INITIAL_RENDER_DATA]?: Record<string, unknown>;
};

export type MCPContextProps = {
toolInput?: Record<string, unknown>;
toolOutput?: Record<string, unknown>;
toolName?: string;
toolResponseMetadata?: Record<string, unknown>;
};

export type ClientContextProps = {
theme?: string;
userAgent?: string;
model?: string;
};
Loading