feat: MCPAppRenderer for inline MCP App iframes with JSON-RPC postMessage bridge

This commit is contained in:
Ken
2026-05-26 19:36:28 +00:00
parent 3723c5bf93
commit 12d1df417f
2 changed files with 176 additions and 0 deletions
+159
View File
@@ -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')
})
})