Here's how to use the <UIResourceRenderer /> component from @mcp-ui/client in a React environment.
Make sure you have @mcp-ui/client and its peer dependencies installed in your project:
npm i @mcp-ui/clientThis example shows how to render a remoteDom resource. This requires a remoteElements and componentLibrary (minimal default provided) to be passed to the UIResourceRenderer.
import React, { useState } from 'react';
import {
UIResourceRenderer,
UIActionResult,
basicComponentLibrary,
remoteTextDefinition,
remoteButtonDefinition,
isUIResource
} from '@mcp-ui/client';
const remoteDomScript = `
const button = document.createElement('ui-button');
button.setAttribute('label', 'Click me for a tool call!');
button.addEventListener('press', () => {
window.parent.postMessage({ type: 'tool', payload: { toolName: 'uiInteraction', params: { action: 'button-click', from: 'remote-dom' } } }, '*');
});
root.appendChild(button);
`;
// This mocks the resource as received from the server SDK
const remoteDomResource = {
type: 'resource',
resource: {
uri: 'ui://remote-component/action-button',
mimeType: 'application/vnd.mcp-ui.remote-dom+javascript; framework=react',
text: remoteDomScript,
},
};
const AppWithRemoteDOM: React.FC = () => {
const [lastAction, setLastAction] = useState<any>(null);
const handleGenericMcpAction = async (result: UIActionResult) => {
if (result.type === 'tool') {
setLastAction({ tool: result.payload.toolName, params: result.payload.params });
}
return { status: 'Action handled' };
};
return (
<div>
<UIResourceRenderer
resource={remoteDomResource.resource}
onUIAction={handleGenericMcpAction}
remoteDomProps={{
library: basicComponentLibrary,
remoteElements: [remoteButtonDefinition, remoteTextDefinition],
}}
/>
{lastAction && (
<div style={{ marginTop: 20, border: '1px solid green', padding: 10 }}>
<h3>Last Action Received by Host:</h3>
<pre>{JSON.stringify(lastAction, null, 2)}</pre>
</div>
)}
</div>
);
};import React, { useState } from 'react';
import {
UIResourceRenderer,
UIActionResult,
basicComponentLibrary,
remoteTextDefinition,
remoteButtonDefinition,
isUIResource
} from '@mcp-ui/client';
// Simulate fetching an MCP UI resource
const fetchMcpResource = async (id: string): Promise<any> => {
if (id === 'raw') {
return {
type: 'resource',
resource: {
uri: 'ui://example/raw-html',
mimeType: 'text/html',
text: "<h1>raw HTML via Text</h1><p>Content loaded rawly.</p><button onclick=\"window.parent.postMessage({ type: 'tool', payload: { toolName: 'uiInteraction', params: { action: 'rawClick', value: Date.now() } } }, '*')\">Click Me (raw)</button>",
},
};
} else if (id === 'blob') {
const html =
"<h1>HTML from Blob</h1><p>Content was Base64 encoded.</p><button onclick=\"window.parent.postMessage({ type: 'tool', payload: { toolName: 'uiInteraction', params: { action: 'blobClick', value: 'test' } } }, '*')\">Click Me (Blob)</button>";
return {
type: 'resource',
resource: {
uri: 'ui://example/blob-html',
mimeType: 'text/html',
blob: btoa(html),
},
};
} else if (id === 'external') {
return {
type: 'resource',
resource: {
uri: 'ui://example/external-site',
mimeType: 'text/uri-list',
text: 'https://vitepress.dev',
},
};
}
if (id === 'remote') {
const remoteDomScript = `
const button = document.createElement('ui-button');
button.setAttribute('label', 'Click me for a tool call!');
button.addEventListener('press', () => {
window.parent.postMessage({ type: 'tool', payload: { toolName: 'uiInteraction', params: { action: 'button-click', from: 'remote-dom' } } }, '*');
});
root.appendChild(button);
`;
return {
type: 'resource',
resource: {
uri: 'ui://remote-component/action-button',
mimeType: 'application/vnd.mcp-ui.remote-dom+javascript; framework=react',
text: remoteDomScript,
},
};
}
throw new Error('Unknown resource ID');
};
const App: React.FC = () => {
const [uiResource, setUIResource] = useState<UIResource | null>(
null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastAction, setLastAction] = useState<any>(null);
const loadResource = async (id: string) => {
setLoading(true);
setError(null);
setUIResource(null);
try {
const block = await fetchMcpResource(id);
setUIResource(block);
} catch (e: any) {
setError(e.message);
}
setLoading(false);
};
const handleGenericMcpAction = async (result: UIActionResult) => {
if (result.type === 'tool') {
console.log(`Action received in host app - Tool: ${result.payload.toolName}, Params:`, result.payload.params);
setLastAction({ tool: result.payload.toolName, params: result.payload.params });
} else if (result.type === 'prompt') {
console.log(`Prompt received in host app:`, result.payload.prompt);
setLastAction({ prompt: result.payload.prompt });
} else if (result.type === 'link') {
console.log(`Link received in host app:`, result.payload.url);
setLastAction({ url: result.payload.url });
} else if (result.type === 'intent') {
console.log(`Intent received in host app:`, result.payload.intent);
setLastAction({ intent: result.payload.intent });
} else if (result.type === 'notify') {
console.log(`Notification received in host app:`, result.payload.message);
setLastAction({ message: result.payload.message });
}
return {
status: 'Action handled by host application',
};
};
return (
<div>
<h1>MCP-UI Client Demo</h1>
<button onClick={() => loadResource('raw')}>
Load raw HTML (Text)
</button>
<button onClick={() => loadResource('blob')}>
Load raw HTML (Blob)
</button>
<button onClick={() => loadResource('external')}>
Load External App (URL)
</button>
<button onClick={() => loadResource('remote')}>
Load Remote DOM
</button>
{loading && <p>Loading resource...</p>}
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
{uiResource && uiResource.resource && (
<div style={{ marginTop: 20, border: '2px solid blue', padding: 10 }}>
<h2>Rendering Resource: {uiResource.resource.uri}</h2>
<UIResourceRenderer
resource={uiResource.resource}
onUIAction={handleGenericMcpAction}
remoteDomProps={{
library: basicComponentLibrary,
remoteElements: [remoteButtonDefinition, remoteTextDefinition],
}}
/>
</div>
)}
{lastAction && (
<div style={{ marginTop: 20, border: '1px solid green', padding: 10 }}>
<h3>Last Action Received by Host:</h3>
<pre>{JSON.stringify(lastAction, null, 2)}</pre>
</div>
)}
</div>
);
};
export default App;Instead of manually checking if content is a UI resource, you can use the isUIResource() utility function:
import React from 'react';
import { UIResourceRenderer, isUIResource } from '@mcp-ui/client';
function ResourceList({ mcpResponses }) {
return (
<div>
{mcpResponses.map((response, index) => {
// Use isUIResource instead of manual checking
if (isUIResource(response)) {
return (
<div key={index}>
<h3>UI Resource: {response.resource.uri}</h3>
<UIResourceRenderer
resource={response.resource}
onUIAction={handleAction}
/>
</div>
);
}
// Handle other response types
return (
<div key={index}>
<p>Non-UI content: {response.type}</p>
</div>
);
})}
</div>
);
}This is equivalent to the manual check:
response.type === 'resource' && response.resource.uri?.startsWith('ui://')But provides better type safety and is more concise.
When an action from the iframe requires asynchronous processing on the host, the messageId property can be used to track the action's lifecycle and result. This allows the iframe to fetch data from the host, present feedback to the user (e.g., loading indicators), success messages, etc.
- Iframe to Host: The iframe sends a message with a unique
messageId. - Host Acknowledgment: The
UIResourceRendererautomatically sends aui-message-receivedmessage back to the iframe to acknowledge receipt. - Host Processing: The
onUIActionfunction provided to the renderer is executed. This function can beasyncand performs the long-running task. - Host Response:
- If the
onUIActionpromise resolves,UIResourceRenderersends aui-message-responsewith the resolved value as theresponse. - If the
onUIActionpromise rejects (throws an error), it sends aui-message-responsewith the error details as theerror.
- If the
- Iframe Updates: The iframe listens for these messages and updates its UI accordingly.
First, the iframe needs to generate a unique ID for the request and send it to the host. It should also keep track of pending requests to handle responses when they arrive.
// A Map to store callbacks for pending requests, keyed by messageId.
const requests = new Map<string, (response: any, error?: any) => void>();
function makeRequest() {
const messageId = crypto.randomUUID();
// Store a callback to handle the response for this specific request.
requests.set(messageId, (response, error) => {
if (error) {
console.error('Request failed:', error);
// Update UI to show error
} else {
console.log('Received response:', response);
// Update UI with the response data
}
});
// Send the request to the host.
window.parent.postMessage(
{
type: "ui-request-data",
messageId,
payload: {
requestType: "get-payment-methods",
params: {
// any params needed for the request
},
},
},
"*"
);
// You can update the UI to a loading state here.
}
// Example: trigger the request on a button click.
button.addEventListener("click", makeRequest);The host listens for messages from the iframe. When it receives a request with a messageId, it can optionally send an acknowledgment and then starts the asynchronous work.
window.addEventListener("message", async (event) => {
const { type, messageId, payload } = event.data;
// Acknowledge receipt of the message
if (messageId && event.source) {
(event.source as Window).postMessage(
{
type: "ui-message-received",
messageId: messageId,
},
{ targetOrigin: "*" }
);
}
if (type === "ui-request-data") {
const { requestType, params } = payload;
if (requestType === "get-payment-methods") {
try {
// 3. Perform the async operation
const paymentMethods = await fetchPaymentMethods(params);
// 4. Send a success response
if (event.source) {
(event.source as Window).postMessage(
{
type: "ui-message-response",
messageId: messageId,
payload: { response: { paymentMethods } },
},
{ targetOrigin: "*" }
);
}
} catch (error) {
// 4. Send an error response
if (event.source) {
(event.source as Window).postMessage(
{
type: "ui-message-response",
messageId: messageId,
payload: { error },
},
{ targetOrigin: "*" }
);
}
}
}
}
});The iframe needs a listener to handle messages from the host. It can use the messageId to match responses to the original requests.
window.addEventListener("message", (event) => {
const { type, messageId, payload } = event.data;
// Check if it's a response to a request we're waiting for.
if (!messageId || !requests.has(messageId)) {
return;
}
if (type === "ui-message-received") {
// The host has acknowledged the request. You can update the UI.
console.log(`Request ${messageId} is being processed...`);
// e.g., show a more specific loading indicator.
}
if (type === "ui-message-response") {
const { response, error } = payload;
// Retrieve the original callback.
const callback = requests.get(messageId);
if (callback) {
callback(response, error);
}
// Clean up the request from the map.
requests.delete(messageId);
}
});This pattern is crucial for building responsive and user-friendly UIs, especially when interacting with potentially slow backend operations.
The UIResourceRenderer supports passing custom styling data to HTML iframes through the htmlProps.iframeRenderData mechanism. This allows you to theme iframe content dynamically based on your host application's theme, user preferences, or other runtime conditions.
The theming flow involves several coordinated steps:
- Host Configuration: The host renders
<UIResourceRenderer>withhtmlProps.iframeRenderDatacontaining your custom CSS or theme data - Parameter Addition: The SDK automatically adds
?waitForRenderData=trueto the iframe URL - Iframe Waiting: The iframe recognizes this parameter and waits before rendering
- Data Transfer: The SDK sends a
ui-lifecycle-iframe-render-datamessage with the render data when the iframe is ready - Theme Application: The iframe receives the data and applies the custom styling
Here's a complete example showing how to implement dynamic theming:
import React, { useState } from 'react';
import { UIResourceRenderer } from '@mcp-ui/client';
const ThemeableApp: React.FC = () => {
const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>('light');
// Define your theme styles
const themeStyles = {
light: `
:root {
--bg-color: #ffffff;
--text-color: #333333;
--primary-color: #007bff;
--border-color: #e0e0e0;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
`,
dark: `
:root {
--bg-color: #1a1a1a;
--text-color: #ffffff;
--primary-color: #4a9eff;
--border-color: #404040;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
`
};
return (
<div style={{ padding: '20px' }}>
<h1>Themed MCP-UI Example</h1>
{/* Theme switcher */}
<div style={{ marginBottom: '20px' }}>
<button
onClick={() => setCurrentTheme(currentTheme === 'light' ? 'dark' : 'light')}
>
Switch to {currentTheme === 'light' ? 'Dark' : 'Light'} Theme
</button>
</div>
{/* Render the themed iframe */}
<div>
<UIResourceRenderer
resource={themedHtmlResource}
onUIAction={handleUIAction}
htmlProps={{
iframeRenderData: {
customCss: themeStyles[currentTheme],
theme: currentTheme,
// You can pass any additional data your iframe needs
additionalConfig: {
animations: true,
fontSize: '16px'
}
}
}}
/>
</div>
</div>
);
};
export default ThemeableApp;In your iframe's HTML content, implement the render data lifecycle:
<script>
// In the iframe's script
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("waitForRenderData") === "true") {
let customRenderData = null;
// The parent will send this message on load or when we notify it we're ready
window.addEventListener("message", (event) => {
// Add origin checks for security in production
if (event.data.type === "ui-lifecycle-iframe-render-data") {
// If the iframe has already received data, we don't need to do anything
if (customRenderData) {
return;
} else {
customRenderData = event.data.payload.renderData;
// Now you can render the UI with the received data
renderUI(customRenderData);
}
}
});
// We can let the parent know we're ready to receive data
window.parent.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
} else {
// If the iframe doesn't need to wait for data, we can render the default UI immediately
renderUI();
}
function renderUI(renderData = null) {
const statusEl = document.getElementById('status');
if (renderData) {
// Apply custom CSS
if (renderData.customCss) {
const styleElement = document.createElement('style');
styleElement.textContent = renderData.customCss;
document.head.appendChild(styleElement);
}
// Use other render data
if (statusEl) {
statusEl.innerHTML = \\`
<strong>✅ Theme Applied!</strong><br>
Theme: \${renderData.theme || 'unknown'}<br>
Additional config: \${JSON.stringify(renderData.additionalConfig || {}, null, 2)}
\\`;
}
// You can access any custom data passed in iframeRenderData
console.log('Render data received:', renderData);
} else {
// Default rendering without theme data
if (statusEl) {
statusEl.innerHTML = '<em>No theme data received - using defaults</em>';
}
}
}
</script>// In your host component
const advancedThemeData = {
colors: {
primary: '#007bff',
secondary: '#6c757d',
success: '#28a745',
warning: '#ffc107',
danger: '#dc3545'
},
fonts: {
heading: 'Georgia, serif',
body: 'Arial, sans-serif',
mono: 'Monaco, monospace'
},
spacing: {
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px'
}
};
// Pass to iframe
<UIResourceRenderer
resource={resource}
onUIAction={handleUIAction}
htmlProps={{
iframeRenderData: { themeConfig: advancedThemeData }
}}
/>- Dynamic Theming: Themes can change at runtime without reloading the iframe
- Consistent Styling: Ensures iframe content matches your host application's design
- Performance: CSS is injected once rather than being included in every iframe
- Flexibility: You can pass any styling or configuration data, not just CSS
- Security: The iframe explicitly opts into receiving render data
This theming system provides a powerful way to create cohesive user experiences across your host application and embedded MCP-UI content.
That's it! Just use <UIResourceRenderer /> with the right props and you're ready to render interactive HTML from MCP resources in your React app. The UIResourceRenderer automatically detects the resource type and renders the appropriate component internally. If you need more details, check out the UIResourceRenderer Component page.