From 1f3bc52142ee94c57201aaba2afef2d1a9e6a591 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 24 Nov 2025 16:01:10 +0000 Subject: [PATCH] add simple-host example --- examples/simple-host/example-host-react.html | 21 ++ examples/simple-host/example-host.html | 21 ++ examples/simple-host/package.json | 38 ++ examples/simple-host/sandbox.html | 48 +++ examples/simple-host/serve.py | 63 ++++ examples/simple-host/src/app-host-utils.ts | 115 ++++++ .../simple-host/src/example-host-react.tsx | 253 +++++++++++++ examples/simple-host/src/example-host.ts | 166 ++++++++ .../src/react/UITemplatedToolCallRenderer.tsx | 356 ++++++++++++++++++ examples/simple-host/src/sandbox.ts | 41 ++ examples/simple-host/tsconfig.json | 25 ++ examples/simple-host/vite.config.ts | 24 ++ examples/simple-server/package.json | 6 +- examples/simple-server/src/ui-raw.ts | 2 +- examples/simple-server/vite.config.ts | 15 +- package.json | 6 +- 16 files changed, 1187 insertions(+), 13 deletions(-) create mode 100644 examples/simple-host/example-host-react.html create mode 100644 examples/simple-host/example-host.html create mode 100644 examples/simple-host/package.json create mode 100644 examples/simple-host/sandbox.html create mode 100644 examples/simple-host/serve.py create mode 100644 examples/simple-host/src/app-host-utils.ts create mode 100644 examples/simple-host/src/example-host-react.tsx create mode 100644 examples/simple-host/src/example-host.ts create mode 100644 examples/simple-host/src/react/UITemplatedToolCallRenderer.tsx create mode 100644 examples/simple-host/src/sandbox.ts create mode 100644 examples/simple-host/tsconfig.json create mode 100644 examples/simple-host/vite.config.ts diff --git a/examples/simple-host/example-host-react.html b/examples/simple-host/example-host-react.html new file mode 100644 index 0000000..fc71204 --- /dev/null +++ b/examples/simple-host/example-host-react.html @@ -0,0 +1,21 @@ + + + + + + + Example MCP-UI React Host + + +
+ + + diff --git a/examples/simple-host/example-host.html b/examples/simple-host/example-host.html new file mode 100644 index 0000000..a48492a --- /dev/null +++ b/examples/simple-host/example-host.html @@ -0,0 +1,21 @@ + + + + + + + + Example MCP View Host + + +

Example MCP View Host

