Protocol Details
This section dives deeper into the UIResource
and its intended usage.
UIResource
Recap
export interface UIResource {
type: 'resource';
resource: {
uri: string;
mimeType: 'text/html' | 'text/uri-list' | 'application/vnd.mcp-ui.remote-dom';
text?: string;
blob?: string;
};
}
2
3
4
5
6
7
8
9
URI Schemes
ui://<component-name>/<instance-id>
- Purpose: For all UI resources. The rendering method is determined by
mimeType
. - Content:
text
orblob
contains either HTML string or URL string. - Client Action:
- If
mimeType: 'text/html'
→ Render in a sandboxed iframe usingsrcdoc
- If
mimeType: 'text/uri-list'
→ Render in a sandboxed iframe usingsrc
- If
mimeType: 'application/vnd.mcp-ui.remote-dom'
→ Execute in sandboxed iframe and render in the tree
- If
- Examples:
- HTML content: A custom button, a small form, a data visualization snippet
- URL content: Embedding a Grafana dashboard, a third-party widget, a mini-application
- RemoteDOM content: A component to be rendered with the host's look-and-feel (component library)
- Purpose: For all UI resources. The rendering method is determined by
Content encoding: text
vs. blob
text
: Simple, direct string. Good for smaller, less complex content.blob
: Base64 encoded string.- Pros: Handles special characters robustly, can be better for larger payloads, ensures integrity during JSON transport.
- Cons: Requires Base64 decoding on the client, slightly increases payload size.
URI List Format Support
When using mimeType: 'text/uri-list'
, the content follows the standard URI list format (RFC 2483). However, MCP-UI requires a single URL for rendering. For security reasons, the protocol must be http/s
.
- Single URL Requirement: MCP-UI will use only the first valid URL found
- Multiple URLs: If multiple URLs are provided, the client will use the first valid URL and log a warning about the ignored alternatives
- Comments: Lines starting with
#
are treated as comments and ignored - Empty lines: Blank lines are ignored
Example URI List Content:
# Primary dashboard URL
https://dashboard.example.com/main
# Backup dashboard URL (will be ignored but logged)
https://backup.dashboard.example.com/main
2
3
4
5
Client Behavior:
- Uses
https://dashboard.example.com/main
for rendering - Logs:
"Multiple URLs found in uri-list content. Using the first URL: "https://dashboard.example.com/main". Other URLs ignored: ["https://backup.dashboard.example.com/main"]"
This design allows for fallback URLs to be specified in the standard format while maintaining simple client implementation that focuses on a single primary URL.
Recommended Client-Side Pattern
Client-side hosts should check for the ui://
URI scheme first to identify MCP-UI resources, rather than checking mimeType:
// ✅ Recommended: Check URI scheme first
if (
mcpResource.type === 'resource' &&
mcpResource.resource.uri?.startsWith('ui://')
) {
return <UIResourceRenderer resource={mcpResource.resource} onUIAction={handleAction} />;
}
// ❌ Not recommended: Check mimeType first
if (
mcpResource.type === 'resource' &&
(mcpResource.resource.mimeType === 'text/html' || mcpResource.resource.mimeType === 'text/uri-list')
) {
return <UIResourceRenderer resource={mcpResource.resource} onUIAction={handleAction} />;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Benefits of URI-first checking:
- Future-proof: Works with new content types like
application/javascript
- Semantic clarity:
ui://
clearly indicates this is a UI resource - Simpler logic: Let the
UIResourceRenderer
component handle mimeType-based rendering internally
Communication (Client <-> Iframe)
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:
<button onclick="handleAction()">Submit Data</button>
<script>
function handleAction() {
const data = { action: 'formData', value: 'someValue' };
// IMPORTANT: Always specify the targetOrigin for security!
// Use '*' only if the parent origin is unknown or variable and security implications are understood.
window.parent.postMessage(
{ type: 'tool', payload: { toolName: 'myCustomTool', params: data } },
'*',
);
}
</script>
2
3
4
5
6
7
8
9
10
11
12
Client-Side Handler:
window.addEventListener('message', (event) => {
// Add origin check for security: if (event.origin !== "expectedOrigin") return;
if (event.data && event.data.tool) {
// Call the onUIAction prop of UIResourceRenderer
}
});
2
3
4
5
6
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:
Iframe sends message with
messageId
:javascriptwindow.parent.postMessage({ type: 'tool', messageId: 'unique-request-id-123', payload: { toolName: 'myAsyncTool', params: { data: 'some data' } } }, '*');
1
2
3
4
5Host responds with acknowledgment:
javascript// The iframe receives this message back { type: 'ui-message-received', messageId: 'unique-request-id-123', }
1
2
3
4
5When
onUIAction
completes successfully:javascript// The iframe receives the actual response { type: 'ui-message-response', messageId: 'unique-request-id-123', payload: { response: { /* the result from onUIAction */ } } }
1
2
3
4
5
6
7
8If
onUIAction
encounters an error:javascript// The iframe receives the error { type: 'ui-message-response', messageId: 'unique-request-id-123', payload: { error: { /* the error object */ } } }
1
2
3
4
5
6
7
8
Complete Iframe Example with Async Handling:
<button onclick="handleAsyncAction()">Async Action</button>
<div id="status">Ready</div>
<div id="result"></div>
<script>
let messageCounter = 0;
const pendingRequests = new Map();
function generateMessageId() {
return `msg-${Date.now()}-${++messageCounter}`;
}
function handleAsyncAction() {
const messageId = generateMessageId();
const statusEl = document.getElementById('status');
const resultEl = document.getElementById('result');
statusEl.textContent = 'Sending request...';
// Store the request context
pendingRequests.set(messageId, {
startTime: Date.now(),
action: 'async-tool-call'
});
// Send the message with messageId
window.parent.postMessage({
type: 'tool',
messageId: messageId,
payload: {
toolName: 'processData',
params: { data: 'example data', timestamp: Date.now() }
}
}, '*');
}
// Listen for responses from the host
window.addEventListener('message', (event) => {
const message = event.data;
if (!message.messageId || !pendingRequests.has(message.messageId)) {
return; // Not for us or unknown request
}
const statusEl = document.getElementById('status');
const resultEl = document.getElementById('result');
const request = pendingRequests.get(message.messageId);
switch (message.type) {
case 'ui-message-received':
statusEl.textContent = 'Request acknowledged, processing...';
break;
case 'ui-message-response':
if (message.payload.error) {
statusEl.textContent = 'Error occurred!';
resultEl.innerHTML = `<div style="color: red;">Error: ${JSON.stringify(message.payload.error)}</div>`;
pendingRequests.delete(message.messageId);
break;
}
statusEl.textContent = 'Completed successfully!';
resultEl.innerHTML = `<pre>${JSON.stringify(message.payload.response, null, 2)}</pre>`;
pendingRequests.delete(message.messageId);
break;
}
});
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
Message Types
The following internal message types are available as constants:
InternalMessageType.UI_MESSAGE_RECEIVED
('ui-message-received'
)InternalMessageType.UI_MESSAGE_RESPONSE
('ui-message-response'
)
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 anonUIAction
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.