Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
486 changes: 486 additions & 0 deletions docs/upgrade-research.md

Large diffs are not rendered by default.

80 changes: 68 additions & 12 deletions epicshop/epic-me/app/routes/mcp-ui-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import {
type UIActionResult,
isUIResource,
} from '@mcp-ui/client'
import { useState, useRef, useEffect, useCallback, type RefObject } from 'react'
import {
useState,
useRef,
useEffect,
useCallback,
useLayoutEffect,
type RefObject,
} from 'react'
import { Form, isRouteErrorResponse } from 'react-router'
import { type Route } from './+types/mcp-ui-renderer'

Expand Down Expand Up @@ -135,6 +142,16 @@ export default function MCPRenderer({ loaderData }: Route.ComponentProps) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [isErrorResponse, setIsErrorResponse] = useState<boolean>(false)

const sendResponseToIframe = (
messageId: string,
payload: { response?: unknown; error?: unknown },
) => {
iframeRef.current?.contentWindow?.postMessage(
{ type: 'ui-message-response', messageId, payload },
'*',
)
}

// Auto-scroll when new messages are added and user is at bottom
useEffect(() => {
if (isAtBottom) {
Expand All @@ -143,7 +160,7 @@ export default function MCPRenderer({ loaderData }: Route.ComponentProps) {
}, [messages, isAtBottom, scrollToBottom])

// Listen to all iframe messages
useEffect(() => {
useLayoutEffect(() => {
const handleMessage = (event: MessageEvent) => {
// Only handle messages from our iframe
if (
Expand All @@ -154,7 +171,6 @@ export default function MCPRenderer({ loaderData }: Route.ComponentProps) {
const messageData =
typeof event.data === 'string' ? JSON.parse(event.data) : event.data

// Check if this is a UI action message that would be handled by onUIAction
const isUIActionMessage =
messageData &&
typeof messageData === 'object' &&
Expand All @@ -163,18 +179,35 @@ export default function MCPRenderer({ loaderData }: Route.ComponentProps) {
messageData.type,
)

// Check if this is a lifecycle message that should be handled internally by UIResourceRenderer
const isLifecycleMessage =
messageData &&
typeof messageData === 'object' &&
messageData.type &&
messageData.type.startsWith('ui-lifecycle-')

// If it's not a UI action message and not a lifecycle message, display it as internal
if (!isUIActionMessage && !isLifecycleMessage) {
const messageContent = JSON.stringify(messageData, null, 2)
addMessage('internal', messageContent, messageData.messageId)
const messageContent = JSON.stringify(messageData, null, 2)

if (isUIActionMessage) {
const { messageId } = messageData
if (!messageId || !pendingPromisesRef.current.has(messageId)) {
addMessage('received', messageContent, messageId)
}

if (messageId && !pendingPromisesRef.current.has(messageId)) {
pendingPromisesRef.current.set(messageId, {
resolve: (response) =>
sendResponseToIframe(messageId, { response }),
reject: (error) => sendResponseToIframe(messageId, { error }),
})
}
return
}

if (isLifecycleMessage) {
return
}

addMessage('internal', messageContent, messageData.messageId)
} catch {
// If we can't parse the message, still display it as internal
const messageContent =
Expand All @@ -186,8 +219,9 @@ export default function MCPRenderer({ loaderData }: Route.ComponentProps) {
}
}

window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
window.addEventListener('message', handleMessage, { capture: true })
return () =>
window.removeEventListener('message', handleMessage, { capture: true })
}, [])

const addMessage = (
Expand All @@ -214,14 +248,28 @@ export default function MCPRenderer({ loaderData }: Route.ComponentProps) {

const handleUIAction = async (result: UIActionResult) => {
const messageId = 'messageId' in result ? result.messageId : undefined
const existingPending = messageId
? pendingPromisesRef.current.get(messageId)
: undefined

const fullResult = JSON.stringify(result, null, 2)
addMessage('received', fullResult, messageId)
if (!existingPending) {
addMessage('received', fullResult, messageId)
}

// Return a promise that will be resolved when user submits response
return new Promise((resolve, reject) => {
if (messageId) {
pendingPromisesRef.current.set(messageId, { resolve, reject })
pendingPromisesRef.current.set(messageId, {
resolve: (value) => {
existingPending?.resolve(value)
resolve(value)
},
reject: (error) => {
existingPending?.reject(error)
reject(error)
},
})
}
})
}
Expand Down Expand Up @@ -267,6 +315,13 @@ export default function MCPRenderer({ loaderData }: Route.ComponentProps) {
return messageId && pendingPromisesRef.current.has(messageId)
}

const handleIframeLoad = () => {
addMessage(
'internal',
JSON.stringify({ type: 'ui-lifecycle-iframe-ready' }, null, 2),
)
}

return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-8 dark:from-gray-900 dark:to-gray-800">
<div className="mx-auto max-w-6xl">
Expand Down Expand Up @@ -317,6 +372,7 @@ export default function MCPRenderer({ loaderData }: Route.ComponentProps) {
ref: iframeRef as RefObject<HTMLIFrameElement>,
title: `Resource content: ${content.resource.uri}`,
'aria-label': 'Interactive resource renderer',
onLoad: handleIframeLoad,
},
autoResizeIframe: true,
}}
Expand Down
Loading
Loading