+ +
+
+ + \ No newline at end of file diff --git a/examples/simple-host/package.json b/examples/simple-host/package.json new file mode 100644 index 0000000..44c0cc0 --- /dev/null +++ b/examples/simple-host/package.json @@ -0,0 +1,38 @@ +{ + "homepage": "https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-host", + "name": "@modelcontextprotocol/ext-apps-host", + "version": "1.0.0", + "type": "module", + "scripts": { + "start:server": "python3 serve.py", + "start:mcp-server": "cd ../simple-server && npm install && npm run start", + "start:proxy": "python3 proxy/serve.py", + "build": "concurrently 'INPUT=example-host.html vite build' 'INPUT=example-host-react.html vite build' 'INPUT=sandbox.html vite build'", + "server": "bun server.ts", + "start": "NODE_ENV=development npm run build && concurrently 'npm run start:server' 'npm run start:mcp-server' 'npm run start:proxy'" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "../../../modelcontextprotocol-typescript-sdk", + "react-dom": "^19.2.0", + "react": "^19.2.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/react-dom": "^19.2.2", + "@types/react": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "esbuild": "~0.19.10", + "express": "^5.1.0", + "prettier": "^3.6.2", + "tsx": "^4.20.6", + "typescript": "^5.9.3", + "vite-plugin-singlefile": "^2.3.0", + "vite": "^6.0.0", + "vitest": "^3.2.4" + } +} diff --git a/examples/simple-host/sandbox.html b/examples/simple-host/sandbox.html new file mode 100644 index 0000000..f09d494 --- /dev/null +++ b/examples/simple-host/sandbox.html @@ -0,0 +1,48 @@ + + + + + + + MCP-UI Proxy + + + + + + diff --git a/examples/simple-host/serve.py b/examples/simple-host/serve.py new file mode 100644 index 0000000..d32ed29 --- /dev/null +++ b/examples/simple-host/serve.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +Simple HTTP server to serve the MCP-UI proxy on port 8765 +""" + +import http.server +import os +import socketserver +import sys +from pathlib import Path + +PORT = int(os.environ.get('PORT', '8080')) +DIRECTORY = Path(__file__).parent / 'dist' + +class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=DIRECTORY.as_posix(), **kwargs) + + def end_headers(self): + # Apply custom headers only to sandbox.html + if self.path == "/sandbox.html" or self.path == "/": + # Add CORS headers to allow cross-origin requests + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "*") + + # Add permissive CSP to allow external resources (images, styles, scripts) + csp = "; ".join( + [ + "default-src 'self'", + "img-src * data: blob: 'unsafe-inline'", + "style-src * blob: data: 'unsafe-inline'", + "script-src * blob: data: 'unsafe-inline' 'unsafe-eval'", + "connect-src *", + "font-src * blob: data:", + "media-src * blob: data:", + "frame-src * blob: data:", + ] + ) + self.send_header("Content-Security-Policy", csp) + + # Disable caching to ensure fresh content on every request + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + + super().end_headers() + + +def main(): + print(f"Server running on: http://localhost:{PORT}") + print(f"Press Ctrl+C to stop the server\n") + + with socketserver.TCPServer(("", PORT), CustomHTTPRequestHandler) as httpd: + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\n\nServer stopped.") + sys.exit(0) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/simple-host/src/app-host-utils.ts b/examples/simple-host/src/app-host-utils.ts new file mode 100644 index 0000000..d05a8b5 --- /dev/null +++ b/examples/simple-host/src/app-host-utils.ts @@ -0,0 +1,115 @@ +import { McpUiSandboxProxyReadyNotificationSchema } from "@modelcontextprotocol/ext-apps"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { Tool } from "@modelcontextprotocol/sdk/types.js"; + +const MCP_UI_RESOURCE_META_KEY = "ui/resourceUri"; + +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.allow = 'microphone' + iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); + + const onReady = new Promise((resolve, _reject) => { + const initialListener = async (event: MessageEvent) => { + if (event.source === iframe.contentWindow) { + if ( + event.data && + event.data.method === + McpUiSandboxProxyReadyNotificationSchema.shape.method._def.value + ) { + window.removeEventListener("message", initialListener); + resolve(); + } + } + }; + window.addEventListener("message", initialListener); + }); + + 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 (MCP_UI_RESOURCE_META_KEY in tool._meta) { + uri = String(tool._meta[MCP_UI_RESOURCE_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 === "text/html+mcp"; + + 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; +} diff --git a/examples/simple-host/src/example-host-react.tsx b/examples/simple-host/src/example-host-react.tsx new file mode 100644 index 0000000..c7c8c9f --- /dev/null +++ b/examples/simple-host/src/example-host-react.tsx @@ -0,0 +1,253 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { UIActionResult } from "../src/mcp-ui-types.js"; +import { UITemplatedToolCallRenderer } from "../src/react/UITemplatedToolCallRenderer.js"; + +const SANDBOX_PROXY_URL = URL.parse("/sandbox.html", location.href); + +/** + * Example React application demonstrating the UITemplatedToolCallRenderer component. + * + * This shows how to: + * - Connect to an MCP server + * - List available tools + * - Render tool UIs using UITemplatedToolCallRenderer + * - Handle UI actions from the tool + */ +function ExampleApp() { + const [client, setClient] = useState(null); + const [tools, setTools] = useState([]); + const [activeTools, setActiveTools] = useState< + Array<{ id: string; name: string; input: Record }> + >([]); + const [error, setError] = useState(null); + + // Connect to MCP server on mount + useEffect(() => { + let mounted = true; + + async function connect() { + try { + const newClient = new Client({ + name: "MCP UI React Host", + version: "1.0.0", + }); + + const mcpServerUrl = new URL("http://localhost:3001/mcp"); + console.log( + "[React Host] Attempting SSE connection to", + mcpServerUrl.href, + ); + + try { + await newClient.connect(new SSEClientTransport(mcpServerUrl)); + console.log("[React Host] SSE connection successful"); + } catch (err) { + console.warn( + "[React Host] SSE connection failed, falling back to HTTP:", + err, + ); + await newClient.connect( + new StreamableHTTPClientTransport(mcpServerUrl), + ); + console.log("[React Host] HTTP connection successful"); + } + + if (!mounted) return; + + // List available tools + const toolsResult = await newClient.listTools(); + + if (!mounted) return; + + setClient(newClient); + setTools(toolsResult.tools); + } catch (err) { + if (!mounted) return; + const errorMsg = err instanceof Error ? err.message : String(err); + setError(`Failed to connect to MCP server: ${errorMsg}`); + console.error("[React Host] Connection error:", err); + } + } + + connect(); + + return () => { + mounted = false; + }; + }, []); + + const handleAddToolUI = ( + toolName: string, + input: Record, + ) => { + const id = `${toolName}-${Date.now()}`; + setActiveTools((prev) => [...prev, { id, name: toolName, input }]); + }; + + const handleRemoveToolUI = (id: string) => { + setActiveTools((prev) => prev.filter((t) => t.id !== id)); + }; + + const handleUIAction = async (toolId: string, action: UIActionResult) => { + console.log(`[React Host] UI Action from ${toolId}:`, action); + + if (action.type === "intent") { + console.log( + "[React Host] Intent:", + action.payload.intent, + action.payload.params, + ); + } else if (action.type === "link") { + console.log("[React Host] Opening link:", action.payload.url); + window.open(action.payload.url, "_blank", "noopener,noreferrer"); + } else if (action.type === "prompt") { + console.log("[React Host] Prompt:", action.payload.prompt); + } else if (action.type === "notify") { + console.log("[React Host] Notification:", action.payload.message); + } + }; + + const handleError = (toolId: string, err: Error) => { + console.error(`[React Host] Error from tool ${toolId}:`, err); + setError(`Tool ${toolId}: ${err.message}`); + }; + + if (error) { + return ( +
+

Example MCP-UI React Host

+
+ Error: {error} +
+ +
+ ); + } + + if (!client) { + return ( +
+

Example MCP-UI React Host

+

Connecting to MCP server...

+
+ ); + } + + return ( +
+

Example MCP-UI React Host

+ +
+

Available Tools

+ {tools.length === 0 ? ( +

No tools available with UI resources.

+ ) : ( +
+ {tools.map((tool) => ( + + ))} +
+ )} +
+ +
+ {activeTools.map((tool) => ( +
+
+

{tool.name}

+ +
+ + handleUIAction(tool.id, action)} + onError={(err) => handleError(tool.id, err)} + /> +
+ ))} + + {activeTools.length === 0 && ( +

+ No tool UIs active. Click a button above to add one. +

+ )} +
+
+ ); +} + +// Mount the React app +const root = document.getElementById("root"); +if (root) { + createRoot(root).render(); +} else { + console.error("Root element not found"); +} diff --git a/examples/simple-host/src/example-host.ts b/examples/simple-host/src/example-host.ts new file mode 100644 index 0000000..cd948e6 --- /dev/null +++ b/examples/simple-host/src/example-host.ts @@ -0,0 +1,166 @@ +import { + AppBridge, + PostMessageTransport, +} from "@modelcontextprotocol/ext-apps/app-bridge"; +import { + setupSandboxProxyIframe, + getToolUiResourceUri, + readToolUiResourceHtml, +} from "../src/app-host-utils"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { LoggingMessageNotificationSchema } from "@modelcontextprotocol/sdk/types.js"; +import { + McpUiOpenLinkRequestSchema, + McpUiMessageRequestSchema, + McpUiSizeChangeNotificationSchema, +} from "@modelcontextprotocol/ext-apps"; + +const SANDBOX_PROXY_URL = URL.parse("/sandbox.html", location.href)!; + +window.addEventListener("load", async () => { + const client = new Client({ + name: "MCP UI Proxy Server", + version: "1.0.0", + }); + const mcpServerUrl = new URL("http://localhost:3001/mcp"); + console.log("[Client] Attempting SSE connection to", mcpServerUrl.href); + try { + await client.connect(new SSEClientTransport(mcpServerUrl)); + console.log("[Client] SSE connection successful"); + } catch (error) { + console.warn( + "[Client] SSE connection failed, falling back to HTTP:", + error, + ); + await client.connect(new StreamableHTTPClientTransport(mcpServerUrl)); + console.log("[Client] HTTP connection successful"); + } + + const tools = Object.fromEntries( + (await client.listTools()).tools.map((t) => [t.name, t]), + ); + + const controlsDiv = document.getElementById("controls") as HTMLDivElement; + const chatRootDiv = document.getElementById("chat-root") as HTMLDivElement; + + /** + * Creates a tool UI instance for the given tool name. + * This demonstrates the new simplified API where the host manages the full proxy lifecycle. + */ + async function createToolUI( + toolName: string, + toolInput: Record, + ) { + try { + // Step 1: Create iframe and wait for sandbox proxy ready + const { iframe, onReady } = + await setupSandboxProxyIframe(SANDBOX_PROXY_URL); + + chatRootDiv.appendChild(iframe); + + // Wait for sandbox proxy to be ready + await onReady; + + // Step 2: Create proxy server instance + const serverCapabilities = client.getServerCapabilities(); + const proxy = new AppBridge( + client, + { + name: "Example MCP UI Host", + version: "1.0.0", + }, + { + openLinks: {}, + serverTools: serverCapabilities?.tools, + serverResources: serverCapabilities?.resources, + }, + ); + + // Step 3: Register handlers BEFORE connecting + proxy.oninitialized = () => { + console.log("[Example] Inner iframe MCP client initialized"); + + // Send tool input once iframe is ready + proxy.sendToolInput({ arguments: toolInput }); + }; + + proxy.setRequestHandler(McpUiOpenLinkRequestSchema, async (req) => { + console.log("[Example] Open link requested:", req.params.url); + window.open(req.params.url, "_blank", "noopener,noreferrer"); + return { isError: false }; + }); + + proxy.setRequestHandler(McpUiMessageRequestSchema, async (req) => { + console.log("[Example] Message requested:", req.params); + return { isError: false }; + }); + + // Handle size changes by resizing the iframe + proxy.setNotificationHandler( + McpUiSizeChangeNotificationSchema, + async (notif: any) => { + const { width, height } = notif.params; + if (width !== undefined) { + iframe.style.width = `${width}px`; + } + if (height !== undefined) { + iframe.style.height = `${height}px`; + } + }, + ); + + proxy.setNotificationHandler( + LoggingMessageNotificationSchema, + async (notif: any) => { + console.log("[Tool UI Log]", notif.params); + }, + ); + + // Step 4: Connect proxy to iframe (triggers MCP initialization) + // Pass iframe.contentWindow as both target and source for proper message filtering + await proxy.connect( + new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!), + ); + + // Step 5: Fetch and send UI resource + const resourceInfo = await getToolUiResourceUri(client, toolName); + if (!resourceInfo) { + throw new Error(`Tool ${toolName} has no UI resource`); + } + + const html = await readToolUiResourceHtml(client, { + uri: resourceInfo.uri, + }); + await proxy.sendSandboxResourceReady({ html }); + + console.log("[Example] Tool UI setup complete for:", toolName); + } catch (error) { + console.error("[Example] Error setting up tool UI:", error); + } + } + + // Create buttons for available tools + if ([...Object.keys(tools)].some((n) => n.startsWith("pizza-"))) { + for (const t of Object.values(tools)) { + if (t.name.startsWith("pizza-")) { + controlsDiv.appendChild( + Object.assign(document.createElement("button"), { + innerText: t.name, + onclick: () => createToolUI(t.name, { pizzaTopping: "Mushrooms" }), + }), + ); + } + } + } else { + controlsDiv.appendChild( + Object.assign(document.createElement("button"), { + innerText: "Add MCP UI View", + onclick: () => + createToolUI("create-example-ui", { message: "Hello from Host!" }), + }), + ); + } +}); diff --git a/examples/simple-host/src/react/UITemplatedToolCallRenderer.tsx b/examples/simple-host/src/react/UITemplatedToolCallRenderer.tsx new file mode 100644 index 0000000..9db0b35 --- /dev/null +++ b/examples/simple-host/src/react/UITemplatedToolCallRenderer.tsx @@ -0,0 +1,356 @@ +import { useEffect, useRef, useState } from "react"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { + type CallToolResult, + type LoggingMessageNotification, + type McpError, + ErrorCode, +} from "@modelcontextprotocol/sdk/types.js"; + +import { + type McpUiMessageRequest, + type McpUiMessageResult, + type McpUiOpenLinkRequest, + type McpUiOpenLinkResult, + AppBridge, + PostMessageTransport, +} from "@modelcontextprotocol/ext-apps/app-bridge"; + +import { + getToolUiResourceUri, + readToolUiResourceHtml, + setupSandboxProxyIframe, +} from "../app-host-utils"; + +type RequestExtra = Parameters< + Parameters[1] +>[1]; + +/** + * Props for the UITemplatedToolCallRenderer component. + */ +export interface UITemplatedToolCallRendererProps { + /** URL to the sandbox proxy HTML that will host the tool UI iframe */ + sandboxProxyUrl: URL; + + /** MCP client connected to the server providing the tool */ + client: Client; + + /** Name of the MCP tool to render UI for */ + toolName: string; + + /** Optional pre-fetched resource URI. If not provided, will be fetched via getToolUiResourceUri() */ + toolResourceUri?: 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; + + onopenlink?: ( + params: McpUiOpenLinkRequest["params"], + extra: RequestExtra, + ) => Promise; + onmessage?: ( + params: McpUiMessageRequest["params"], + extra: RequestExtra, + ) => Promise; + onloggingmessage?: (params: LoggingMessageNotification["params"]) => void; + + /** Callback invoked when an error occurs during setup or message handling */ + onerror?: (error: Error) => void; +} + +/** + * 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 a sandboxed iframe with the proxy HTML + * 2. Establishes MCP communication channel between host and iframe + * 3. Fetches and loads the tool's UI resource (HTML) + * 4. Sends tool inputs and results to the UI when ready + * 5. Handles UI actions (intents, link opening, prompts, notifications) + * 6. Automatically resizes iframe based on content size changes + * + * @example + * ```tsx + * { + * if (action.type === 'intent') { + * // Handle intent request from UI + * console.log('Intent:', action.payload.intent); + * } + * }} + * onerror={(error) => console.error('UI Error:', error)} + * /> + * ``` + * + * **Architecture:** + * - Host (this component) ↔ Sandbox Proxy (iframe) ↔ Tool UI (nested iframe) + * - Communication uses MCP protocol over postMessage + * - Sandbox proxy provides CSP isolation for untrusted tool UIs + * - Standard MCP initialization flow determines when UI is ready + * + * **Lifecycle:** + * 1. `setupSandboxProxyIframe()` creates iframe and waits for proxy ready + * 2. Component creates `McpUiProxyServer` instance + * 3. Registers all handlers (BEFORE connecting to avoid race conditions) + * 4. Connects proxy to iframe via `MessageTransport` + * 5. MCP initialization completes → `onClientReady` callback fires + * 6. Fetches tool UI resource and sends to sandbox proxy + * 7. Sends tool inputs/results when iframe signals ready + * + * @param props - Component props + * @returns React element containing the sandboxed tool UI iframe + */ +export const UITemplatedToolCallRenderer = ( + props: UITemplatedToolCallRendererProps, +) => { + const { + client, + sandboxProxyUrl, + toolName, + toolResourceUri, + toolInput, + toolResult, + onmessage, + onopenlink, + onloggingmessage, + onerror, + } = props; + + // State + const [appBridge, setAppBridge] = useState(null); + const [iframeReady, setIframeReady] = useState(false); + const [error, setError] = useState(null); + const containerRef = useRef(null); + const iframeRef = useRef(null); + + // Use refs for callbacks to avoid effect re-runs when they change + const onmessageRef = useRef(onmessage); + const onopenlinkRef = useRef(onopenlink); + const onloggingmessageRef = useRef(onloggingmessage); + const onerrorRef = useRef(onerror); + + useEffect(() => { + onmessageRef.current = onmessage; + onopenlinkRef.current = onopenlink; + onloggingmessageRef.current = onloggingmessage; + onerrorRef.current = onerror; + }); + + useEffect(() => { + let mounted = true; + + const setup = async () => { + try { + const { iframe, onReady } = + await setupSandboxProxyIframe(sandboxProxyUrl); + + if (!mounted) return; + + iframeRef.current = iframe; + if (containerRef.current) { + containerRef.current.appendChild(iframe); + } + + await onReady; + + if (!mounted) return; + + const serverCapabilities = client.getServerCapabilities(); + const appBridge = new AppBridge( + client, + { + name: "Example MCP UI Host", + version: "1.0.0", + }, + { + openLinks: {}, + serverTools: serverCapabilities?.tools, + serverResources: serverCapabilities?.resources, + }, + ); + + // Step 3: Register ALL handlers BEFORE connecting (critical for avoiding race conditions) + + // Hook into the standard MCP initialization to know when the inner iframe is ready + appBridge.oninitialized = () => { + if (!mounted) return; + console.log("[Host] Inner iframe MCP client initialized"); + setIframeReady(true); + }; + + // Register handlers passed in via props + + appBridge.onmessage = (params, extra) => { + if (!onmessageRef.current) { + throw new McpError(ErrorCode.MethodNotFound, "Method not found"); + } + return onmessageRef.current(params, extra); + }; + appBridge.onopenlink = (params, extra) => { + if (!onopenlinkRef.current) { + throw new McpError(ErrorCode.MethodNotFound, "Method not found"); + } + return onopenlinkRef.current(params, extra); + }; + appBridge.onloggingmessage = (params) => { + if (!onloggingmessageRef.current) { + throw new McpError(ErrorCode.MethodNotFound, "Method not found"); + } + return onloggingmessageRef.current(params); + }; + + appBridge.onsizechange = async ({ width, height }) => { + if (iframeRef.current) { + if (width !== undefined) { + iframeRef.current.style.width = `${width}px`; + } + if (height !== undefined) { + iframeRef.current.style.height = `${height}px`; + } + } + }; + + // Step 4: NOW connect (triggers MCP initialization handshake) + // IMPORTANT: Pass iframe.contentWindow as BOTH target and source to ensure + // this proxy only responds to messages from its specific iframe + await appBridge.connect( + new PostMessageTransport( + iframe.contentWindow!, + iframe.contentWindow!, + ), + ); + + if (!mounted) return; + + // Step 5: Store proxy in state + setAppBridge(appBridge); + } catch (err) { + console.error("[UITemplatedToolCallRenderer] Error:", err); + if (!mounted) return; + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + onerrorRef.current?.(error); + } + }; + + setup(); + + return () => { + mounted = false; + // Cleanup: remove iframe from DOM + if ( + iframeRef.current && + containerRef.current?.contains(iframeRef.current) + ) { + containerRef.current.removeChild(iframeRef.current); + } + }; + }, [client, sandboxProxyUrl]); + + // Effect 2: Fetch and send UI resource + useEffect(() => { + if (!appBridge) return; + + let mounted = true; + + const fetchAndSendResource = async () => { + try { + // Get the resource URI (use prop if provided, otherwise fetch) + let resourceInfo: { uri: string }; + + if (toolResourceUri) { + // When URI is provided directly, assume it's NOT OpenAI Apps SDK format + resourceInfo = { + uri: toolResourceUri, + }; + console.log( + `[Host] Using provided resource URI: ${resourceInfo.uri}`, + ); + } else { + console.log(`[Host] 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 or openai/outputTemplate in tool._meta)`, + ); + } + resourceInfo = info; + console.log(`[Host] Got resource URI: ${resourceInfo.uri}`); + } + + if (!resourceInfo.uri) { + throw new Error(`Tool ${toolName}: URI is undefined or empty`); + } + + if (!mounted) return; + + // Read the HTML content + console.log(`[Host] Reading resource HTML from: ${resourceInfo.uri}`); + const html = await readToolUiResourceHtml(client, { + uri: resourceInfo.uri, + }); + + if (!mounted) return; + + // Send the resource to the sandbox proxy + console.log("[Host] Sending sandbox resource ready"); + await appBridge.sendSandboxResourceReady({ html }); + } catch (err) { + if (!mounted) return; + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + onerrorRef.current?.(error); + } + }; + + fetchAndSendResource(); + + return () => { + mounted = false; + }; + }, [appBridge, toolName, toolResourceUri, client]); + + // Effect 3: Send tool input when ready + useEffect(() => { + if (appBridge && iframeReady && toolInput) { + console.log("[Host] Sending tool input:", toolInput); + appBridge.sendToolInput({ arguments: toolInput }); + } + }, [appBridge, iframeReady, toolInput]); + + // Effect 4: Send tool result when ready + useEffect(() => { + if (appBridge && iframeReady && toolResult) { + console.log("[Host] Sending tool result:", toolResult); + appBridge.sendToolResult(toolResult); + } + }, [appBridge, iframeReady, toolResult]); + + // Render + return ( +
+ {error && ( +
+ Error: {error.message} +
+ )} +
+ ); +}; diff --git a/examples/simple-host/src/sandbox.ts b/examples/simple-host/src/sandbox.ts new file mode 100644 index 0000000..81947d0 --- /dev/null +++ b/examples/simple-host/src/sandbox.ts @@ -0,0 +1,41 @@ +// Double-iframe raw HTML mode (HTML sent via postMessage) +const inner = document.createElement("iframe"); +inner.style = "width:100%; height:100%; border:none;"; +// sandbox will be set from postMessage payload; default minimal before html arrives +inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); +document.body.appendChild(inner); + +// Wait for HTML content from parent +window.addEventListener("message", async (event) => { + if (event.source === window.parent) { + if ( + event.data && + event.data.method === "ui/notifications/sandbox-resource-ready" + ) { + const { html, sandbox } = event.data.params; + if (typeof sandbox === "string") { + inner.setAttribute("sandbox", sandbox); + } + if (typeof html === "string") { + inner.srcdoc = html; + } + } else { + if (inner && inner.contentWindow) { + inner.contentWindow.postMessage(event.data, "*"); + } + } + } else if (event.source === inner.contentWindow) { + // Relay messages from inner to parent + window.parent.postMessage(event.data, "*"); + } +}); + +// Notify parent that proxy is ready to receive HTML (distinct event) +window.parent.postMessage( + { + jsonrpc: "2.0", + method: "ui/notifications/sandbox-proxy-ready", + params: {}, + }, + "*", +); diff --git a/examples/simple-host/tsconfig.json b/examples/simple-host/tsconfig.json new file mode 100644 index 0000000..5550165 --- /dev/null +++ b/examples/simple-host/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2021", "DOM"], + "jsx": "react-jsx", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": "./", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": [ + "examples/**/*.ts", + "examples/**/*.tsx", + "src/**/*.ts", + "src/**/*.tsx" + ], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/simple-host/vite.config.ts b/examples/simple-host/vite.config.ts new file mode 100644 index 0000000..e2f6958 --- /dev/null +++ b/examples/simple-host/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + rollupOptions: { + input: INPUT, + }, + outDir: `dist`, + emptyOutDir: false, + }, +}); diff --git a/examples/simple-server/package.json b/examples/simple-server/package.json index 7c04f9f..f7fdce9 100644 --- a/examples/simple-server/package.json +++ b/examples/simple-server/package.json @@ -4,13 +4,13 @@ "private": true, "type": "module", "scripts": { - "build:uis": "rm -rf dist && concurrently 'UI=ui-raw.html vite build' 'UI=ui-vanilla.html vite build' 'UI=ui-react.html vite build'", + "build": "concurrently 'INPUT=ui-raw.html vite build' 'INPUT=ui-vanilla.html vite build' 'INPUT=ui-react.html vite build'", "server": "bun server.ts", - "start": "npm run build:uis && npm run server" + "start": "npm run build && npm run server" }, "dependencies": { "@modelcontextprotocol/ext-apps": "../..", - "@modelcontextprotocol/sdk": "^1.22.0", + "@modelcontextprotocol/sdk": "../../../modelcontextprotocol-typescript-sdk", "react": "^19.2.0", "react-dom": "^19.2.0", "zod": "^3.25.0" diff --git a/examples/simple-server/src/ui-raw.ts b/examples/simple-server/src/ui-raw.ts index d91ab9f..1000571 100644 --- a/examples/simple-server/src/ui-raw.ts +++ b/examples/simple-server/src/ui-raw.ts @@ -8,7 +8,7 @@ * * npx esbuild src/ui-raw.ts --bundle --outfile=dist/ui-raw.js --minify --sourcemap --platform=browser * - * + * * We implement a barebones JSON-RPC message sender/receiver (see `app` object below), * but without timeouts or runtime type validation of any kind * (for that, use the Apps SDK / see ui-vanilla.ts or ui-react.ts). diff --git a/examples/simple-server/vite.config.ts b/examples/simple-server/vite.config.ts index a4d7fa9..28d7e5b 100644 --- a/examples/simple-server/vite.config.ts +++ b/examples/simple-server/vite.config.ts @@ -1,16 +1,23 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { viteSingleFile } from "vite-plugin-singlefile"; -import { resolve } from "path"; -// Get the entry from environment variable, default to ui-react -const uiPath = process.env.UI; +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; export default defineConfig({ plugins: [react(), viteSingleFile()], build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + rollupOptions: { - input: uiPath, + input: INPUT, }, outDir: `dist`, emptyOutDir: false, diff --git a/package.json b/package.json index 4f5b0cf..eaf0136 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,7 @@ "dist" ], "scripts": { - "start:example-server0": "bun examples/example-server.ts", - "start:example-server": "cd ../openai-apps-sdk-examples && concurrently \"npm run serve\" \"cd pizzaz_server_node && PORT=3001 npm start\"", - "start:sandbox-proxy": "python3 sandbox_proxy.py", - "start": "npm run build && npm run start:example-server", - "start:dev": "NODE_ENV=development npm start", + "start": "cd examples/example-host && npm i && npm start", "build": "bun build.bun.ts", "prepare": "npm run build", "prettier:base-cmd": "prettier -u --ignore-path ./.gitignore --ignore-path ./.prettierignore",