add simple-host example

This commit is contained in:
Olivier Chafik 2025-11-24 16:01:10 +00:00
commit 1f3bc52142
No known key found for this signature in database
16 changed files with 1187 additions and 13 deletions

View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
}
</style>
<title>Example MCP-UI React Host</title>
</head>
<body>
<div id="root"></div>
<script src="/src/example-host-react.tsx" type="module"></script>
</body>
</html>

View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
#chat-root {
display: flex;
flex-direction: column;
}
</style>
<script src="/src/example-host.ts" type="module"></script>
<title>Example MCP View Host</title>
</head>
<body>
<h1>Example MCP View Host</h1>
<div id="controls"></div>
<div id="chat-root"></div>
</body>
</html>

View file

@ -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"
}
}

View file

@ -0,0 +1,48 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<!-- Permissive CSP so nested content is not constrained by host CSP -->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
img-src * data: blob: 'unsafe-inline';
media-src * blob: data:;
font-src * blob: data:;
script-src 'self'
'wasm-unsafe-eval'
'unsafe-inline'
'unsafe-eval'
blob: data: http://localhost:* https://localhost:*;
style-src * blob: data: 'unsafe-inline';
connect-src *;
frame-src * blob: data: http://localhost:* https://localhost:*;
base-uri 'self';
" />
<title>MCP-UI Proxy</title>
<style>
html,
body {
margin: 0;
height: 100vh;
width: 100vw;
}
body {
display: flex;
flex-direction: column;
}
* {
box-sizing: border-box;
}
iframe {
background-color: transparent;
border: 0px none transparent;
padding: 0px;
overflow: hidden;
flex-grow: 1;
}
</style>
</head>
<body>
<script type="module" src="/src/sandbox.ts"></script>
</body>
</html>

View file

@ -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()

View file

@ -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<void>;
}> {
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<void>((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<ToolUiResourceInfo | null> {
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<string> {
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;
}

View file

@ -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<Client | null>(null);
const [tools, setTools] = useState<Tool[]>([]);
const [activeTools, setActiveTools] = useState<
Array<{ id: string; name: string; input: Record<string, unknown> }>
>([]);
const [error, setError] = useState<string | null>(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<string, unknown>,
) => {
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 (
<div style={{ padding: "20px" }}>
<h1>Example MCP-UI React Host</h1>
<div
style={{
color: "red",
padding: "10px",
border: "1px solid red",
borderRadius: "4px",
}}
>
<strong>Error:</strong> {error}
</div>
<button onClick={() => window.location.reload()}>Retry</button>
</div>
);
}
if (!client) {
return (
<div style={{ padding: "20px" }}>
<h1>Example MCP-UI React Host</h1>
<p>Connecting to MCP server...</p>
</div>
);
}
return (
<div style={{ padding: "20px", fontFamily: "system-ui, sans-serif" }}>
<h1>Example MCP-UI React Host</h1>
<div
style={{
marginBottom: "20px",
padding: "15px",
backgroundColor: "#f5f5f5",
borderRadius: "4px",
}}
>
<h2 style={{ marginTop: 0 }}>Available Tools</h2>
{tools.length === 0 ? (
<p>No tools available with UI resources.</p>
) : (
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
{tools.map((tool) => (
<button
key={tool.name}
onClick={() => {
// 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}
</button>
))}
</div>
)}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
{activeTools.map((tool) => (
<div
key={tool.id}
style={{
padding: "10px",
border: "2px solid #ddd",
borderRadius: "8px",
position: "relative",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "10px",
}}
>
<h3 style={{ margin: 0 }}>{tool.name}</h3>
<button
onClick={() => handleRemoveToolUI(tool.id)}
style={{
padding: "4px 12px",
backgroundColor: "#dc3545",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
}}
>
Remove
</button>
</div>
<UITemplatedToolCallRenderer
sandboxProxyUrl={SANDBOX_PROXY_URL}
client={client}
toolName={tool.name}
toolInput={tool.input}
onUIAction={(action) => handleUIAction(tool.id, action)}
onError={(err) => handleError(tool.id, err)}
/>
</div>
))}
{activeTools.length === 0 && (
<p style={{ color: "#666", textAlign: "center", padding: "40px" }}>
No tool UIs active. Click a button above to add one.
</p>
)}
</div>
</div>
);
}
// Mount the React app
const root = document.getElementById("root");
if (root) {
createRoot(root).render(<ExampleApp />);
} else {
console.error("Root element not found");
}

View file

@ -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<string, unknown>,
) {
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!" }),
}),
);
}
});

View file

@ -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<AppBridge["setRequestHandler"]>[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<string, unknown>;
/** Optional result from tool execution to pass to the tool UI once it's ready */
toolResult?: CallToolResult;
onopenlink?: (
params: McpUiOpenLinkRequest["params"],
extra: RequestExtra,
) => Promise<McpUiOpenLinkResult>;
onmessage?: (
params: McpUiMessageRequest["params"],
extra: RequestExtra,
) => Promise<McpUiMessageResult>;
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
* <UITemplatedToolCallRenderer
* sandboxProxyUrl={new URL('http://localhost:8765/sandbox_proxy.html')}
* client={mcpClient}
* toolName="create-chart"
* toolInput={{ data: [1, 2, 3], type: 'bar' }}
* onUIAction={async (action) => {
* 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<AppBridge | null>(null);
const [iframeReady, setIframeReady] = useState(false);
const [error, setError] = useState<Error | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(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 (
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
}}
>
{error && (
<div style={{ color: "red", padding: "1rem" }}>
Error: {error.message}
</div>
)}
</div>
);
};

View file

@ -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: {},
},
"*",
);

View file

@ -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"]
}

View file

@ -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,
},
});

View file

@ -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"

View file

@ -8,7 +8,7 @@
* <code>
* npx esbuild src/ui-raw.ts --bundle --outfile=dist/ui-raw.js --minify --sourcemap --platform=browser
* </code>
*
*
* 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).

View file

@ -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,

View file

@ -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",