diff --git a/README.md b/README.md index 405b3227..8ad0da8f 100644 --- a/README.md +++ b/README.md @@ -74,12 +74,13 @@ It accepts the following props: - **`resource`**: The resource object from an MCP Tool response. It must include `uri`, `mimeType`, and content (`text`, `blob`) - **`onUIAction`**: Optional callback for handling UI actions from the resource: ```typescript - { type: 'tool', payload: { toolName: string, params: Record } } | - { type: 'intent', payload: { intent: string, params: Record } } | - { type: 'prompt', payload: { prompt: string } } | - { type: 'notify', payload: { message: string } } | - { type: 'link', payload: { url: string } } + { type: 'tool', payload: { toolName: string, params: Record }, messageId?: string } | + { type: 'intent', payload: { intent: string, params: Record }, messageId?: string } | + { type: 'prompt', payload: { prompt: string }, messageId?: string } | + { type: 'notify', payload: { message: string }, messageId?: string } | + { type: 'link', payload: { url: string }, messageId?: string } ``` + When actions include a `messageId`, the iframe automatically receives response messages for asynchronous handling. - **`supportedContentTypes`**: Optional array to restrict which content types are allowed (`['rawHtml', 'externalUrl', 'remoteDom']`) - **`htmlProps`**: Optional props for the internal `` - **`style`**: Optional custom styles for the iframe diff --git a/docs/src/guide/client/html-resource.md b/docs/src/guide/client/html-resource.md index 10090977..01fc7a16 100644 --- a/docs/src/guide/client/html-resource.md +++ b/docs/src/guide/client/html-resource.md @@ -21,13 +21,20 @@ The component accepts the following props: - **`resource`**: The resource object from an `UIResource`. It should include `uri`, `mimeType`, and either `text` or `blob`. - **`onUIAction`**: An optional callback that fires when the iframe content (for `ui://` resources) posts a message to your app. The message should look like: ```typescript - { type: 'tool', payload: { toolName: string, params: Record } } | - { type: 'intent', payload: { intent: string, params: Record } } | - { type: 'prompt', payload: { prompt: string } } | - { type: 'notify', payload: { message: string } } | - { type: 'link', payload: { url: string } } | + { type: 'tool', payload: { toolName: string, params: Record }, messageId?: string } | + { type: 'intent', payload: { intent: string, params: Record }, messageId?: string } | + { type: 'prompt', payload: { prompt: string }, messageId?: string } | + { type: 'notify', payload: { message: string }, messageId?: string } | + { type: 'link', payload: { url: string }, messageId?: string } | ``` If you don't provide a callback for a specific type, the default handler will be used. + + **Asynchronous Response Handling**: When a message includes a `messageId` field, the iframe will automatically receive response messages: + - `ui-action-received`: Sent immediately when the message is received + - `ui-action-response`: Sent when your callback resolves successfully + - `ui-action-error`: Sent if your callback throws an error + + See [Protocol Details](../protocol-details.md#asynchronous-communication-with-message-ids) for complete examples. - **`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=`. 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. diff --git a/docs/src/guide/client/resource-renderer.md b/docs/src/guide/client/resource-renderer.md index c3ef9a41..93fd1976 100644 --- a/docs/src/guide/client/resource-renderer.md +++ b/docs/src/guide/client/resource-renderer.md @@ -36,12 +36,14 @@ interface UIResourceRendererProps { - **`resource`**: The resource object from an MCP response. Should include `uri`, `mimeType`, and content (`text`, `blob`, or `content`) - **`onUIAction`**: Optional callback for handling UI actions from the resource: ```typescript - { type: 'tool', payload: { toolName: string, params: Record } } | - { type: 'intent', payload: { intent: string, params: Record } } | - { type: 'prompt', payload: { prompt: string } } | - { type: 'notify', payload: { message: string } } | - { type: 'link', payload: { url: string } } + { type: 'tool', payload: { toolName: string, params: Record }, messageId?: string } | + { type: 'intent', payload: { intent: string, params: Record }, messageId?: string } | + { type: 'prompt', payload: { prompt: string }, messageId?: string } | + { type: 'notify', payload: { message: string }, messageId?: string } | + { type: 'link', payload: { url: string }, messageId?: string } ``` + + **Asynchronous Communication**: When actions include a `messageId`, the iframe automatically receives response messages (`ui-action-received`, `ui-action-response`, `ui-action-error`). See [Protocol Details](../protocol-details.md#asynchronous-communication-with-message-ids) for examples. - **`supportedContentTypes`**: Optional array to restrict which content types are allowed (`['rawHtml', 'externalUrl', 'remoteDom']`) - **`htmlProps`**: Optional props for the `` - **`style`**: Optional custom styles for iframe-based resources diff --git a/docs/src/guide/client/usage-examples.md b/docs/src/guide/client/usage-examples.md index e2b6b6f3..f66ab76a 100644 --- a/docs/src/guide/client/usage-examples.md +++ b/docs/src/guide/client/usage-examples.md @@ -229,4 +229,181 @@ export default App; --- +## Handling Asynchronous Actions with Message IDs + +When your iframe content needs to track the status of long-running operations, you can use the `messageId` field to receive acknowledgment and response messages. Here's a complete example: + +### HTML Resource with Async Communication + +```typescript +import React, { useState } from 'react'; +import { UIResourceRenderer } from '@mcp-ui/client'; + +const AsyncExampleApp: React.FC = () => { + const [actionStatus, setActionStatus] = useState('Ready'); + const [actionResult, setActionResult] = useState(null); + + const handleAsyncUIAction = async (result: UIActionResult): Promise => { + console.log(`Received action with messageId: ${result.messageId}`); + setActionStatus('Processing...'); + + // Simulate an async operation (e.g., API call, database query) + await new Promise(resolve => setTimeout(resolve, 2000)); + + if (result.type === 'tool' && result.payload.toolName === 'processData') { + // Simulate success or failure based on params + if (result.payload.params.shouldFail) { + throw new Error('Simulated processing error'); + } + + return { + status: 'success', + processedData: `Processed: ${result.payload.params.data}`, + timestamp: new Date().toISOString() + }; + } + + return { status: 'unknown action' }; + }; + + const asyncHtmlResource = { + uri: 'ui://async-example/demo', + mimeType: 'text/html' as const, + text: ` + + + + + + +

