-
Notifications
You must be signed in to change notification settings - Fork 391
React renderer for MCP Apps #147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 25 commits
9184830
73c2c0d
3b23ede
5342f81
6d07a4b
098d10e
c55ef76
e0c4421
d12ddcc
8aa7205
a312e52
cae7999
3cf3c37
e6dbe15
fc16af4
196401e
681bb43
a84faee
fc32fc6
f750c98
d2d350c
38a7fd0
93f8759
b2673b9
fece80e
23f28e9
28f61b7
8ff3186
0d64f86
d49b412
9bfcea7
73b7696
9be1bf9
9244180
c042d9c
f960673
4b67191
c3d0806
387879b
3fc3435
d4c623a
558d915
d256c63
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import typescriptEslint from '@typescript-eslint/eslint-plugin'; | ||
| import typescriptParser from '@typescript-eslint/parser'; | ||
| import reactPlugin from 'eslint-plugin-react'; | ||
| import prettier from 'eslint-config-prettier'; | ||
|
|
||
| export default [ | ||
| { | ||
| ignores: [ | ||
| 'node_modules/**', | ||
| 'dist/**', | ||
| 'coverage/**', | ||
| '**/*.log', | ||
| '**/*.js', | ||
| '**/*.mjs', | ||
| '**/*.cjs', | ||
| 'sdks/typescript/client/src/remote-dom/iframe-bundle.ts', | ||
| 'examples/**', | ||
| ], | ||
| }, | ||
| { | ||
| files: ['**/*.ts', '**/*.tsx'], | ||
| plugins: { | ||
| '@typescript-eslint': typescriptEslint, | ||
| react: reactPlugin, | ||
| }, | ||
| languageOptions: { | ||
| parser: typescriptParser, | ||
| parserOptions: { | ||
| ecmaVersion: 'latest', | ||
| sourceType: 'module', | ||
| ecmaFeatures: { | ||
| jsx: true, | ||
| }, | ||
| }, | ||
| }, | ||
| settings: { | ||
| react: { | ||
| version: 'detect', | ||
| }, | ||
| }, | ||
| rules: { | ||
| ...typescriptEslint.configs.recommended.rules, | ||
| ...reactPlugin.configs.recommended.rules, | ||
| ...reactPlugin.configs['jsx-runtime'].rules, | ||
| 'react/prop-types': 'off', | ||
| '@typescript-eslint/no-explicit-any': 'off', | ||
| '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], | ||
| '@typescript-eslint/no-empty-object-type': 'off', | ||
| }, | ||
| }, | ||
| prettier, | ||
| ]; |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -19,11 +19,14 @@ | |||||
| "dist" | ||||||
| ], | ||||||
| "dependencies": { | ||||||
| "@modelcontextprotocol/sdk": "^1.22.0", | ||||||
| "@modelcontextprotocol/ext-apps": "github:modelcontextprotocol/ext-apps#main", | ||||||
|
||||||
| "@modelcontextprotocol/ext-apps": "github:modelcontextprotocol/ext-apps#main", | |
| "@modelcontextprotocol/ext-apps": "github:modelcontextprotocol/ext-apps#abcdef1234567890abcdef1234567890abcdef12", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ochafik was this change needed? it is causing a resolution issue when trying to install locally ➤ YN0001: │ Error: @mcp-ui/shared@workspace:*: Workspace not found (@mcp-ui/shared@workspace:*) removing this line fixes the error
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed. Thanks @infoxicator!
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,253 @@ | ||||||||||
| import { useEffect, useRef, useState } from 'react'; | ||||||||||
|
|
||||||||||
| import type { | ||||||||||
| CallToolResult, | ||||||||||
| LoggingMessageNotification, | ||||||||||
| Implementation, | ||||||||||
| } from '@modelcontextprotocol/sdk/types.js'; | ||||||||||
|
|
||||||||||
| import { | ||||||||||
| AppBridge, | ||||||||||
| PostMessageTransport, | ||||||||||
| type McpUiSizeChangedNotification, | ||||||||||
| type McpUiResourceCsp, | ||||||||||
| type McpUiAppCapabilities, | ||||||||||
| } from '@modelcontextprotocol/ext-apps/app-bridge'; | ||||||||||
|
|
||||||||||
| import { setupSandboxProxyIframe } from '../utils/app-host-utils'; | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Information about the guest app, available after initialization. | ||||||||||
| */ | ||||||||||
| export interface AppInfo { | ||||||||||
| /** Guest app's name and version */ | ||||||||||
| appVersion?: Implementation; | ||||||||||
| /** Guest app's declared capabilities */ | ||||||||||
| appCapabilities?: McpUiAppCapabilities; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Sandbox configuration for the iframe. | ||||||||||
| */ | ||||||||||
| export interface SandboxConfig { | ||||||||||
| /** URL to the sandbox proxy HTML */ | ||||||||||
| url: URL; | ||||||||||
| /** Override iframe sandbox attribute (default: "allow-scripts allow-same-origin allow-forms") */ | ||||||||||
| permissions?: string; | ||||||||||
| /** CSP metadata to forward to the sandbox proxy */ | ||||||||||
| csp?: McpUiResourceCsp; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Props for the AppFrame component. | ||||||||||
| */ | ||||||||||
| export interface AppFrameProps { | ||||||||||
| /** Pre-fetched HTML content to render in the sandbox */ | ||||||||||
| html: string; | ||||||||||
|
|
||||||||||
| /** Sandbox configuration */ | ||||||||||
| sandbox: SandboxConfig; | ||||||||||
|
|
||||||||||
| /** Pre-configured AppBridge for MCP communication (required) */ | ||||||||||
| appBridge: AppBridge; | ||||||||||
|
|
||||||||||
| /** Callback when guest reports size change */ | ||||||||||
| onSizeChanged?: (params: McpUiSizeChangedNotification['params']) => void; | ||||||||||
|
|
||||||||||
| /** Callback when guest sends a logging message */ | ||||||||||
| onLoggingMessage?: (params: LoggingMessageNotification['params']) => void; | ||||||||||
|
|
||||||||||
| /** Callback when app initialization completes, with app info */ | ||||||||||
| onInitialized?: (appInfo: AppInfo) => void; | ||||||||||
|
|
||||||||||
| /** Tool input arguments to send when app initializes */ | ||||||||||
| toolInput?: Record<string, unknown>; | ||||||||||
|
|
||||||||||
| /** Tool result to send when app initializes */ | ||||||||||
| toolResult?: CallToolResult; | ||||||||||
|
|
||||||||||
| /** Callback when an error occurs */ | ||||||||||
| onError?: (error: Error) => void; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Low-level component that renders pre-fetched HTML in a sandboxed iframe. | ||||||||||
| * | ||||||||||
| * This component requires a pre-configured AppBridge for MCP communication. | ||||||||||
| * For automatic AppBridge creation and resource fetching, use the higher-level | ||||||||||
| * AppRenderer component instead. | ||||||||||
| * | ||||||||||
| * @example With pre-configured AppBridge | ||||||||||
| * ```tsx | ||||||||||
| * const appBridge = new AppBridge(client, hostInfo, capabilities); | ||||||||||
| * // ... configure appBridge handlers ... | ||||||||||
| * | ||||||||||
| * <AppFrame | ||||||||||
| * html={htmlContent} | ||||||||||
| * sandbox={{ url: sandboxUrl }} | ||||||||||
| * appBridge={appBridge} | ||||||||||
| * toolInput={args} | ||||||||||
| * toolResult={result} | ||||||||||
| * onSizeChanged={({ width, height }) => console.log('Size:', width, height)} | ||||||||||
| * /> | ||||||||||
| * ``` | ||||||||||
| */ | ||||||||||
| export const AppFrame = (props: AppFrameProps) => { | ||||||||||
| const { | ||||||||||
| html, | ||||||||||
| sandbox, | ||||||||||
| appBridge, | ||||||||||
| onSizeChanged, | ||||||||||
| onLoggingMessage, | ||||||||||
| onInitialized, | ||||||||||
| toolInput, | ||||||||||
| toolResult, | ||||||||||
| onError, | ||||||||||
| } = props; | ||||||||||
|
|
||||||||||
| const [iframeReady, setIframeReady] = useState(false); | ||||||||||
| const [bridgeConnected, setBridgeConnected] = useState(false); | ||||||||||
| const [error, setError] = useState<Error | null>(null); | ||||||||||
| const containerRef = useRef<HTMLDivElement | null>(null); | ||||||||||
| const iframeRef = useRef<HTMLIFrameElement | null>(null); | ||||||||||
|
|
||||||||||
| // Refs for callbacks to avoid effect re-runs | ||||||||||
| const onSizeChangedRef = useRef(onSizeChanged); | ||||||||||
| const onLoggingMessageRef = useRef(onLoggingMessage); | ||||||||||
| const onInitializedRef = useRef(onInitialized); | ||||||||||
| const onErrorRef = useRef(onError); | ||||||||||
|
|
||||||||||
| useEffect(() => { | ||||||||||
| onSizeChangedRef.current = onSizeChanged; | ||||||||||
| onLoggingMessageRef.current = onLoggingMessage; | ||||||||||
| onInitializedRef.current = onInitialized; | ||||||||||
| onErrorRef.current = onError; | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| // Effect 1: Set up sandbox iframe and connect AppBridge | ||||||||||
| useEffect(() => { | ||||||||||
| let mounted = true; | ||||||||||
|
|
||||||||||
| const setup = async () => { | ||||||||||
| try { | ||||||||||
| const { iframe, onReady } = await setupSandboxProxyIframe(sandbox.url); | ||||||||||
|
|
||||||||||
| if (!mounted) return; | ||||||||||
|
|
||||||||||
| iframeRef.current = iframe; | ||||||||||
| if (containerRef.current) { | ||||||||||
| containerRef.current.appendChild(iframe); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| await onReady; | ||||||||||
|
|
||||||||||
| if (!mounted) return; | ||||||||||
|
|
||||||||||
| // Register size change handler | ||||||||||
| appBridge.onsizechange = async (params) => { | ||||||||||
| onSizeChangedRef.current?.(params); | ||||||||||
| // Also update iframe size | ||||||||||
| if (iframeRef.current) { | ||||||||||
| if (params.width !== undefined) { | ||||||||||
| iframeRef.current.style.width = `${params.width}px`; | ||||||||||
| } | ||||||||||
| if (params.height !== undefined) { | ||||||||||
| iframeRef.current.style.height = `${params.height}px`; | ||||||||||
| } | ||||||||||
| } | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| // Hook into initialization | ||||||||||
| appBridge.oninitialized = () => { | ||||||||||
| if (!mounted) return; | ||||||||||
| console.log('[AppFrame] App initialized'); | ||||||||||
|
||||||||||
| console.log('[AppFrame] App initialized'); | |
| if (process.env.NODE_ENV !== 'production') { | |
| console.log('[AppFrame] App initialized'); | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| if (iframeRef.current && containerRef.current?.contains(iframeRef.current)) { | |
| containerRef.current.removeChild(iframeRef.current); | |
| } |
@ochafik found a lifecycle problem. This code destroys the iframe when the component unmounts. but then the mounting process doesn't happen again (create iframe setHtml). I don't think that's what we want anyway?
The iframe should preserve the state when rerendering instead of destroying and recreating the iframe?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @infoxicator , please review 73b7696 (cc @ochafik)
Copilot
AI
Dec 16, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Console.log statements are left in production code. These should either be removed, converted to a proper logging mechanism (with configurable log levels), or wrapped in a debug flag.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The dependency
@modelcontextprotocol/ext-appsis specified using a GitHub URL reference. This is not a stable versioning strategy and can lead to unpredictable behavior. Consider using a specific commit hash, tag, or published npm version for reproducible builds.