feat: MCPAppRenderer for inline MCP App iframes with JSON-RPC postMessage bridge
This commit is contained in:
@@ -0,0 +1,159 @@
|
|||||||
|
import { useRef, useEffect, useCallback, useState } from 'react'
|
||||||
|
import { IconAppWindow, IconLoader } from '@tabler/icons-react'
|
||||||
|
|
||||||
|
interface MCPAppRendererProps {
|
||||||
|
resourceUri: string
|
||||||
|
data: Record<string, unknown>
|
||||||
|
csp?: string
|
||||||
|
onToolCall?: (toolName: string, args: Record<string, unknown>) => void
|
||||||
|
onSendMessage?: (content: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MCPAppRenderer({
|
||||||
|
resourceUri,
|
||||||
|
data,
|
||||||
|
onToolCall,
|
||||||
|
onSendMessage,
|
||||||
|
}: MCPAppRendererProps) {
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [iframeHeight, setIframeHeight] = useState(300)
|
||||||
|
|
||||||
|
const appName = resourceUri.replace(/^app:\/\//, '')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
fetch(`/api/apps/${appName}`, { credentials: 'include' })
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`Failed to load app: ${res.status}`)
|
||||||
|
return res.text()
|
||||||
|
})
|
||||||
|
.then((html) => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (iframeRef.current) {
|
||||||
|
iframeRef.current.srcdoc = html
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [appName])
|
||||||
|
|
||||||
|
const handleMessage = useCallback(
|
||||||
|
(event: MessageEvent) => {
|
||||||
|
if (!iframeRef.current?.contentWindow) return
|
||||||
|
|
||||||
|
const msg = event.data
|
||||||
|
if (!msg || msg.jsonrpc !== '2.0') return
|
||||||
|
|
||||||
|
const params = msg.params ?? {}
|
||||||
|
|
||||||
|
switch (msg.method) {
|
||||||
|
case 'ui/initialize':
|
||||||
|
iframeRef.current.contentWindow.postMessage(
|
||||||
|
{ jsonrpc: '2.0', method: 'tool/input', params: data },
|
||||||
|
'*'
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'tools/call':
|
||||||
|
onToolCall?.(params.name, params.arguments ?? {})
|
||||||
|
iframeRef.current.contentWindow.postMessage(
|
||||||
|
{ jsonrpc: '2.0', id: msg.id, result: { status: 'accepted' } },
|
||||||
|
'*'
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ui/sendMessage':
|
||||||
|
if (onSendMessage && params.content) {
|
||||||
|
onSendMessage(params.content)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ui/resize':
|
||||||
|
if (typeof params.height === 'number') {
|
||||||
|
setIframeHeight(Math.min(params.height, 800))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data, onToolCall, onSendMessage]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('message', handleMessage)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('message', handleMessage)
|
||||||
|
}
|
||||||
|
}, [handleMessage])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='tool-call-card'
|
||||||
|
style={{ color: '#f7607a', fontSize: '0.8rem' }}
|
||||||
|
>
|
||||||
|
<IconAppWindow size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||||
|
MCP App error: {error}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='card'
|
||||||
|
style={{
|
||||||
|
margin: '0.5rem 0',
|
||||||
|
border: '1px solid #2a2a4a',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: '#0a0a1a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.3rem 0.6rem',
|
||||||
|
borderBottom: '1px solid #2a2a4a',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.3rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: '#7aa2f7',
|
||||||
|
background: '#12192e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<IconLoader size={14} />
|
||||||
|
) : (
|
||||||
|
<IconAppWindow size={14} />
|
||||||
|
)}
|
||||||
|
{appName}
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
title={`MCP App: ${appName}`}
|
||||||
|
sandbox='allow-scripts allow-forms allow-popups'
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: iframeHeight + 'px',
|
||||||
|
border: 'none',
|
||||||
|
background: '#0a0a1a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
describe('MCPAppRenderer', () => {
|
||||||
|
it('exports MCPAppRenderer as a function', async () => {
|
||||||
|
const mod = await import('../MCPAppRenderer')
|
||||||
|
expect(mod.MCPAppRenderer).toBeDefined()
|
||||||
|
expect(typeof mod.MCPAppRenderer).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("strips 'app://' prefix from 'app://chart'", () => {
|
||||||
|
expect('app://chart'.replace(/^app:\/\//, '')).toBe('chart')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("strips 'app://' prefix from 'app://comparison'", () => {
|
||||||
|
expect('app://comparison'.replace(/^app:\/\//, '')).toBe('comparison')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user