Async Action Demo

+ + +
Ready
+
+ + + + + ` + }; + + return ( +
+

Async Communication Example

+

Host Status: {actionStatus}

+ {actionResult && ( +
+

Last Host Result:

+
{JSON.stringify(actionResult, null, 2)}
+
+ )} + + +
+ ); +}; +``` + +### Key Features Demonstrated + +1. **Message ID Generation**: The iframe creates unique message IDs for each request +2. **Request Tracking**: Pending requests are stored to match responses +3. **Status Updates**: The UI shows different states (pending, success, error) +4. **Response Handling**: Different message types trigger appropriate UI updates +5. **Cleanup**: Completed requests are removed from pending tracking + +This pattern is especially useful for: +- Long-running server operations +- File uploads or downloads +- Database queries +- External API calls +- Multi-step workflows + +--- + That's it! Just use `` 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](./resource-renderer.md) page. diff --git a/docs/src/guide/protocol-details.md b/docs/src/guide/protocol-details.md index 54fbb262..66492b39 100644 --- a/docs/src/guide/protocol-details.md +++ b/docs/src/guide/protocol-details.md @@ -93,6 +93,8 @@ if ( For `ui://` resources, you can use `window.parent.postMessage` to send data or actions from the iframe back to the host client application. The client application should set up an event listener for `message` events. +### Basic Communication + **Iframe Script Example:** ```html @@ -120,3 +122,140 @@ window.addEventListener('message', (event) => { } }); ``` + +### Asynchronous Communication with Message IDs + +For iframe content that needs to handle asynchronous responses, you can include a `messageId` field in your UI action messages. When the host provides an `onUIAction` callback, the iframe will receive acknowledgment and response messages. + +**Message Flow:** + +1. **Iframe sends message with `messageId`:** + ```javascript + window.parent.postMessage({ + type: 'tool', + messageId: 'unique-request-id-123', + payload: { toolName: 'myAsyncTool', params: { data: 'some data' } } + }, '*'); + ``` + +2. **Host responds with acknowledgment:** + ```javascript + // The iframe receives this message back + { + type: 'ui-action-received', + messageId: 'unique-request-id-123', + } + ``` + +3. **When `onUIAction` completes successfully:** + ```javascript + // The iframe receives the actual response + { + type: 'ui-action-response', + messageId: 'unique-request-id-123', + payload: { + response: { /* the result from onUIAction */ } + } + } + ``` + +4. **If `onUIAction` encounters an error:** + ```javascript + // The iframe receives the error + { + type: 'ui-action-error', + messageId: 'unique-request-id-123', + payload: { + error: { /* the error object */ } + } + } + ``` + +**Complete Iframe Example with Async Handling:** + +```html + +
Ready
+
+ + +``` + +### Message Types + +The following internal message types are available as constants: + +- `InternalMessageType.UI_ACTION_RECEIVED` (`'ui-action-received'`) +- `InternalMessageType.UI_ACTION_RESPONSE` (`'ui-action-response'`) +- `InternalMessageType.UI_ACTION_ERROR` (`'ui-action-error'`) + +These types are exported from both `@mcp-ui/client` and `@mcp-ui/server` packages. + +**Important Notes:** + +- **Message ID is optional**: If you don't provide a `messageId`, the iframe will not receive response messages. +- **Only with `onUIAction`**: Response messages are only sent when the host provides an `onUIAction` callback. +- **Unique IDs**: Ensure `messageId` values are unique to avoid conflicts between multiple pending requests. +- **Cleanup**: Always clean up pending request tracking when you receive responses to avoid memory leaks. diff --git a/packages/client/README.md b/packages/client/README.md index 6a36993e..83faaa1a 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -74,12 +74,13 @@ It accepts the following props: - **`resource`**: The resource object from an MCP response. Should include `uri`, `mimeType`, and content (`text`, `blob`, or `content`) - **`onUIAction`**: Optional callback for handling UI actions from the resource: ```typescript - { type: 'tool', payload: { toolName: string, params: Record } } | - { type: 'intent', payload: { intent: string, params: Record } } | - { type: 'prompt', payload: { prompt: string } } | - { type: 'notify', payload: { message: string } } | - { type: 'link', payload: { url: string } } + { type: 'tool', payload: { toolName: string, params: Record }, messageId?: string } | + { type: 'intent', payload: { intent: string, params: Record }, messageId?: string } | + { type: 'prompt', payload: { prompt: string }, messageId?: string } | + { type: 'notify', payload: { message: string }, messageId?: string } | + { type: 'link', payload: { url: string }, messageId?: string } ``` + When actions include a `messageId`, the iframe automatically receives response messages for asynchronous handling. - **`supportedContentTypes`**: Optional array to restrict which content types are allowed (`['rawHtml', 'externalUrl', 'remoteDom']`) - **`htmlProps`**: Optional props for the internal `` - **`style`**: Optional custom styles for the iframe diff --git a/packages/client/src/components/HTMLResourceRenderer.tsx b/packages/client/src/components/HTMLResourceRenderer.tsx index 0a2e7d38..4dfcc636 100644 --- a/packages/client/src/components/HTMLResourceRenderer.tsx +++ b/packages/client/src/components/HTMLResourceRenderer.tsx @@ -13,6 +13,12 @@ export type HTMLResourceRendererProps = { }; }; +const InternalMessageType = { + UI_ACTION_RECEIVED: 'ui-action-received', + UI_ACTION_RESPONSE: 'ui-action-response', + UI_ACTION_ERROR: 'ui-action-error', +} as const; + export const HTMLResourceRenderer = ({ resource, onUIAction, @@ -29,16 +35,30 @@ export const HTMLResourceRenderer = ({ ); useEffect(() => { - function handleMessage(event: MessageEvent) { + async function handleMessage(event: MessageEvent) { // Only process the message if it came from this specific iframe if (iframeRef.current && event.source === iframeRef.current.contentWindow) { const uiActionResult = event.data as UIActionResult; if (!uiActionResult) { return; } - onUIAction?.(uiActionResult)?.catch((err) => { - console.error('Error handling UI action result in HTMLResourceRenderer:', err); - }); + + // return the "ui-action-received" message only if the onUIAction callback is provided + // otherwise we cannot know that the message was received by the client + if (onUIAction) { + postToFrame(InternalMessageType.UI_ACTION_RECEIVED, event, uiActionResult); + try { + const response = await onUIAction(uiActionResult); + postToFrame(InternalMessageType.UI_ACTION_RESPONSE, event, uiActionResult, { + response, + }); + } catch (err) { + console.error('Error handling UI action result in HTMLResourceRenderer:', err); + postToFrame(InternalMessageType.UI_ACTION_ERROR, event, uiActionResult, { + error: err, + }); + } + } } } window.addEventListener('message', handleMessage); @@ -87,3 +107,24 @@ export const HTMLResourceRenderer = ({ }; HTMLResourceRenderer.displayName = 'HTMLResourceRenderer'; + +function postToFrame( + type: (typeof InternalMessageType)[keyof typeof InternalMessageType], + event: MessageEvent, + uiActionResult: UIActionResult, + payload?: unknown, +) { + if (uiActionResult.messageId) { + event.source?.postMessage( + { + type, + messageId: uiActionResult.messageId, + payload, + }, + { + // in case the iframe is srcdoc, the origin is null + targetOrigin: event.origin && event.origin !== 'null' ? event.origin : '*', + }, + ); + } +} diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 88abc8e6..fbea5efc 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -6,7 +6,11 @@ export type UIActionType = 'tool' | 'prompt' | 'link' | 'intent' | 'notify'; export const ALL_RESOURCE_CONTENT_TYPES = ['rawHtml', 'externalUrl', 'remoteDom'] as const; export type ResourceContentType = (typeof ALL_RESOURCE_CONTENT_TYPES)[number]; -export type UIActionResultToolCall = { +type GenericActionMessage = { + messageId?: string; +}; + +export type UIActionResultToolCall = GenericActionMessage & { type: 'tool'; payload: { toolName: string; @@ -14,21 +18,21 @@ export type UIActionResultToolCall = { }; }; -export type UIActionResultPrompt = { +export type UIActionResultPrompt = GenericActionMessage & { type: 'prompt'; payload: { prompt: string; }; }; -export type UIActionResultLink = { +export type UIActionResultLink = GenericActionMessage & { type: 'link'; payload: { url: string; }; }; -export type UIActionResultIntent = { +export type UIActionResultIntent = GenericActionMessage & { type: 'intent'; payload: { intent: string; @@ -36,7 +40,7 @@ export type UIActionResultIntent = { }; }; -export type UIActionResultNotification = { +export type UIActionResultNotification = GenericActionMessage & { type: 'notify'; payload: { message: string; diff --git a/packages/server/README.md b/packages/server/README.md index 6a36993e..83faaa1a 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -74,12 +74,13 @@ It accepts the following props: - **`resource`**: The resource object from an MCP response. Should include `uri`, `mimeType`, and content (`text`, `blob`, or `content`) - **`onUIAction`**: Optional callback for handling UI actions from the resource: ```typescript - { type: 'tool', payload: { toolName: string, params: Record } } | - { type: 'intent', payload: { intent: string, params: Record } } | - { type: 'prompt', payload: { prompt: string } } | - { type: 'notify', payload: { message: string } } | - { type: 'link', payload: { url: string } } + { type: 'tool', payload: { toolName: string, params: Record }, messageId?: string } | + { type: 'intent', payload: { intent: string, params: Record }, messageId?: string } | + { type: 'prompt', payload: { prompt: string }, messageId?: string } | + { type: 'notify', payload: { message: string }, messageId?: string } | + { type: 'link', payload: { url: string }, messageId?: string } ``` + When actions include a `messageId`, the iframe automatically receives response messages for asynchronous handling. - **`supportedContentTypes`**: Optional array to restrict which content types are allowed (`['rawHtml', 'externalUrl', 'remoteDom']`) - **`htmlProps`**: Optional props for the internal `` - **`style`**: Optional custom styles for the iframe diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 948bf93f..db26da11 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -135,6 +135,12 @@ export function postUIActionResult(result: UIActionResult): void { } } +export const InternalMessageType = { + UI_ACTION_RECEIVED: 'ui-action-received', + UI_ACTION_RESPONSE: 'ui-action-response', + UI_ACTION_ERROR: 'ui-action-error', +}; + export function uiActionResultToolCall( toolName: string, params: Record, diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index e5ca0228..4df06b92 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -39,7 +39,11 @@ export interface CreateUIResourceOptions { export type UIActionType = 'tool' | 'prompt' | 'link' | 'intent' | 'notify'; -export type UIActionResultToolCall = { +type GenericActionMessage = { + messageId?: string; +}; + +export type UIActionResultToolCall = GenericActionMessage & { type: 'tool'; payload: { toolName: string; @@ -47,21 +51,21 @@ export type UIActionResultToolCall = { }; }; -export type UIActionResultPrompt = { +export type UIActionResultPrompt = GenericActionMessage & { type: 'prompt'; payload: { prompt: string; }; }; -export type UIActionResultLink = { +export type UIActionResultLink = GenericActionMessage & { type: 'link'; payload: { url: string; }; }; -export type UIActionResultIntent = { +export type UIActionResultIntent = GenericActionMessage & { type: 'intent'; payload: { intent: string; @@ -69,7 +73,7 @@ export type UIActionResultIntent = { }; }; -export type UIActionResultNotification = { +export type UIActionResultNotification = GenericActionMessage & { type: 'notify'; payload: { message: string;