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