diff --git a/README.md b/README.md
index f960030..a72babe 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,10 @@ This repo provides:
- [ui-vanilla.tsx](./examples/simple-server/src/ui-vanilla.ts): vanilla App returned by the `create-ui-vanilla`
- [ui-raw.tsx](./examples/simple-server/src/ui-raw.ts): same as vanilla App but doesn't use the SDK runtime (just its types)
+- [examples/simple-host](./examples/simple-host): bare-bone examples on how to host MCP Apps (both use the [AppBridge](./src/app-bridge.ts) class to talk to a hosted App)
+ - [example-host-react.tsx](./examples/simple-host/src/example-host-react.tsx) uses React (esp. [AppRenderer.tsx](./examples/simple-host/src/AppRenderer.tsx))
+ - [example-host-vanilla.tsx](./examples/simple-host/src/example-host-vanilla.tsx) doesn't use React
+
- [message-transport](./src/message-transport.ts): `PostMessageTransport` class that uses `postMessage` to exchange JSON-RPC messages between windows / iframes
- [app.ts](./src/app.ts): `App` class used by an App to talk to its host
@@ -26,22 +30,47 @@ This repo provides:
What this repo does NOT provide:
-- There's no host implementation here (beyond the `AppBridge` just used for communications).
+- There's no _supported_ host implementation in this repo (beyond the [examples/simple-host](./examples/simple-host) example)
- We have [contributed a tentative implementation](https://github.com/MCP-UI-Org/mcp-ui/pull/147) of hosting / iframing / sandboxing logic to the [MCP-UI](https://github.com/idosal/mcp-ui) repository, and expect OSS clients may use it, while other clients might roll their own hosting logic.
- - A prior iteration of an e2e prototype w/ client, server and hosting parts is available [in this gist](https://gist.github.com/ochafik/a9603ba2d6757d6038ce066eded4c354)
-## Installation
+## Using the SDK
-This repo is in flux and isn't published to npm (when it is, it will use the `@modelcontextprotocol/ext-apps` package). Please install it from git for now:
+### Run examples
-```bash
-npm install git+https://github.com/modelcontextprotocol/ext-apps.git
+Run the examples in this repo end-to-end:
+
+```
+npm i
+npm start
+open http://localhost:8080/
```
-## Development Notes
+> [!NOTE]
+> Please bear with us while we add more examples!
-### Build tools in dependencies
+### Using the SDK in your project
-The build tools (`esbuild`, `tsx`, `typescript`) are in `dependencies` rather than `devDependencies`. This is intentional: it allows the `prepare` script to run when the package is installed from git, since npm doesn't install devDependencies for git dependencies.
+This repo is in flux and isn't published to npm yet: when it is, it will use the `@modelcontextprotocol/ext-apps` package.
-Once the package is published to npm with pre-built `dist/`, these can be moved back to `devDependencies`.
+In the meantime you can depend on the SDK library in a Node.js project by installing it w/ its git URL:
+
+```bash
+npm install -S git+https://github.com/modelcontextprotocol/ext-apps.git
+```
+
+Your `package.json` will then look like:
+
+```json
+{
+ ...
+ "dependencies": {
+ ...
+ "@modelcontextprotocol/ext-apps": "git+https://github.com/modelcontextprotocol/ext-apps.git"
+ }
+}
+```
+
+> [!NOTE]
+> The build tools (`esbuild`, `tsx`, `typescript`) are in `dependencies` rather than `devDependencies`. This is intentional: it allows the `prepare` script to run when the package is installed from git, since npm doesn't install devDependencies for git dependencies.
+>
+> Once the package is published to npm with pre-built `dist/`, these can be moved back to `devDependencies`.
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-vanilla.html b/examples/simple-host/example-host-vanilla.html
new file mode 100644
index 0000000..cc4f2df
--- /dev/null
+++ b/examples/simple-host/example-host-vanilla.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..0de72ce
--- /dev/null
+++ b/examples/simple-host/package.json
@@ -0,0 +1,37 @@
+{
+ "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": "tsx server.ts",
+ "start:mcp-server": "cd ../simple-server && npm install && npm run start",
+ "build": "concurrently 'INPUT=example-host-vanilla.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'"
+ },
+ "dependencies": {
+ "@modelcontextprotocol/ext-apps": "../..",
+ "@modelcontextprotocol/sdk": "^1.22.0",
+ "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/server.ts b/examples/simple-host/server.ts
new file mode 100644
index 0000000..b601982
--- /dev/null
+++ b/examples/simple-host/server.ts
@@ -0,0 +1,58 @@
+#!/usr/bin/env npx tsx
+/**
+ * Simple HTTP server to serve the host and sandbox html files with appropriate
+ * Content Security Policy (CSP) headers.
+ */
+
+import express from "express";
+import cors from "cors";
+import { fileURLToPath } from "url";
+import { dirname, join } from "path";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const PORT = parseInt(process.env.PORT || "8080", 10);
+const DIRECTORY = join(__dirname, "dist");
+
+const app = express();
+
+// CORS middleware for all routes
+app.use(cors());
+
+// Custom middleware for sandbox.html and root
+app.use((req, res, next) => {
+ if (req.path === "/sandbox.html" || req.path === "/") {
+ // Permissive CSP to allow external resources (images, styles, scripts)
+ const csp = [
+ "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:",
+ ].join("; ");
+ res.setHeader("Content-Security-Policy", csp);
+
+ // Disable caching to ensure fresh content on every request
+ res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
+ res.setHeader("Pragma", "no-cache");
+ res.setHeader("Expires", "0");
+ }
+ next();
+});
+
+// Serve static files from dist directory
+app.use(express.static(DIRECTORY));
+
+// Redirect root to example-host.html
+app.get("/", (_req, res) => {
+ res.redirect("/example-host-react.html");
+});
+
+app.listen(PORT, () => {
+ console.log(`Server running on: http://localhost:${PORT}`);
+ console.log("Press Ctrl+C to stop the server\n");
+});
diff --git a/examples/simple-host/src/AppRenderer.tsx b/examples/simple-host/src/AppRenderer.tsx
new file mode 100644
index 0000000..6853977
--- /dev/null
+++ b/examples/simple-host/src/AppRenderer.tsx
@@ -0,0 +1,340 @@
+import { useEffect, useRef, useState } from "react";
+
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import {
+ type CallToolResult,
+ type LoggingMessageNotification,
+ McpError,
+ ErrorCode,
+} from "@modelcontextprotocol/sdk/types.js";
+
+import {
+ AppBridge,
+ PostMessageTransport,
+} from "@modelcontextprotocol/ext-apps/app-bridge";
+
+import {
+ getToolUiResourceUri,
+ readToolUiResourceHtml,
+ setupSandboxProxyIframe,
+} from "./app-host-utils";
+
+/**
+ * Props for the AppRenderer component.
+ */
+export interface AppRendererProps {
+ /** 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?: AppBridge["onopenlink"];
+ onmessage?: AppBridge["onmessage"];
+ onloggingmessage?: AppBridge["onloggingmessage"];
+
+ /** 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 AppRenderer = (props: AppRendererProps) => {
+ 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("[AppRenderer] 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/app-host-utils.ts b/examples/simple-host/src/app-host-utils.ts
new file mode 100644
index 0000000..a13701d
--- /dev/null
+++ b/examples/simple-host/src/app-host-utils.ts
@@ -0,0 +1,114 @@
+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.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..8bc7318
--- /dev/null
+++ b/examples/simple-host/src/example-host-react.tsx
@@ -0,0 +1,260 @@
+import { useEffect, useState } from "react";
+import { createRoot } from "react-dom/client";
+
+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 { AppRenderer, AppRendererProps } from "../src/AppRenderer";
+import { AppBridge } from "../../../dist/src/app-bridge";
+
+const SANDBOX_PROXY_URL = URL.parse("/sandbox.html", location.href)!;
+
+/**
+ * Example React application demonstrating the AppRenderer component.
+ *
+ * This shows how to:
+ * - Connect to an MCP server
+ * - List available tools
+ * - Render tool UIs using AppRenderer
+ * - 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 handleMessage: AppRendererProps["onmessage"] = async (
+ params,
+ _extra,
+ ) => {
+ console.log("[React Host] Message:", params);
+ return {};
+ };
+
+ const handleLoggingMessage: AppRendererProps["onloggingmessage"] = (
+ params,
+ ) => {
+ console.log("[React Host] Logging message:", params);
+ };
+
+ const handleOpenLink: AppRendererProps["onopenlink"] = async (
+ params,
+ _extra,
+ ) => {
+ console.log("[React Host] Open link request:", params);
+ window.open(params.url, "_blank", "noopener,noreferrer");
+ return { isError: false };
+ };
+
+ 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}
+
+
window.location.reload()}>Retry
+
+ );
+ }
+
+ 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) => (
+ {
+ // Example input - in a real app, you'd have a form for this
+ const input = tool.name.startsWith("pizza-")
+ ? { pizzaTopping: "Mushrooms" }
+ : { message: "Hello from React!" };
+ handleAddToolUI(tool.name, input);
+ }}
+ style={{
+ padding: "8px 16px",
+ backgroundColor: "#007bff",
+ color: "white",
+ border: "none",
+ borderRadius: "4px",
+ cursor: "pointer",
+ }}
+ >
+ Add {tool.name}
+
+ ))}
+
+ )}
+
+
+
+ {activeTools.map((tool) => (
+
+
+
{tool.name}
+ handleRemoveToolUI(tool.id)}
+ style={{
+ padding: "4px 12px",
+ backgroundColor: "#dc3545",
+ color: "white",
+ border: "none",
+ borderRadius: "4px",
+ cursor: "pointer",
+ }}
+ >
+ Remove
+
+
+
+
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-vanilla.ts b/examples/simple-host/src/example-host-vanilla.ts
new file mode 100644
index 0000000..9a304d2
--- /dev/null
+++ b/examples/simple-host/src/example-host-vanilla.ts
@@ -0,0 +1,159 @@
+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 appBridge = 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
+ appBridge.oninitialized = () => {
+ console.log("[Example] Inner iframe MCP client initialized");
+
+ // Send tool input once iframe is ready
+ appBridge.sendToolInput({ arguments: toolInput });
+ };
+
+ appBridge.onopenlink = async ({ url }) => {
+ console.log("[Example] Open link requested:", url);
+ window.open(url, "_blank", "noopener,noreferrer");
+ return { isError: false };
+ };
+
+ appBridge.onmessage = async (params) => {
+ console.log("[Example] Message requested:", params);
+ return { isError: false };
+ };
+
+ // Handle size changes by resizing the iframe
+ appBridge.onsizechange = ({ width, height }) => {
+ if (width !== undefined) {
+ iframe.style.width = `${width}px`;
+ }
+ if (height !== undefined) {
+ iframe.style.height = `${height}px`;
+ }
+ };
+
+ appBridge.onloggingmessage = async (params) => {
+ console.log("[Tool UI Log]", params);
+ };
+
+ // Step 4: Connect proxy to iframe (triggers MCP initialization)
+ // Pass iframe.contentWindow as both target and source for proper message filtering
+ await appBridge.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 appBridge.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-ui-vanilla", { message: "Hello from Host!" }),
+ }),
+ );
+ }
+});
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..21a1edd 100644
--- a/examples/simple-server/package.json
+++ b/examples/simple-server/package.json
@@ -4,9 +4,9 @@
"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": "../..",
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..39647cd 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"homepage": "https://gist.github.com/a9603ba2d6757d6038ce066eded4c354",
"name": "@modelcontextprotocol/ext-apps",
- "version": "1.0.0",
+ "version": "0.0.1",
"description": "Hey",
"type": "module",
"main": "./dist/src/app.js",
@@ -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/simple-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",
diff --git a/src/app-bridge.ts b/src/app-bridge.ts
index b36cb79..08df061 100644
--- a/src/app-bridge.ts
+++ b/src/app-bridge.ts
@@ -12,6 +12,8 @@ import {
ListResourcesResultSchema,
ListResourceTemplatesRequestSchema,
ListResourceTemplatesResultSchema,
+ LoggingMessageNotification,
+ LoggingMessageNotificationSchema,
Notification,
PingRequest,
PingRequestSchema,
@@ -40,6 +42,12 @@ import {
McpUiInitializeRequest,
McpUiInitializeRequestSchema,
McpUiInitializeResult,
+ McpUiMessageRequest,
+ McpUiMessageRequestSchema,
+ McpUiMessageResult,
+ McpUiOpenLinkRequest,
+ McpUiOpenLinkRequestSchema,
+ McpUiOpenLinkResult,
McpUiResourceTeardownRequest,
McpUiResourceTeardownResultSchema,
McpUiSandboxProxyReadyNotification,
@@ -101,6 +109,42 @@ export class AppBridge extends Protocol {
callback(n.params),
);
}
+ set onmessage(
+ callback: (
+ params: McpUiMessageRequest["params"],
+ extra: RequestExtra,
+ ) => Promise,
+ ) {
+ this.setRequestHandler(
+ McpUiMessageRequestSchema,
+ async (request, extra) => {
+ return callback(request.params, extra);
+ },
+ );
+ }
+ set onopenlink(
+ callback: (
+ params: McpUiOpenLinkRequest["params"],
+ extra: RequestExtra,
+ ) => Promise,
+ ) {
+ this.setRequestHandler(
+ McpUiOpenLinkRequestSchema,
+ async (request, extra) => {
+ return callback(request.params, extra);
+ },
+ );
+ }
+ set onloggingmessage(
+ callback: (params: LoggingMessageNotification["params"]) => void,
+ ) {
+ this.setNotificationHandler(
+ LoggingMessageNotificationSchema,
+ async (notification) => {
+ callback(notification.params);
+ },
+ );
+ }
assertCapabilityForMethod(method: Request["method"]): void {
// TODO
diff --git a/src/types.ts b/src/types.ts
index 35445ab..40553ad 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -20,7 +20,7 @@ export const McpUiOpenLinkRequestSchema = RequestSchema.extend({
export type McpUiOpenLinkRequest = z.infer;
export const McpUiOpenLinkResultSchema = z.object({
- isError: z.boolean(),
+ isError: z.boolean().optional(),
});
export type McpUiOpenLinkResult = z.infer;
@@ -36,7 +36,7 @@ export type McpUiMessageRequest = z.infer;
export const McpUiMessageResultSchema = z.object({
// Note: we don't return the result from follow up messages as they might leak info from the chat.
// We do tell the caller if it errored, though.
- isError: z.boolean(),
+ isError: z.boolean().optional(),
});
export type McpUiMessageResult = z.infer;