diff --git a/docs/src/guide/mcp-apps.md b/docs/src/guide/mcp-apps.md index 17700e9b..ade39f71 100644 --- a/docs/src/guide/mcp-apps.md +++ b/docs/src/guide/mcp-apps.md @@ -440,6 +440,113 @@ The adapter logs debug information to the browser console. Look for messages pre [MCP Apps Adapter] Intercepted MCP-UI message: prompt ``` +## Host-Side Rendering (Client SDK) + +The `@mcp-ui/client` package provides React components for rendering MCP Apps tool UIs in your host application. + +### AppRenderer Component + +`AppRenderer` is the high-level component that handles the complete lifecycle of rendering an MCP tool's UI: + +```tsx +import { AppRenderer, type AppRendererHandle } from '@mcp-ui/client'; + +function ToolUI({ client, toolName, toolInput, toolResult }) { + const appRef = useRef(null); + + return ( + window.open(url)} + onMessage={async (params) => { + console.log('Message from tool UI:', params); + return { isError: false }; + }} + onError={(error) => console.error('Tool UI error:', error)} + /> + ); +} +``` + +**Key Props:** +- `client` - Optional MCP client for automatic resource fetching and MCP request forwarding +- `toolName` - Name of the tool to render UI for +- `sandbox` - Sandbox configuration with the sandbox proxy URL +- `html` - Optional pre-fetched HTML (skips resource fetching) +- `toolResourceUri` - Optional pre-fetched resource URI +- `toolInput` / `toolResult` - Tool arguments and results to pass to the UI +- `hostContext` - Theme, locale, viewport info for the guest UI +- `onOpenLink` / `onMessage` / `onLoggingMessage` - Handlers for guest UI requests + +**Ref Methods:** +- `sendToolListChanged()` - Notify guest when tools change +- `sendResourceListChanged()` - Notify guest when resources change +- `sendPromptListChanged()` - Notify guest when prompts change +- `teardownResource()` - Clean up before unmounting + +### Using Without an MCP Client + +You can use `AppRenderer` without a full MCP client by providing custom handlers: + +```tsx + { + // Proxy to your MCP client in a different context + return myMcpProxy.readResource({ uri }); + }} + onCallTool={async (params) => { + return myMcpProxy.callTool(params); + }} +/> +``` + +Or provide pre-fetched HTML directly: + +```tsx + +``` + +### AppFrame Component + +`AppFrame` is the lower-level component for when you already have the HTML content and an `AppBridge` instance: + +```tsx +import { AppFrame, AppBridge } from '@mcp-ui/client'; + +function LowLevelToolUI({ html, client }) { + const bridge = useMemo(() => new AppBridge(client, hostInfo, capabilities), [client]); + + return ( + console.log('Size changed:', size)} + /> + ); +} +``` + +### Sandbox Proxy + +Both components require a sandbox proxy HTML file to be served. This provides security isolation for the guest UI. The sandbox proxy URL should point to a page that loads the MCP Apps sandbox proxy script. + ## Related Resources - [MCP Apps SEP Specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) diff --git a/examples/external-url-demo/tsconfig.app.json b/examples/external-url-demo/tsconfig.app.json index 0a68247e..3828564b 100644 --- a/examples/external-url-demo/tsconfig.app.json +++ b/examples/external-url-demo/tsconfig.app.json @@ -21,5 +21,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["node_modules", "dist"] } diff --git a/examples/mcp-apps-demo/package.json b/examples/mcp-apps-demo/package.json index 34150682..58b7db32 100644 --- a/examples/mcp-apps-demo/package.json +++ b/examples/mcp-apps-demo/package.json @@ -9,7 +9,7 @@ "dependencies": { "@mcp-ui/server": "workspace:*", "@modelcontextprotocol/ext-apps": "^0.2.2", - "@modelcontextprotocol/sdk": "^1.22.0", + "@modelcontextprotocol/sdk": "^1.25.1", "cors": "^2.8.5", "express": "^4.18.2", "zod": "^3.22.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bbd108f..5d4e70d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,10 +188,10 @@ importers: version: link:../../sdks/typescript/server '@modelcontextprotocol/ext-apps': specifier: ^0.2.2 - version: 0.2.2(@modelcontextprotocol/sdk@1.23.0(zod@3.25.67))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(zod@3.25.67) + version: 0.2.2(@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.67))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(zod@3.25.67) '@modelcontextprotocol/sdk': - specifier: ^1.22.0 - version: 1.23.0(zod@3.25.67) + specifier: ^1.25.1 + version: 1.25.1(hono@4.11.1)(zod@3.25.67) cors: specifier: ^2.8.5 version: 2.8.5 @@ -428,9 +428,12 @@ importers: sdks/typescript/client: dependencies: + '@modelcontextprotocol/ext-apps': + specifier: https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/ext-apps@ff79336 + version: https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/ext-apps@ff79336(@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.67))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.25.67) '@modelcontextprotocol/sdk': - specifier: ^1.22.0 - version: 1.22.0 + specifier: ^1.24.0 + version: 1.25.1(hono@4.11.1)(zod@3.25.67) '@quilted/threads': specifier: ^3.1.3 version: 3.1.3(@preact/signals-core@1.10.0) @@ -443,6 +446,9 @@ importers: '@remote-dom/react': specifier: ^1.2.2 version: 1.2.2(@preact/signals-core@1.10.0)(react@18.3.1) + zod: + specifier: ^3.23.8 + version: 3.25.67 devDependencies: '@testing-library/jest-dom': specifier: ^6.0.0 @@ -2095,21 +2101,25 @@ packages: react-dom: optional: true - '@modelcontextprotocol/sdk@1.22.0': - resolution: {integrity: sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==} - engines: {node: '>=18'} + '@modelcontextprotocol/ext-apps@https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/ext-apps@ff79336': + resolution: {tarball: https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/ext-apps@ff79336} + version: 0.2.2 peerDependencies: - '@cfworker/json-schema': ^4.1.1 + '@modelcontextprotocol/sdk': ^1.24.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + zod: ^3.25.0 || ^4.0.0 peerDependenciesMeta: - '@cfworker/json-schema': + react: + optional: true + react-dom: optional: true - '@modelcontextprotocol/sdk@1.23.0': - resolution: {integrity: sha512-MCGd4K9aZKvuSqdoBkdMvZNcYXCkZRYVs/Gh92mdV5IHbctX9H9uIvd4X93+9g8tBbXv08sxc/QHXTzf8y65bA==} + '@modelcontextprotocol/sdk@1.22.0': + resolution: {integrity: sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 peerDependenciesMeta: '@cfworker/json-schema': optional: true @@ -10458,9 +10468,10 @@ snapshots: '@mcp-ui/server@5.2.0(zod@3.25.67)': dependencies: - '@modelcontextprotocol/sdk': 1.23.0(zod@3.25.67) + '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.1)(zod@3.25.67) transitivePeerDependencies: - '@cfworker/json-schema' + - hono - supports-color - zod @@ -10580,9 +10591,9 @@ snapshots: '@mjackson/node-fetch-server@0.6.1': {} - '@modelcontextprotocol/ext-apps@0.2.2(@modelcontextprotocol/sdk@1.23.0(zod@3.25.67))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(zod@3.25.67)': + '@modelcontextprotocol/ext-apps@0.2.2(@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.67))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(zod@3.25.67)': dependencies: - '@modelcontextprotocol/sdk': 1.23.0(zod@3.25.67) + '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.1)(zod@3.25.67) prettier: 3.7.4 zod: 3.25.67 optionalDependencies: @@ -10605,10 +10616,9 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - '@modelcontextprotocol/ext-apps@0.2.2(@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.67))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(zod@3.25.67)': + '@modelcontextprotocol/ext-apps@https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/ext-apps@ff79336(@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.67))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.25.67)': dependencies: '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.1)(zod@3.25.67) - prettier: 3.7.4 zod: 3.25.67 optionalDependencies: '@oven/bun-darwin-aarch64': 1.3.3 @@ -10627,8 +10637,8 @@ snapshots: '@rollup/rollup-linux-arm64-gnu': 4.53.3 '@rollup/rollup-linux-x64-gnu': 4.53.3 '@rollup/rollup-win32-x64-msvc': 4.53.3 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) '@modelcontextprotocol/sdk@1.22.0': dependencies: @@ -10648,24 +10658,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.23.0(zod@3.25.67)': - dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - content-type: 1.0.5 - cors: 2.8.5 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.1.0 - express-rate-limit: 7.5.1(express@5.1.0) - pkce-challenge: 5.0.0 - raw-body: 3.0.2 - zod: 3.25.67 - zod-to-json-schema: 3.25.0(zod@3.25.67) - transitivePeerDependencies: - - supports-color - '@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.67)': dependencies: '@hono/node-server': 1.19.7(hono@4.11.1) @@ -12837,7 +12829,7 @@ snapshots: agents@0.0.80(@cloudflare/workers-types@4.20250620.0)(react@19.1.0): dependencies: - '@modelcontextprotocol/sdk': 1.23.0(zod@3.25.67) + '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.1)(zod@3.25.67) ai: 4.3.16(react@19.1.0)(zod@3.25.67) cron-schedule: 5.0.4 nanoid: 5.1.5 @@ -12848,6 +12840,7 @@ snapshots: transitivePeerDependencies: - '@cfworker/json-schema' - '@cloudflare/workers-types' + - hono - supports-color aggregate-error@3.1.0: diff --git a/sdks/typescript/client/package.json b/sdks/typescript/client/package.json index bfb86c26..31527b40 100644 --- a/sdks/typescript/client/package.json +++ b/sdks/typescript/client/package.json @@ -19,11 +19,13 @@ "dist" ], "dependencies": { - "@modelcontextprotocol/sdk": "^1.22.0", + "@modelcontextprotocol/ext-apps": "https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/ext-apps@ff79336", + "@modelcontextprotocol/sdk": "^1.24.0", "@quilted/threads": "^3.1.3", "@r2wc/react-to-web-component": "^2.0.4", "@remote-dom/core": "^1.8.0", - "@remote-dom/react": "^1.2.2" + "@remote-dom/react": "^1.2.2", + "zod": "^3.23.8" }, "devDependencies": { "@testing-library/jest-dom": "^6.0.0", diff --git a/sdks/typescript/client/src/components/AppFrame.tsx b/sdks/typescript/client/src/components/AppFrame.tsx new file mode 100644 index 00000000..20506544 --- /dev/null +++ b/sdks/typescript/client/src/components/AppFrame.tsx @@ -0,0 +1,287 @@ +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; + + /** 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 ... + * + * 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(null); + const containerRef = useRef(null); + const iframeRef = useRef(null); + // Track the current sandbox URL to detect when it changes + const currentSandboxUrlRef = useRef(null); + // Track the current appBridge to detect when it changes (for isolation) + const currentAppBridgeRef = useRef(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(() => { + const sandboxUrlString = sandbox.url.href; + + // If we already have an iframe set up for this sandbox URL AND the same appBridge, skip setup + // This preserves the iframe state across React re-renders (including StrictMode) + // but ensures isolation when switching to a different app/resource (different appBridge) + if ( + currentSandboxUrlRef.current === sandboxUrlString && + currentAppBridgeRef.current === appBridge && + iframeRef.current + ) { + return; + } + + // Reset state when setting up a new iframe/bridge to ensure isolation + // between different apps/resources + setIframeReady(false); + setBridgeConnected(false); + setError(null); + + let mounted = true; + + const setup = async () => { + try { + // If switching to a different sandbox URL or appBridge, clean up the old iframe first + if (iframeRef.current && containerRef.current?.contains(iframeRef.current)) { + containerRef.current.removeChild(iframeRef.current); + iframeRef.current = null; + currentSandboxUrlRef.current = null; + currentAppBridgeRef.current = null; + } + + const { iframe, onReady } = await setupSandboxProxyIframe(sandbox.url); + + if (!mounted) return; + + iframeRef.current = iframe; + currentSandboxUrlRef.current = sandboxUrlString; + currentAppBridgeRef.current = appBridge; + 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'); + setIframeReady(true); + onInitializedRef.current?.({ + appVersion: appBridge.getAppVersion(), + appCapabilities: appBridge.getAppCapabilities(), + }); + }; + + // Register logging handler + appBridge.onloggingmessage = (params) => { + onLoggingMessageRef.current?.(params); + }; + + // Connect the bridge + await appBridge.connect( + new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!), + ); + + if (!mounted) return; + + setBridgeConnected(true); + } catch (err) { + console.error('[AppFrame] Error:', err); + if (!mounted) return; + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + onErrorRef.current?.(error); + } + }; + + setup(); + + return () => { + mounted = false; + }; + }, [sandbox.url, appBridge]); + + // Effect 2: Send HTML to sandbox when bridge is connected + useEffect(() => { + // Ensure we only send HTML to the correct appBridge that's currently connected + // This prevents race conditions when switching between apps + if (!bridgeConnected || !html || currentAppBridgeRef.current !== appBridge) return; + + const sendHtml = async () => { + try { + console.log('[AppFrame] Sending HTML to sandbox'); + await appBridge.sendSandboxResourceReady({ + html, + csp: sandbox.csp, + }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + onErrorRef.current?.(error); + } + }; + + sendHtml(); + }, [bridgeConnected, html, appBridge, sandbox.csp]); + + // Effect 3: Send tool input when ready + useEffect(() => { + // Ensure we only send to the correct appBridge that's currently connected + if (bridgeConnected && iframeReady && toolInput && currentAppBridgeRef.current === appBridge) { + console.log('[AppFrame] Sending tool input:', toolInput); + appBridge.sendToolInput({ arguments: toolInput }); + } + }, [appBridge, bridgeConnected, iframeReady, toolInput]); + + // Effect 4: Send tool result when ready + useEffect(() => { + // Ensure we only send to the correct appBridge that's currently connected + if (bridgeConnected && iframeReady && toolResult && currentAppBridgeRef.current === appBridge) { + console.log('[AppFrame] Sending tool result:', toolResult); + appBridge.sendToolResult(toolResult); + } + }, [appBridge, bridgeConnected, iframeReady, toolResult]); + + return ( +
+ {error &&
Error: {error.message}
} +
+ ); +}; diff --git a/sdks/typescript/client/src/components/AppRenderer.tsx b/sdks/typescript/client/src/components/AppRenderer.tsx new file mode 100644 index 00000000..e1eb6cca --- /dev/null +++ b/sdks/typescript/client/src/components/AppRenderer.tsx @@ -0,0 +1,515 @@ +import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react'; + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { + type CallToolRequest, + type CallToolResult, + type ListPromptsRequest, + type ListPromptsResult, + type ListResourcesRequest, + type ListResourcesResult, + type ListResourceTemplatesRequest, + type ListResourceTemplatesResult, + type LoggingMessageNotification, + type ReadResourceRequest, + type ReadResourceResult, + McpError, + ErrorCode, +} from '@modelcontextprotocol/sdk/types.js'; + +import { + AppBridge, + RESOURCE_MIME_TYPE, + type McpUiMessageRequest, + type McpUiMessageResult, + type McpUiOpenLinkRequest, + type McpUiOpenLinkResult, + type McpUiSizeChangedNotification, + type McpUiToolInputPartialNotification, + type McpUiHostContext, +} from '@modelcontextprotocol/ext-apps/app-bridge'; + +import { AppFrame, type SandboxConfig } from './AppFrame'; +import { getToolUiResourceUri, readToolUiResourceHtml } from '../utils/app-host-utils'; + +/** + * Extra metadata passed to request handlers (from AppBridge). + */ +export type RequestHandlerExtra = Parameters[1]>[1]; + +/** + * Handle to access AppRenderer methods for sending notifications to the Guest UI. + * Obtained via ref on AppRenderer. + */ +export interface AppRendererHandle { + /** Notify the Guest UI that the server's tool list has changed */ + sendToolListChanged: () => void; + /** Notify the Guest UI that the server's resource list has changed */ + sendResourceListChanged: () => void; + /** Notify the Guest UI that the server's prompt list has changed */ + sendPromptListChanged: () => void; + /** Notify the Guest UI that the resource is being torn down / cleaned up */ + teardownResource: () => void; +} + +/** + * Props for the AppRenderer component. + */ +export interface AppRendererProps { + /** MCP client connected to the server providing the tool. Omit to disable automatic MCP forwarding and use custom handlers instead. */ + client?: Client; + + /** Name of the MCP tool to render UI for */ + toolName: string; + + /** Sandbox configuration */ + sandbox: SandboxConfig; + + /** Optional pre-fetched resource URI. If not provided, will be fetched via getToolUiResourceUri() */ + toolResourceUri?: string; + + /** Optional pre-fetched HTML. If provided, skips all resource fetching */ + html?: string; + + /** Optional input arguments to pass to the tool UI once it's ready */ + toolInput?: Record; + + /** Optional result from tool execution to pass to the tool UI once it's ready */ + toolResult?: CallToolResult; + + /** Partial/streaming tool input to send to the guest UI */ + toolInputPartial?: McpUiToolInputPartialNotification['params']; + + /** Set to true to notify the guest UI that the tool execution was cancelled */ + toolCancelled?: boolean; + + /** Host context (theme, viewport, locale, etc.) to pass to the guest UI */ + hostContext?: McpUiHostContext; + + /** Handler for open-link requests from the guest UI */ + onOpenLink?: ( + params: McpUiOpenLinkRequest['params'], + extra: RequestHandlerExtra, + ) => Promise; + + /** Handler for message requests from the guest UI */ + onMessage?: ( + params: McpUiMessageRequest['params'], + extra: RequestHandlerExtra, + ) => Promise; + + /** Handler for logging messages from the guest UI */ + onLoggingMessage?: (params: LoggingMessageNotification['params']) => void; + + /** Handler for size change notifications from the guest UI */ + onSizeChanged?: (params: McpUiSizeChangedNotification['params']) => void; + + /** Callback invoked when an error occurs during setup or message handling */ + onError?: (error: Error) => void; + + // --- MCP Request Handlers (override automatic forwarding) --- + + /** + * Handler for tools/call requests from the guest UI. + * If provided, overrides the automatic forwarding to the MCP client. + */ + onCallTool?: ( + params: CallToolRequest['params'], + extra: RequestHandlerExtra, + ) => Promise; + + /** + * Handler for resources/list requests from the guest UI. + * If provided, overrides the automatic forwarding to the MCP client. + */ + onListResources?: ( + params: ListResourcesRequest['params'], + extra: RequestHandlerExtra, + ) => Promise; + + /** + * Handler for resources/templates/list requests from the guest UI. + * If provided, overrides the automatic forwarding to the MCP client. + */ + onListResourceTemplates?: ( + params: ListResourceTemplatesRequest['params'], + extra: RequestHandlerExtra, + ) => Promise; + + /** + * Handler for resources/read requests from the guest UI. + * If provided, overrides the automatic forwarding to the MCP client. + */ + onReadResource?: ( + params: ReadResourceRequest['params'], + extra: RequestHandlerExtra, + ) => Promise; + + /** + * Handler for prompts/list requests from the guest UI. + * If provided, overrides the automatic forwarding to the MCP client. + */ + onListPrompts?: ( + params: ListPromptsRequest['params'], + extra: RequestHandlerExtra, + ) => Promise; +} + +/** + * React component that renders an MCP tool's custom UI in a sandboxed iframe. + * + * This component manages the complete lifecycle of an MCP-UI tool: + * 1. Creates AppBridge for MCP communication + * 2. Fetches the tool's UI resource (HTML) if not provided + * 3. Delegates rendering to AppFrame + * 4. Handles UI actions (intents, link opening, prompts, notifications) + * + * For lower-level control or when you already have the HTML content, + * use the AppFrame component directly. + * + * @example Basic usage + * ```tsx + * window.open(url)} + * onError={(error) => console.error('UI Error:', error)} + * /> + * ``` + * + * @example With pre-fetched HTML (skips resource fetching) + * ```tsx + * + * ``` + * + * @example Using ref to access AppBridge methods + * ```tsx + * const appRef = useRef(null); + * + * // Notify guest UI when tools change + * useEffect(() => { + * appRef.current?.sendToolListChanged(); + * }, [toolsVersion]); + * + * + * ``` + * + * @example With custom MCP request handlers (no client) + * ```tsx + * { + * // Proxy to your MCP client (e.g., in a different context) + * return myMcpProxy.readResource({ uri }); + * }} + * onCallTool={async (params) => { + * // Custom tool call handling with caching/filtering + * return myCustomToolCall(params); + * }} + * onListResources={async () => { + * // Aggregate resources from multiple servers + * return { resources: [...server1Resources, ...server2Resources] }; + * }} + * /> + * ``` + */ +export const AppRenderer = forwardRef((props, ref) => { + const { + client, + toolName, + sandbox, + toolResourceUri, + html: htmlProp, + toolInput, + toolResult, + toolInputPartial, + toolCancelled, + hostContext, + onMessage, + onOpenLink, + onLoggingMessage, + onSizeChanged, + onError, + onCallTool, + onListResources, + onListResourceTemplates, + onReadResource, + onListPrompts, + } = props; + + // State + const [appBridge, setAppBridge] = useState(null); + const [html, setHtml] = useState(htmlProp ?? null); + const [error, setError] = useState(null); + + // Refs for callbacks + const onMessageRef = useRef(onMessage); + const onOpenLinkRef = useRef(onOpenLink); + const onLoggingMessageRef = useRef(onLoggingMessage); + const onSizeChangedRef = useRef(onSizeChanged); + const onErrorRef = useRef(onError); + const onCallToolRef = useRef(onCallTool); + const onListResourcesRef = useRef(onListResources); + const onListResourceTemplatesRef = useRef(onListResourceTemplates); + const onReadResourceRef = useRef(onReadResource); + const onListPromptsRef = useRef(onListPrompts); + + useEffect(() => { + onMessageRef.current = onMessage; + onOpenLinkRef.current = onOpenLink; + onLoggingMessageRef.current = onLoggingMessage; + onSizeChangedRef.current = onSizeChanged; + onErrorRef.current = onError; + onCallToolRef.current = onCallTool; + onListResourcesRef.current = onListResources; + onListResourceTemplatesRef.current = onListResourceTemplates; + onReadResourceRef.current = onReadResource; + onListPromptsRef.current = onListPrompts; + }); + + // Expose send methods via ref for Host → Guest notifications + useImperativeHandle( + ref, + () => ({ + sendToolListChanged: () => appBridge?.sendToolListChanged(), + sendResourceListChanged: () => appBridge?.sendResourceListChanged(), + sendPromptListChanged: () => appBridge?.sendPromptListChanged(), + teardownResource: () => appBridge?.teardownResource({}), + }), + [appBridge], + ); + + // Effect 1: Create and configure AppBridge + useEffect(() => { + let mounted = true; + + const createBridge = () => { + try { + const serverCapabilities = client?.getServerCapabilities(); + const bridge = new AppBridge( + client ?? null, + { + name: 'MCP-UI Host', + version: '1.0.0', + }, + { + openLinks: {}, + serverTools: serverCapabilities?.tools, + serverResources: serverCapabilities?.resources, + }, + ); + + // Register message handler + bridge.onmessage = async (params, extra) => { + if (onMessageRef.current) { + return onMessageRef.current(params, extra); + } else { + throw new McpError(ErrorCode.MethodNotFound, 'Method not found'); + } + }; + + // Register open-link handler + bridge.onopenlink = async (params, extra) => { + if (onOpenLinkRef.current) { + return onOpenLinkRef.current(params, extra); + } else { + throw new McpError(ErrorCode.MethodNotFound, 'Method not found'); + } + }; + + // Register logging handler + bridge.onloggingmessage = (params) => { + if (onLoggingMessageRef.current) { + onLoggingMessageRef.current(params); + } + }; + + // Register custom MCP request handlers (these override automatic forwarding) + if (onCallToolRef.current) { + bridge.oncalltool = (params, extra) => onCallToolRef.current!(params, extra); + } + if (onListResourcesRef.current) { + bridge.onlistresources = (params, extra) => onListResourcesRef.current!(params, extra); + } + if (onListResourceTemplatesRef.current) { + bridge.onlistresourcetemplates = (params, extra) => + onListResourceTemplatesRef.current!(params, extra); + } + if (onReadResourceRef.current) { + bridge.onreadresource = (params, extra) => onReadResourceRef.current!(params, extra); + } + if (onListPromptsRef.current) { + bridge.onlistprompts = (params, extra) => onListPromptsRef.current!(params, extra); + } + + if (!mounted) return; + setAppBridge(bridge); + } catch (err) { + console.error('[AppRenderer] Error creating bridge:', err); + if (!mounted) return; + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + onErrorRef.current?.(error); + } + }; + + createBridge(); + + return () => { + mounted = false; + }; + }, [client]); + + // Effect 2: Fetch HTML if not provided + useEffect(() => { + if (htmlProp) { + setHtml(htmlProp); + return; + } + + // Determine if we can fetch HTML + const canFetchWithClient = !!client; + const canFetchWithCallback = !!toolResourceUri && !!onReadResourceRef.current; + + if (!canFetchWithClient && !canFetchWithCallback) { + setError( + new Error( + "Either 'html' prop, 'client', or ('toolResourceUri' + 'onReadResource') must be provided to fetch UI resource", + ), + ); + return; + } + + let mounted = true; + + const fetchHtml = async () => { + try { + // Get resource URI + let uri: string; + if (toolResourceUri) { + uri = toolResourceUri; + console.log(`[AppRenderer] Using provided resource URI: ${uri}`); + } else if (client) { + console.log(`[AppRenderer] Fetching resource URI for tool: ${toolName}`); + const info = await getToolUiResourceUri(client, toolName); + if (!info) { + throw new Error( + `Tool ${toolName} has no UI resource (no ui/resourceUri in tool._meta)`, + ); + } + uri = info.uri; + console.log(`[AppRenderer] Got resource URI: ${uri}`); + } else { + throw new Error('Cannot determine resource URI without client or toolResourceUri'); + } + + if (!mounted) return; + + // Read HTML content - use client if available, otherwise use onReadResource callback + console.log(`[AppRenderer] Reading resource HTML from: ${uri}`); + let htmlContent: string; + + if (client) { + htmlContent = await readToolUiResourceHtml(client, { uri }); + } else if (onReadResourceRef.current) { + // Use the onReadResource callback to fetch the HTML + const result = await onReadResourceRef.current({ uri }, {} as RequestHandlerExtra); + if (!result.contents || result.contents.length !== 1) { + throw new Error('Unsupported UI resource content length: ' + result.contents?.length); + } + const content = result.contents[0]; + const isHtml = (t?: string) => t === RESOURCE_MIME_TYPE; + + if ('text' in content && typeof content.text === 'string' && isHtml(content.mimeType)) { + htmlContent = content.text; + } else if ( + 'blob' in content && + typeof content.blob === 'string' && + isHtml(content.mimeType) + ) { + htmlContent = atob(content.blob); + } else { + throw new Error('Unsupported UI resource content format: ' + JSON.stringify(content)); + } + } else { + throw new Error('No way to read resource HTML'); + } + + if (!mounted) return; + + setHtml(htmlContent); + } catch (err) { + if (!mounted) return; + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + onErrorRef.current?.(error); + } + }; + + fetchHtml(); + + return () => { + mounted = false; + }; + }, [client, toolName, toolResourceUri, htmlProp]); + + // Effect 3: Sync host context when it changes + useEffect(() => { + if (appBridge && hostContext) { + appBridge.setHostContext(hostContext); + } + }, [appBridge, hostContext]); + + // Effect 4: Send partial tool input when it changes + useEffect(() => { + if (appBridge && toolInputPartial) { + appBridge.sendToolInputPartial(toolInputPartial); + } + }, [appBridge, toolInputPartial]); + + // Effect 5: Send tool cancelled notification when flag is set + useEffect(() => { + if (appBridge && toolCancelled) { + appBridge.sendToolCancelled({}); + } + }, [appBridge, toolCancelled]); + + // Handle size change callback + const handleSizeChanged = onSizeChangedRef.current; + + // Render error state + if (error) { + return
Error: {error.message}
; + } + + // Render loading state + if (!appBridge || !html) { + return null; + } + + // Render AppFrame with the fetched HTML and configured bridge + return ( + + ); +}); + +AppRenderer.displayName = 'AppRenderer'; diff --git a/sdks/typescript/client/src/components/__tests__/AppFrame.test.tsx b/sdks/typescript/client/src/components/__tests__/AppFrame.test.tsx new file mode 100644 index 00000000..69cb3dd2 --- /dev/null +++ b/sdks/typescript/client/src/components/__tests__/AppFrame.test.tsx @@ -0,0 +1,428 @@ +import { render, screen, waitFor, act } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import '@testing-library/jest-dom'; + +import { AppFrame, type AppFrameProps } from '../AppFrame'; +import * as appHostUtils from '../../utils/app-host-utils'; +import type { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge'; + +// Mock the ext-apps module +vi.mock('@modelcontextprotocol/ext-apps/app-bridge', () => { + // Create a mock constructor for PostMessageTransport + const MockPostMessageTransport = vi.fn().mockImplementation(function(this: unknown) { + return this; + }); + + return { + AppBridge: vi.fn(), + PostMessageTransport: MockPostMessageTransport, + }; +}); + +// Track registered handlers +let registeredOninitialized: (() => void) | null = null; +let registeredOnsizechange: ((params: { width?: number; height?: number }) => void) | null = null; +let registeredOnloggingmessage: ((params: object) => void) | null = null; + +// Mock AppBridge factory +const createMockAppBridge = () => { + const bridge = { + connect: vi.fn().mockResolvedValue(undefined), + sendSandboxResourceReady: vi.fn().mockResolvedValue(undefined), + sendToolInput: vi.fn(), + sendToolResult: vi.fn(), + getAppVersion: vi.fn().mockReturnValue({ name: 'TestApp', version: '1.0.0' }), + getAppCapabilities: vi.fn().mockReturnValue({ tools: {} }), + _oninitialized: null as (() => void) | null, + _onsizechange: null as ((params: { width?: number; height?: number }) => void) | null, + _onloggingmessage: null as ((params: object) => void) | null, + }; + + Object.defineProperty(bridge, 'oninitialized', { + set: (fn) => { + bridge._oninitialized = fn; + registeredOninitialized = fn; + }, + get: () => bridge._oninitialized, + }); + Object.defineProperty(bridge, 'onsizechange', { + set: (fn) => { + bridge._onsizechange = fn; + registeredOnsizechange = fn; + }, + get: () => bridge._onsizechange, + }); + Object.defineProperty(bridge, 'onloggingmessage', { + set: (fn) => { + bridge._onloggingmessage = fn; + registeredOnloggingmessage = fn; + }, + get: () => bridge._onloggingmessage, + }); + + return bridge; +}; + +// Mock the app-host-utils module +vi.mock('../../utils/app-host-utils', () => ({ + setupSandboxProxyIframe: vi.fn(), +})); + +describe('', () => { + let mockIframe: Partial; + let mockContentWindow: { postMessage: ReturnType }; + let onReadyResolve: () => void; + let mockAppBridge: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + registeredOninitialized = null; + registeredOnsizechange = null; + registeredOnloggingmessage = null; + mockAppBridge = createMockAppBridge(); + + // Create mock contentWindow + mockContentWindow = { + postMessage: vi.fn(), + }; + + // Create a real iframe element and mock contentWindow via defineProperty + const realIframe = document.createElement('iframe'); + Object.defineProperty(realIframe, 'contentWindow', { + get: () => mockContentWindow as unknown as Window, + configurable: true, + }); + mockIframe = realIframe; + + // Setup mock for setupSandboxProxyIframe + const onReadyPromise = new Promise((resolve) => { + onReadyResolve = resolve; + }); + + vi.mocked(appHostUtils.setupSandboxProxyIframe).mockResolvedValue({ + iframe: mockIframe as HTMLIFrameElement, + onReady: onReadyPromise, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const defaultProps: Omit = { + html: 'Test', + sandbox: { url: new URL('http://localhost:8081/sandbox.html') }, + }; + + const getPropsWithBridge = (overrides: Partial = {}): AppFrameProps => ({ + ...defaultProps, + appBridge: mockAppBridge as unknown as AppBridge, + ...overrides, + }); + + it('should render without crashing', () => { + render(); + expect(document.querySelector('div')).toBeInTheDocument(); + }); + + it('should call setupSandboxProxyIframe with sandbox URL', async () => { + render(); + + await waitFor(() => { + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenCalledWith(defaultProps.sandbox.url); + }); + }); + + it('should connect AppBridge when provided', async () => { + render(); + + await act(() => { + onReadyResolve(); + }); + + await waitFor(() => { + expect(mockAppBridge.connect).toHaveBeenCalled(); + }); + }); + + it('should send HTML via AppBridge.sendSandboxResourceReady', async () => { + render(); + + await act(() => { + onReadyResolve(); + }); + + // Trigger initialization + await act(() => { + registeredOninitialized?.(); + }); + + await waitFor(() => { + expect(mockAppBridge.sendSandboxResourceReady).toHaveBeenCalledWith({ + html: defaultProps.html, + csp: undefined, + }); + }); + }); + + it('should call onInitialized with app info when app initializes', async () => { + const onInitialized = vi.fn(); + + render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + await waitFor(() => { + expect(onInitialized).toHaveBeenCalledWith({ + appVersion: { name: 'TestApp', version: '1.0.0' }, + appCapabilities: { tools: {} }, + }); + }); + }); + + it('should send tool input after initialization', async () => { + const toolInput = { foo: 'bar' }; + + render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + await waitFor(() => { + expect(mockAppBridge.sendToolInput).toHaveBeenCalledWith({ + arguments: toolInput, + }); + }); + }); + + it('should send tool result after initialization', async () => { + const toolResult = { content: [{ type: 'text' as const, text: 'result' }] }; + + render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + await waitFor(() => { + expect(mockAppBridge.sendToolResult).toHaveBeenCalledWith(toolResult); + }); + }); + + it('should call onSizeChanged when size changes', async () => { + const onSizeChanged = vi.fn(); + + render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOnsizechange?.({ width: 800, height: 600 }); + }); + + expect(onSizeChanged).toHaveBeenCalledWith({ width: 800, height: 600 }); + }); + + it('should call onLoggingMessage when logging message received', async () => { + const onLoggingMessage = vi.fn(); + + render(); + + await act(() => { + onReadyResolve(); + }); + + const logParams = { level: 'info', data: 'test message' }; + await act(() => { + registeredOnloggingmessage?.(logParams); + }); + + expect(onLoggingMessage).toHaveBeenCalledWith(logParams); + }); + + it('should forward CSP to sandbox', async () => { + const csp = { + connectDomains: ['api.example.com'], + resourceDomains: ['cdn.example.com'], + }; + + render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + await waitFor(() => { + expect(mockAppBridge.sendSandboxResourceReady).toHaveBeenCalledWith({ + html: defaultProps.html, + csp, + }); + }); + }); + + it('should call onError when setup fails', async () => { + const onError = vi.fn(); + const error = new Error('Setup failed'); + + vi.mocked(appHostUtils.setupSandboxProxyIframe).mockRejectedValue(error); + + render(); + + await waitFor(() => { + expect(onError).toHaveBeenCalledWith(error); + }); + }); + + it('should display error message when error occurs', async () => { + const error = new Error('Test error'); + vi.mocked(appHostUtils.setupSandboxProxyIframe).mockRejectedValue(error); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Error: Test error/)).toBeInTheDocument(); + }); + }); + + describe('lifecycle', () => { + it('should preserve iframe across re-renders', async () => { + const { rerender } = render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + // setupSandboxProxyIframe should be called once on initial mount + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenCalledTimes(1); + + // Re-render with same props (simulating React StrictMode remount or parent re-render) + rerender(); + + // Should NOT call setupSandboxProxyIframe again - iframe is preserved + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenCalledTimes(1); + }); + + it('should recreate iframe when sandbox URL changes', async () => { + const { rerender } = render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenCalledTimes(1); + + // Create new mock for second iframe + const secondOnReadyPromise = new Promise((resolve) => { + onReadyResolve = resolve; + }); + vi.mocked(appHostUtils.setupSandboxProxyIframe).mockResolvedValue({ + iframe: mockIframe as HTMLIFrameElement, + onReady: secondOnReadyPromise, + }); + + // Re-render with DIFFERENT sandbox URL + const newSandboxUrl = new URL('http://localhost:9999/different-sandbox.html'); + rerender(); + + // Should call setupSandboxProxyIframe again with new URL + await waitFor(() => { + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenCalledTimes(2); + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenLastCalledWith(newSandboxUrl); + }); + }); + + it('should update HTML content without recreating iframe', async () => { + const { rerender } = render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + // Initial HTML sent + await waitFor(() => { + expect(mockAppBridge.sendSandboxResourceReady).toHaveBeenCalledTimes(1); + expect(mockAppBridge.sendSandboxResourceReady).toHaveBeenCalledWith({ + html: defaultProps.html, + csp: undefined, + }); + }); + + // Re-render with new HTML + const newHtml = 'Updated Content'; + rerender(); + + // Should send new HTML without recreating iframe + await waitFor(() => { + expect(mockAppBridge.sendSandboxResourceReady).toHaveBeenCalledTimes(2); + expect(mockAppBridge.sendSandboxResourceReady).toHaveBeenLastCalledWith({ + html: newHtml, + csp: undefined, + }); + }); + + // Iframe should NOT be recreated + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenCalledTimes(1); + }); + + it('should update toolInput without recreating iframe', async () => { + const { rerender } = render( + , + ); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + await waitFor(() => { + expect(mockAppBridge.sendToolInput).toHaveBeenCalledWith({ arguments: { initial: true } }); + }); + + // Re-render with new toolInput + rerender(); + + await waitFor(() => { + expect(mockAppBridge.sendToolInput).toHaveBeenCalledWith({ arguments: { updated: true } }); + }); + + // Iframe should NOT be recreated + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx b/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx new file mode 100644 index 00000000..5187ee66 --- /dev/null +++ b/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx @@ -0,0 +1,543 @@ +import React from 'react'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import '@testing-library/jest-dom'; +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; + +import { AppRenderer, type AppRendererProps, type AppRendererHandle } from '../AppRenderer'; +import type { AppFrameProps } from '../AppFrame'; +import * as appHostUtils from '../../utils/app-host-utils'; + +// Mock AppFrame to capture props +const mockAppFrame = vi.fn(); +vi.mock('../AppFrame', () => ({ + AppFrame: (props: AppFrameProps) => { + mockAppFrame(props); + return ( +
+ {props.toolInput && {JSON.stringify(props.toolInput)}} + {props.toolResult && {JSON.stringify(props.toolResult)}} +
+ ); + }, +})); + +// Mock app-host-utils +vi.mock('../../utils/app-host-utils', () => ({ + getToolUiResourceUri: vi.fn(), + readToolUiResourceHtml: vi.fn(), +})); + +// Store mock bridge instance for test access +let mockBridgeInstance: Partial | null = null; + +// Mock AppBridge constructor +vi.mock('@modelcontextprotocol/ext-apps/app-bridge', () => { + return { + AppBridge: vi.fn().mockImplementation(function () { + mockBridgeInstance = { + onmessage: undefined, + onopenlink: undefined, + onloggingmessage: undefined, + oncalltool: undefined, + onlistresources: undefined, + onlistresourcetemplates: undefined, + onreadresource: undefined, + onlistprompts: undefined, + setHostContext: vi.fn(), + sendToolInputPartial: vi.fn(), + sendToolCancelled: vi.fn(), + sendToolListChanged: vi.fn(), + sendResourceListChanged: vi.fn(), + sendPromptListChanged: vi.fn(), + teardownResource: vi.fn(), + }; + return mockBridgeInstance; + }), + RESOURCE_MIME_TYPE: 'text/html', + }; +}); + +// Mock MCP Client +const mockClient = { + getServerCapabilities: vi.fn().mockReturnValue({ + tools: {}, + resources: {}, + }), +}; + +describe('', () => { + const defaultProps: AppRendererProps = { + client: mockClient as unknown as Client, + toolName: 'test-tool', + sandbox: { url: new URL('http://localhost:8081/sandbox.html') }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockBridgeInstance = null; + mockAppFrame.mockClear(); + + // Default mock implementations + vi.mocked(appHostUtils.getToolUiResourceUri).mockResolvedValue({ + uri: 'ui://test-tool', + }); + vi.mocked(appHostUtils.readToolUiResourceHtml).mockResolvedValue( + 'Test Tool UI', + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('basic rendering', () => { + it('should render AppFrame after fetching HTML', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + }); + + it('should fetch resource URI for the tool', async () => { + render(); + + await waitFor(() => { + expect(appHostUtils.getToolUiResourceUri).toHaveBeenCalledWith(mockClient, 'test-tool'); + }); + }); + + it('should read HTML from resource URI', async () => { + render(); + + await waitFor(() => { + expect(appHostUtils.readToolUiResourceHtml).toHaveBeenCalledWith(mockClient, { + uri: 'ui://test-tool', + }); + }); + }); + + it('should pass fetched HTML to AppFrame', async () => { + render(); + + await waitFor(() => { + const appFrame = screen.getByTestId('app-frame'); + expect(appFrame).toHaveAttribute('data-html', 'Test Tool UI'); + }); + }); + + it('should use provided toolResourceUri instead of fetching', async () => { + const props: AppRendererProps = { + ...defaultProps, + toolResourceUri: 'ui://custom-uri', + }; + + render(); + + await waitFor(() => { + expect(appHostUtils.getToolUiResourceUri).not.toHaveBeenCalled(); + expect(appHostUtils.readToolUiResourceHtml).toHaveBeenCalledWith(mockClient, { + uri: 'ui://custom-uri', + }); + }); + }); + + it('should use provided HTML directly without fetching', async () => { + const props: AppRendererProps = { + ...defaultProps, + html: 'Pre-fetched HTML', + }; + + render(); + + await waitFor(() => { + expect(appHostUtils.getToolUiResourceUri).not.toHaveBeenCalled(); + expect(appHostUtils.readToolUiResourceHtml).not.toHaveBeenCalled(); + expect(screen.getByTestId('app-frame')).toHaveAttribute( + 'data-html', + 'Pre-fetched HTML', + ); + }); + }); + + it('should pass sandbox config to AppFrame', async () => { + render(); + + await waitFor(() => { + const appFrame = screen.getByTestId('app-frame'); + expect(appFrame).toHaveAttribute('data-sandbox-url', 'http://localhost:8081/sandbox.html'); + }); + }); + + it('should pass toolInput to AppFrame', async () => { + const toolInput = { query: 'test query' }; + const props: AppRendererProps = { + ...defaultProps, + toolInput, + }; + + render(); + + await waitFor(() => { + const toolInputEl = screen.getByTestId('tool-input'); + expect(toolInputEl).toHaveTextContent(JSON.stringify(toolInput)); + }); + }); + + it('should pass toolResult to AppFrame', async () => { + const toolResult = { content: [{ type: 'text' as const, text: 'result' }] }; + const props: AppRendererProps = { + ...defaultProps, + toolResult, + }; + + render(); + + await waitFor(() => { + const toolResultEl = screen.getByTestId('tool-result'); + expect(toolResultEl).toHaveTextContent(JSON.stringify(toolResult)); + }); + }); + + + it('should display error when tool has no UI resource', async () => { + vi.mocked(appHostUtils.getToolUiResourceUri).mockResolvedValue(null); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Error:/)).toBeInTheDocument(); + expect(screen.getByText(/has no UI resource/)).toBeInTheDocument(); + }); + }); + + it('should call onError when resource fetch fails', async () => { + const onError = vi.fn(); + const error = new Error('Fetch failed'); + vi.mocked(appHostUtils.readToolUiResourceHtml).mockRejectedValue(error); + + render(); + + await waitFor(() => { + expect(onError).toHaveBeenCalledWith(error); + }); + }); + + it('should return null while loading', () => { + // Make the promise never resolve + vi.mocked(appHostUtils.getToolUiResourceUri).mockReturnValue(new Promise(() => {})); + + const { container } = render(); + + // Should render nothing while loading + expect(container.firstChild).toBeNull(); + }); + }); + + describe('hostContext prop', () => { + it('should call setHostContext when hostContext is provided', async () => { + const hostContext = { theme: 'dark' as const }; + + render(); + + await waitFor(() => { + expect(mockBridgeInstance?.setHostContext).toHaveBeenCalledWith(hostContext); + }); + }); + + it('should update hostContext when prop changes', async () => { + const { rerender } = render(); + + await waitFor(() => { + expect(mockBridgeInstance?.setHostContext).toHaveBeenCalledWith({ theme: 'light' }); + }); + + rerender(); + + await waitFor(() => { + expect(mockBridgeInstance?.setHostContext).toHaveBeenCalledWith({ theme: 'dark' }); + }); + }); + }); + + describe('toolInputPartial prop', () => { + it('should call sendToolInputPartial when toolInputPartial is provided', async () => { + const toolInputPartial = { arguments: { delta: 'partial data' } }; + + render(); + + await waitFor(() => { + expect(mockBridgeInstance?.sendToolInputPartial).toHaveBeenCalledWith(toolInputPartial); + }); + }); + }); + + describe('toolCancelled prop', () => { + it('should call sendToolCancelled when toolCancelled is true', async () => { + render(); + + await waitFor(() => { + expect(mockBridgeInstance?.sendToolCancelled).toHaveBeenCalledWith({}); + }); + }); + + it('should not call sendToolCancelled when toolCancelled is false', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(mockBridgeInstance?.sendToolCancelled).not.toHaveBeenCalled(); + }); + }); + + describe('ref methods', () => { + it('should expose sendToolListChanged via ref', async () => { + const ref = React.createRef(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(ref.current).not.toBeNull(); + }); + + act(() => { + ref.current?.sendToolListChanged(); + }); + + expect(mockBridgeInstance?.sendToolListChanged).toHaveBeenCalled(); + }); + + it('should expose sendResourceListChanged via ref', async () => { + const ref = React.createRef(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(ref.current).not.toBeNull(); + }); + + act(() => { + ref.current?.sendResourceListChanged(); + }); + + expect(mockBridgeInstance?.sendResourceListChanged).toHaveBeenCalled(); + }); + + it('should expose sendPromptListChanged via ref', async () => { + const ref = React.createRef(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(ref.current).not.toBeNull(); + }); + + act(() => { + ref.current?.sendPromptListChanged(); + }); + + expect(mockBridgeInstance?.sendPromptListChanged).toHaveBeenCalled(); + }); + + it('should expose teardownResource via ref', async () => { + const ref = React.createRef(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(ref.current).not.toBeNull(); + }); + + act(() => { + ref.current?.teardownResource(); + }); + + expect(mockBridgeInstance?.teardownResource).toHaveBeenCalledWith({}); + }); + }); + + describe('MCP request handler props', () => { + it('should register onCallTool handler on AppBridge', async () => { + const onCallTool = vi.fn().mockResolvedValue({ content: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + // The handler should be registered + expect(mockBridgeInstance?.oncalltool).toBeDefined(); + }); + + it('should register onListResources handler on AppBridge', async () => { + const onListResources = vi.fn().mockResolvedValue({ resources: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(mockBridgeInstance?.onlistresources).toBeDefined(); + }); + + it('should register onListResourceTemplates handler on AppBridge', async () => { + const onListResourceTemplates = vi.fn().mockResolvedValue({ resourceTemplates: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(mockBridgeInstance?.onlistresourcetemplates).toBeDefined(); + }); + + it('should register onReadResource handler on AppBridge', async () => { + const onReadResource = vi.fn().mockResolvedValue({ contents: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(mockBridgeInstance?.onreadresource).toBeDefined(); + }); + + it('should register onListPrompts handler on AppBridge', async () => { + const onListPrompts = vi.fn().mockResolvedValue({ prompts: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(mockBridgeInstance?.onlistprompts).toBeDefined(); + }); + }); + + describe('callback props', () => { + it('should pass onSizeChanged to AppFrame', async () => { + const onSizeChanged = vi.fn(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(mockAppFrame).toHaveBeenCalledWith( + expect.objectContaining({ + onSizeChanged: expect.any(Function), + }), + ); + }); + + it('should pass onError to AppFrame', async () => { + const onError = vi.fn(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(mockAppFrame).toHaveBeenCalledWith( + expect.objectContaining({ + onError, + }), + ); + }); + }); + + describe('no client', () => { + it('should work without client when html is provided', async () => { + const props: AppRendererProps = { + // client omitted - using html prop instead + toolName: 'test-tool', + sandbox: { url: new URL('http://localhost:8081/sandbox.html') }, + html: 'Static HTML', + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + expect(screen.getByTestId('app-frame')).toHaveAttribute( + 'data-html', + 'Static HTML', + ); + }); + }); + + it('should show error without client and no html', async () => { + const props: AppRendererProps = { + // client omitted, no html provided + toolName: 'test-tool', + sandbox: { url: new URL('http://localhost:8081/sandbox.html') }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByText(/Error:/)).toBeInTheDocument(); + }); + }); + + it('should work with onReadResource and toolResourceUri instead of client', async () => { + const mockReadResource = vi.fn().mockResolvedValue({ + contents: [ + { + uri: 'ui://test/tool', + mimeType: 'text/html', + text: 'Custom fetched HTML', + }, + ], + }); + + const props: AppRendererProps = { + // client omitted - using onReadResource + toolResourceUri instead + toolName: 'test-tool', + sandbox: { url: new URL('http://localhost:8081/sandbox.html') }, + toolResourceUri: 'ui://test/tool', + onReadResource: mockReadResource, + }; + + render(); + + await waitFor(() => { + expect(mockReadResource).toHaveBeenCalledWith( + { uri: 'ui://test/tool' }, + expect.anything(), + ); + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + expect(screen.getByTestId('app-frame')).toHaveAttribute( + 'data-html', + 'Custom fetched HTML', + ); + }); + }); + }); +}); diff --git a/sdks/typescript/client/src/index.ts b/sdks/typescript/client/src/index.ts index 49734e7d..a49602da 100644 --- a/sdks/typescript/client/src/index.ts +++ b/sdks/typescript/client/src/index.ts @@ -2,6 +2,27 @@ export { UIResourceRenderer } from './components/UIResourceRenderer'; export { getUIResourceMetadata, getResourceMetadata } from './utils/metadataUtils'; export { isUIResource } from './utils/isUIResource'; +// MCP Apps renderers +export { + AppRenderer, + type AppRendererProps, + type AppRendererHandle, + type RequestHandlerExtra, +} from './components/AppRenderer'; +export { + AppFrame, + type AppFrameProps, + type SandboxConfig, + type AppInfo, +} from './components/AppFrame'; + +// Re-export AppBridge, transport, and common types for advanced use cases +export { + AppBridge, + PostMessageTransport, + type McpUiHostContext, +} from '@modelcontextprotocol/ext-apps/app-bridge'; + // The types needed to create a custom component library export type { ComponentLibrary, diff --git a/sdks/typescript/client/src/utils/app-host-utils.ts b/sdks/typescript/client/src/utils/app-host-utils.ts new file mode 100644 index 00000000..770026fa --- /dev/null +++ b/sdks/typescript/client/src/utils/app-host-utils.ts @@ -0,0 +1,133 @@ +import { + RESOURCE_URI_META_KEY, + RESOURCE_MIME_TYPE, + SANDBOX_PROXY_READY_METHOD, +} from '@modelcontextprotocol/ext-apps/app-bridge'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { Tool } from '@modelcontextprotocol/sdk/types.js'; + +const DEFAULT_SANDBOX_TIMEOUT_MS = 10000; + +export async function setupSandboxProxyIframe(sandboxProxyUrl: URL): Promise<{ + iframe: HTMLIFrameElement; + onReady: Promise; +}> { + const iframe = document.createElement('iframe'); + iframe.style.width = '100%'; + iframe.style.height = '600px'; + iframe.style.border = 'none'; + iframe.style.backgroundColor = 'transparent'; + iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms'); + + const onReady = new Promise((resolve, reject) => { + let settled = false; + + const cleanup = () => { + window.removeEventListener('message', messageListener); + iframe.removeEventListener('error', errorListener); + }; + + const timeoutId = setTimeout(() => { + if (!settled) { + settled = true; + cleanup(); + reject(new Error('Timed out waiting for sandbox proxy iframe to be ready')); + } + }, DEFAULT_SANDBOX_TIMEOUT_MS); + + const messageListener = (event: MessageEvent) => { + if (event.source === iframe.contentWindow) { + if ( + event.data && + event.data.method === SANDBOX_PROXY_READY_METHOD + ) { + if (!settled) { + settled = true; + clearTimeout(timeoutId); + cleanup(); + resolve(); + } + } + } + }; + + const errorListener = () => { + if (!settled) { + settled = true; + clearTimeout(timeoutId); + cleanup(); + reject(new Error('Failed to load sandbox proxy iframe')); + } + }; + + window.addEventListener('message', messageListener); + iframe.addEventListener('error', errorListener); + }); + + iframe.src = sandboxProxyUrl.href; + + return { iframe, onReady }; +} + +export type ToolUiResourceInfo = { + uri: string; +}; + +export async function getToolUiResourceUri( + client: Client, + toolName: string, +): Promise { + let tool: Tool | undefined; + let cursor: string | undefined = undefined; + do { + const toolsResult = await client.listTools({ cursor }); + tool = toolsResult.tools.find((t) => t.name === toolName); + cursor = toolsResult.nextCursor; + } while (!tool && cursor); + if (!tool) { + throw new Error(`tool ${toolName} not found`); + } + if (!tool._meta) { + return null; + } + + let uri: string; + if (RESOURCE_URI_META_KEY in tool._meta) { + uri = String(tool._meta[RESOURCE_URI_META_KEY]); + } else { + return null; + } + if (!uri.startsWith('ui://')) { + throw new Error(`tool ${toolName} has unsupported output template URI: ${uri}`); + } + return { uri }; +} + +export async function readToolUiResourceHtml( + client: Client, + opts: { + uri: string; + }, +): Promise { + const resource = await client.readResource({ uri: opts.uri }); + + if (!resource) { + throw new Error('UI resource not found: ' + opts.uri); + } + if (resource.contents.length !== 1) { + throw new Error('Unsupported UI resource content length: ' + resource.contents.length); + } + const content = resource.contents[0]; + let html: string; + const isHtml = (t?: string) => t === RESOURCE_MIME_TYPE; + + if ('text' in content && typeof content.text === 'string' && isHtml(content.mimeType)) { + html = content.text; + } else if ('blob' in content && typeof content.blob === 'string' && isHtml(content.mimeType)) { + html = atob(content.blob); + } else { + throw new Error('Unsupported UI resource content format: ' + JSON.stringify(content)); + } + + return html; +}