[MCP-UI] Interactive
While iframe-based UI components provide rich, visual interfaces, they need a way to communicate back to the host application to trigger actions, request data, or request links be opened. Without this communication, iframes would be isolated islands that can't integrate with the broader application ecosystem.
The solution is the MCP UI communication protocol - a standardized way for iframe-based UI components to send messages to their host application using the
postMessage API. This enables rich interactions like calling MCP tools, sending prompts to AI assistants, navigating to external links, and requesting data from the host.// Send a link navigation request to the host
await sendLinkMcpMessage('https://www.example.com/snowboarding/')
// Call an MCP tool from within the iframe
await sendMcpMessage('tool', {
toolName: 'generate_haiku',
params: { theme: 'quantum computing', mood: 'playful' },
})
// Send a prompt to the AI assistant
await sendMcpMessage('prompt', {
prompt:
'Create a recipe for a fusion dish combining Japanese and Mexican cuisine',
})
The MCP UI communication protocol uses
postMessageto enable secure, bidirectional communication between iframe-based UI components and their host application. This allows iframes to trigger actions in the host while maintaining security boundaries.
Here's how this works in practice. When a user clicks a "Watch Launch" button in your space exploration dashboard iframe, instead of opening a new tab or trying to navigate directly, the iframe sends a
link message to the host application. The host then handles the navigation, ensuring it follows the application's routing and security policies.The communication protocol supports several key message types:
link- Request host to navigate to a URLtool- Request host to execute an MCP toolprompt- Request host to send a prompt to the AI assistantintent- Express user intent for the host to act uponnotify- Notify host of side effects from user interactions
intentandnotifyare not used in this exercise as they are not relevant for general use AI agent apps and typically require specific server-client integration.
The message structure follows a consistent pattern:
type Message = {
type: string
messageId?: string // optional, used for tracking the message
payload: Record<string, unknown>
}
Navigate to Links
Since iframes can't directly navigate their parent window, we need to communicate with the host application. The pattern is:
- Generate unique ID - Create a message ID to match requests with responses
- Send message - Use
window.parent.postMessage()with type'link'and the URL - Listen for response - Handle the
'ui-message-response'from the host - Clean up - Remove event listener when done
The communication pattern looks like:
// Send request with unique ID
window.parent.postMessage(
{
type: 'link',
messageId: uniqueId,
payload: { url },
},
'*',
)
// Listen for matching response
function handleMessage(event) {
if (
event.data.type === 'ui-message-response' &&
event.data.messageId === uniqueId
) {
// Handle success/error
}
}
Code:
查看代码
export async function sendLinkMcpMessage(url: string) {
const messageId = crypto.randomUUID()
return new Promise((resolve, reject) => {
function handleMessage(event: MessageEvent) {
if (event.data.type !== 'ui-message-response') return
if (event.data.messageId !== messageId) return
const { response, error } = event.data.payload
if (error) return reject(error)
window.removeEventListener('message', handleMessage)
return resolve(response)
}
window.parent.postMessage(
{ type: 'link', messageId, payload: { url } },
'*',
)
window.addEventListener('message', handleMessage)
})
}
utils:
查看代码
import { useEffect } from 'react'
import { type z } from 'zod'
export function useMcpUiInit(rootRef: React.RefObject<HTMLDivElement | null>) {
useEffect(() => {
window.parent.postMessage({ type: 'ui-lifecycle-iframe-ready' }, '*')
if (!rootRef.current) return
const height = rootRef.current.clientHeight
const width = rootRef.current.clientWidth
window.parent.postMessage(
{ type: 'ui-size-change', payload: { height, width } },
'*',
)
}, [rootRef])
}
type MessageOptions = { schema?: z.ZodSchema }
type McpMessageReturnType<Options> = Promise<
Options extends { schema: z.ZodSchema } ? z.infer<Options['schema']> : unknown
>
type McpMessageTypes = {
tool: { toolName: string; params: Record<string, unknown> }
prompt: { prompt: string }
link: { url: string }
}
type McpMessageType = keyof McpMessageTypes
function sendMcpMessage<Options extends MessageOptions>(
type: 'tool',
payload: McpMessageTypes['tool'],
options?: Options,
): McpMessageReturnType<Options>
function sendMcpMessage<Options extends MessageOptions>(
type: 'prompt',
payload: McpMessageTypes['prompt'],
options?: Options,
): McpMessageReturnType<Options>
function sendMcpMessage<Options extends MessageOptions>(
type: 'link',
payload: McpMessageTypes['link'],
options?: Options,
): McpMessageReturnType<Options>
function sendMcpMessage(
type: McpMessageType,
payload: McpMessageTypes[McpMessageType],
options: MessageOptions = {},
): McpMessageReturnType<typeof options> {
const { schema } = options
const messageId = crypto.randomUUID()
return new Promise((resolve, reject) => {
if (!window.parent || window.parent === window) {
console.log(`[MCP] No parent frame available. Would have sent message:`, {
type,
messageId,
payload,
})
reject(new Error('No parent frame available'))
return
}
window.parent.postMessage({ type, messageId, payload }, '*')
function handleMessage(event: MessageEvent) {
if (event.data.type !== 'ui-message-response') return
if (event.data.messageId !== messageId) return
window.removeEventListener('message', handleMessage)
const { response, error } = event.data.payload
if (error) return reject(error)
if (!schema) return resolve(response)
const parseResult = schema.safeParse(response)
if (!parseResult.success) return reject(parseResult.error)
return resolve(parseResult.data)
}
window.addEventListener('message', handleMessage)
})
}
export { sendMcpMessage }

浙公网安备 33010602011771号