Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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`). Providing `toolOutput` here overrides `iframeRenderData` for the sandbox payload.
- **`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 takes precedence over this merged object.
- **`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`). 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`). Providing `toolOutput` here overrides `iframeRenderData` for the sandbox payload.
- **`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
31 changes: 16 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,20 @@ export const HTMLResourceRenderer = ({
};
}, [iframeRenderData, metadataInitialRenderData]);

const initialRenderData = useMemo(
() => mcpContextProps?.toolOutput ?? combinedRenderData,
[mcpContextProps?.toolOutput, combinedRenderData],
);

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}
{...htmlProps}
{...mcpContextProps}
{...clientContextProps}
/>
);
}
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,40 @@ describe('HTMLResource metadata', () => {
);
});

it('should prioritize toolOutput from mcpContextProps over 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: toolOutput },
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