Skip to content

Latest commit

 

History

History
643 lines (548 loc) · 19.8 KB

File metadata and controls

643 lines (548 loc) · 19.8 KB

React Usage & Examples

Here's how to use the <UIResourceRenderer /> component from @mcp-ui/client in a React environment.

Installation

Make sure you have @mcp-ui/client and its peer dependencies installed in your project:

npm i @mcp-ui/client

Rendering Remote DOM Resources

This 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>
  );
};

Rendering HTML Resources

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;

Using the isUIResource Utility

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.


Handling Asynchronous Actions with Message IDs

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.

Communication Flow

  1. Iframe to Host: The iframe sends a message with a unique messageId.
  2. Host Acknowledgment: The UIResourceRenderer automatically sends a ui-message-received message back to the iframe to acknowledge receipt.
  3. Host Processing: The onUIAction function provided to the renderer is executed. This function can be async and performs the long-running task.
  4. Host Response:
    • If the onUIAction promise resolves, UIResourceRenderer sends a ui-message-response with the resolved value as the response.
    • If the onUIAction promise rejects (throws an error), it sends a ui-message-response with the error details as the error.
  5. Iframe Updates: The iframe listens for these messages and updates its UI accordingly.

Example

1. In the iframe: Initiating the request

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);

2. In the host: Receiving and processing the request

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: "*" }
          );
        }
      }
    }
  }
});

3. In the iframe: Handling the response

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.


Custom Theming with Render Data

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.

How It Works

The theming flow involves several coordinated steps:

  1. Host Configuration: The host renders <UIResourceRenderer> with htmlProps.iframeRenderData containing your custom CSS or theme data
  2. Parameter Addition: The SDK automatically adds ?waitForRenderData=true to the iframe URL
  3. Iframe Waiting: The iframe recognizes this parameter and waits before rendering
  4. Data Transfer: The SDK sends a ui-lifecycle-iframe-render-data message with the render data when the iframe is ready
  5. Theme Application: The iframe receives the data and applies the custom styling

Example: Dynamic Theme Support

Here's a complete example showing how to implement dynamic theming:

Host Application Setup

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;

Iframe Script Implementation

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>

Passing Multiple Theme Variables

// 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 }
  }}
/>

Benefits of This Approach

  • 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.