Skip to content

Instantly share code, notes, and snippets.

@ochafik
Last active November 21, 2025 19:23
Show Gist options
  • Select an option

  • Save ochafik/a9603ba2d6757d6038ce066eded4c354 to your computer and use it in GitHub Desktop.

Select an option

Save ochafik/a9603ba2d6757d6038ce066eded4c354 to your computer and use it in GitHub Desktop.
MCP-UI v2 Prototype
node_modules/
dist/
.DS_Store
old/
intermediate-outputs/

Simple SDK for an MCP-UI update that would use MCP messages for its iframe / host interface.

This makes it easy to expose more than tools to the UI (which gets fuller access to its server!), and relies mostly on the TypeScript SDK for the request / response mechanics.

Examples

Vanilla JavaScript Host

git clone https://gist.github.com/ochafik/a9603ba2d6757d6038ce066eded4c354 mcp-ui-sdk
cd mcp-ui-sdk
npm i
npm start
open http://localhost:8000/dist/example-host.html

React Host (using UITemplatedToolCallRenderer component)

npm start
open http://localhost:8000/dist/example-host-react.html

Development mode

# No minification, and no inlining of html - no dist/
NODE_ENV=development && npm start && open http://localhost:8000/example-host.html
NODE_ENV=development && npm start && open http://localhost:8000/example-host-react.html

TODO:

  • add webpack build example config (w/ builtin html inline)
  • (Later) Stick to low-level interface
sandbox.html
    on load:
        parent.postMessage({
            method: sandbox-proxy-ready
        })
    on sandbox-proxy-resource-ready from parent:
        inner iframe.srcdoc = resource

    pipes messages from parent <-> inner iframe
import { McpUiSandboxProxyReadyNotificationSchema } from "./app";
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" || t === "text/html+skybridge";
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;
}
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { ZodLiteral, ZodObject } from "zod";
import {
type McpUiToolInputNotification,
type McpUiToolResultNotification,
type McpUiSandboxResourceReadyNotification,
type McpUiSizeChangeNotification,
LATEST_PROTOCOL_VERSION,
McpUiAppCapabilities,
McpUiHostCapabilities,
McpUiInitializedNotificationSchema,
McpUiInitializeRequest,
McpUiInitializeRequestSchema,
McpUiInitializeResult,
McpUiResourceTeardownRequest,
McpUiResourceTeardownRequestSchema,
} from "./app";
export { LATEST_PROTOCOL_VERSION } from "./app";
export { PostMessageTransport } from "./app";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import {
CallToolRequestSchema,
CallToolResultSchema,
Implementation,
ListPromptsRequestSchema,
ListPromptsResultSchema,
ListResourcesRequestSchema,
ListResourcesResultSchema,
ListResourceTemplatesRequestSchema,
ListResourceTemplatesResultSchema,
Notification,
PingRequestSchema,
PromptListChangedNotificationSchema,
ResourceListChangedNotificationSchema,
Result,
ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js";
import {
Protocol,
ProtocolOptions,
RequestOptions,
} from "@modelcontextprotocol/sdk/shared/protocol.js";
type HostOptions = ProtocolOptions;
export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION];
export class AppBridge extends Protocol<Request, Notification, Result> {
oninitialized?: () => void;
private _appCapabilities?: McpUiAppCapabilities;
constructor(
private _client: Client,
private _hostInfo: Implementation,
private _capabilities: McpUiHostCapabilities,
options?: HostOptions,
) {
super(options);
this.setRequestHandler(McpUiInitializeRequestSchema, (request) =>
this._oninitialize(request),
);
this.setNotificationHandler(McpUiInitializedNotificationSchema, () =>
this.oninitialized?.(),
);
this.setRequestHandler(PingRequestSchema, (request) => {
console.log("Received ping:", request.params);
return {};
});
}
assertCapabilityForMethod(method: Request["method"]): void {
// TODO
}
assertRequestHandlerCapability(method: Request["method"]): void {
// TODO
}
assertNotificationCapability(method: Notification["method"]): void {
// TODO
}
getCapabilities(): McpUiHostCapabilities {
return this._capabilities;
}
private async _oninitialize(
request: McpUiInitializeRequest,
): Promise<McpUiInitializeResult> {
const requestedVersion = request.params.protocolVersion;
this._appCapabilities = request.params.appCapabilities;
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(
requestedVersion,
)
? requestedVersion
: LATEST_PROTOCOL_VERSION;
return {
protocolVersion,
hostCapabilities: this.getCapabilities(),
hostInfo: this._hostInfo,
hostContext: {
// TODO
},
};
}
sendSizeChange(params: McpUiSizeChangeNotification["params"]) {
return this.notification(<McpUiSizeChangeNotification>{
method: "ui/notifications/size-change",
params,
});
}
sendToolInput(params: McpUiToolInputNotification["params"]) {
return this.notification(<McpUiToolInputNotification>{
method: "ui/notifications/tool-input",
params,
});
}
sendToolResult(params: McpUiToolResultNotification["params"]) {
return this.notification(<McpUiToolResultNotification>{
method: "ui/notifications/tool-result",
params,
});
}
sendSandboxResourceReady(
params: McpUiSandboxResourceReadyNotification["params"],
) {
return this.notification(<McpUiSandboxResourceReadyNotification>{
method: "ui/notifications/sandbox-resource-ready",
params,
});
}
sendResourceTeardown(
params: McpUiResourceTeardownRequest["params"],
options?: RequestOptions,
) {
return this.request(
<McpUiResourceTeardownRequest>{
method: "ui/resource-teardown",
params,
},
McpUiResourceTeardownRequestSchema,
options,
);
}
private forwardRequest<
Req extends ZodObject<{
method: ZodLiteral<string>;
}>,
Res extends ZodObject<{}>,
>(requestSchema: Req, resultSchema: Res) {
this.setRequestHandler(requestSchema, async (request, extra) => {
console.log(`Forwarding request ${request.method} from MCP UI client`);
return this._client.request(request, resultSchema, {
signal: extra.signal,
});
});
}
private forwardNotification<
N extends ZodObject<{ method: ZodLiteral<string> }>,
>(notificationSchema: N) {
this.setNotificationHandler(notificationSchema, async (notification) => {
console.log(
`Forwarding notification ${notification.method} from MCP UI client`,
);
await this._client.notification(notification);
});
}
async connect(transport: Transport) {
// Forward core available MCP features
const serverCapabilities = this._client.getServerCapabilities();
if (!serverCapabilities) {
throw new Error("Client server capabilities not available");
}
if (serverCapabilities.tools) {
this.forwardRequest(CallToolRequestSchema, CallToolResultSchema);
if (serverCapabilities.tools.listChanged) {
this.forwardNotification(ToolListChangedNotificationSchema);
}
}
if (serverCapabilities.resources) {
this.forwardRequest(
ListResourcesRequestSchema,
ListResourcesResultSchema,
);
this.forwardRequest(
ListResourceTemplatesRequestSchema,
ListResourceTemplatesResultSchema,
);
if (serverCapabilities.resources.listChanged) {
this.forwardNotification(ResourceListChangedNotificationSchema);
}
}
if (serverCapabilities.prompts) {
this.forwardRequest(ListPromptsRequestSchema, ListPromptsResultSchema);
if (serverCapabilities.prompts.listChanged) {
this.forwardNotification(PromptListChangedNotificationSchema);
}
}
// MCP-UI specific handlers are registered by the host component
// after the proxy is created. The standard MCP initialization
// (via oninitialized callback set in constructor) handles the ready signal.
return super.connect(transport);
}
}
import {
type RequestOptions,
Protocol,
ProtocolOptions,
} from "@modelcontextprotocol/sdk/shared/protocol.js";
import {
CallToolRequest,
CallToolResult,
CallToolResultSchema,
Implementation,
LoggingMessageNotification,
Notification,
PingRequestSchema,
Request,
Result,
} from "@modelcontextprotocol/sdk/types.js";
import {
LATEST_PROTOCOL_VERSION,
McpUiAppCapabilities,
McpUiHostCapabilities,
McpUiHostInfo,
McpUiInitializedNotification,
McpUiInitializeRequest,
McpUiInitializeResultSchema,
McpUiMessageRequest,
McpUiMessageResultSchema,
McpUiOpenLinkRequest,
McpUiOpenLinkResultSchema,
McpUiSizeChangeNotification,
} from "./types";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
export { PostMessageTransport } from "./message-transport.js";
export * from "./types";
type AppOptions = ProtocolOptions & {
autoResize?: boolean;
};
export class App extends Protocol<Request, Notification, Result> {
private _hostCapabilities?: McpUiHostCapabilities;
private _hostInfo?: McpUiHostInfo;
constructor(
private _appInfo: Implementation,
private _capabilities: McpUiAppCapabilities = {},
private options: AppOptions = { autoResize: true },
) {
super(options);
this.setRequestHandler(PingRequestSchema, (request) => {
console.log("Received ping:", request.params);
return {};
});
}
assertCapabilityForMethod(method: Request["method"]): void {
// TODO
}
assertRequestHandlerCapability(method: Request["method"]): void {
switch (method) {
case "tools/call":
case "tools/list":
if (!this._capabilities.tools) {
throw new Error(
`Client does not support tool capability (required for ${method})`,
);
}
return;
case "ping":
return;
default:
throw new Error(`No handler for method ${method} registered`);
}
}
assertNotificationCapability(method: Notification["method"]): void {
// TODO
}
async callServerTool(
params: CallToolRequest["params"],
options?: RequestOptions,
): Promise<CallToolResult> {
return await this.request(
{ method: "tools/call", params },
CallToolResultSchema,
options,
);
}
sendMessage(params: McpUiMessageRequest["params"], options?: RequestOptions) {
return this.request(
<McpUiMessageRequest>{
method: "ui/message",
params,
},
McpUiMessageResultSchema,
options,
);
}
sendLog(params: LoggingMessageNotification["params"]) {
return this.notification(<LoggingMessageNotification>{
method: "notifications/message",
params,
});
}
sendOpenLink(
params: McpUiOpenLinkRequest["params"],
options?: RequestOptions,
) {
return this.request(
<McpUiOpenLinkRequest>{
method: "ui/open-link",
params,
},
McpUiOpenLinkResultSchema,
options,
);
}
sendSizeChange(params: McpUiSizeChangeNotification["params"]) {
return this.notification(<McpUiSizeChangeNotification>{
method: "ui/notifications/size-change",
params,
});
}
setupSizeChangeNotifications() {
const sendBodySizeChange = () => {
let rafId: number | null = null;
// Debounce using requestAnimationFrame to avoid duplicate messages
// when both documentElement and body fire resize events
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
const { body, documentElement: html } = document;
const bodyStyle = getComputedStyle(body);
const htmlStyle = getComputedStyle(html);
const width = body.scrollWidth;
const height =
body.scrollHeight +
(parseFloat(bodyStyle.borderTop) || 0) +
(parseFloat(bodyStyle.borderBottom) || 0) +
(parseFloat(htmlStyle.borderTop) || 0) +
(parseFloat(htmlStyle.borderBottom) || 0);
this.sendSizeChange({ width, height });
rafId = null;
});
};
sendBodySizeChange();
const resizeObserver = new ResizeObserver(sendBodySizeChange);
// Observe both html and body to catch all size changes
resizeObserver.observe(document.documentElement);
resizeObserver.observe(document.body);
return () => resizeObserver.disconnect();
}
override async connect(
transport: Transport,
options?: RequestOptions,
): Promise<void> {
await super.connect(transport);
try {
const result = await this.request(
<McpUiInitializeRequest>{
method: "ui/initialize",
params: {
appCapabilities: this._capabilities,
appInfo: this._appInfo,
protocolVersion: LATEST_PROTOCOL_VERSION,
},
},
McpUiInitializeResultSchema,
options,
);
if (result === undefined) {
throw new Error(`Server sent invalid initialize result: ${result}`);
}
this._hostCapabilities = result.hostCapabilities;
this._hostInfo = result.hostInfo;
await this.notification(<McpUiInitializedNotification>{
method: "ui/notifications/initialized",
});
if (this.options?.autoResize) {
this.setupSizeChangeNotifications();
}
} catch (error) {
// Disconnect if initialization fails.
void this.close();
throw error;
}
}
}
#!/usr/bin/env tsx
import { execSync } from "child_process";
import * as esbuild from "esbuild";
import { readFile, writeFile } from "fs/promises";
// Run TypeScript compiler for type declarations
execSync("tsc", { stdio: "inherit" });
const isDevelopment = process.env.NODE_ENV === "development";
// Build all JavaScript/TypeScript files
async function buildJs(
entrypoint: string,
opts: { outdir?: string; target?: "browser" | "node" } = {},
) {
return esbuild.build({
entryPoints: [entrypoint],
outdir: opts.outdir ?? "dist",
platform: opts.target === "node" ? "node" : "browser",
format: "esm",
bundle: true,
minify: !isDevelopment,
sourcemap: isDevelopment ? "inline" : false,
});
}
await Promise.all([
// buildJs("src/index.ts", { outdir: "dist/src" }),
// buildJs("useApp.tsx", { outdir: "dist/src/react" }),
buildJs("example-ui-react.tsx", { outdir: "dist/examples" }),
buildJs("example-ui.ts", { outdir: "dist/examples" }),
buildJs("example-host.ts", { outdir: "dist/examples" }),
buildJs("example-host-react.tsx", { outdir: "dist/examples" }),
buildJs("example-server.ts", {
target: "node",
outdir: "dist/examples",
}),
]);
async function inlineHtml(htmlFilePath: string, outputFilePath: string) {
let html = await readFile(htmlFilePath, "utf-8");
// Find all src sources, then fetch them, then replace them
const files = html.matchAll(
/<script src="((?:\.\/)?dist\/.*?\.js)"><\/script>/g,
);
const fileContent = Object.fromEntries(
await Promise.all(
Array.from(files, async ([, filename]) => {
const scriptContent = await readFile(filename, "utf-8");
return [filename, scriptContent];
}),
),
);
html = html.replace(
/<script src="((?:\.\/)?dist\/.*?\.js)"><\/script>/g,
(_, filename) => {
let scriptContent = fileContent[filename];
// Escape </script> to prevent premature script tag closing
// This is the standard way to inline scripts safely
scriptContent = scriptContent.replace(/<\/script>/gi, "<\\/script>");
return `<script>${scriptContent}</script>`;
},
);
await writeFile(outputFilePath, html);
}
await inlineHtml("example-ui.html", "dist/example-ui.html");
await inlineHtml(
"example-ui-react.html",
"dist/example-ui-react.html",
);
<!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="dist/examples/example-host-react.js"></script>
</body>
</html>
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 "./mcp-ui-types.js";
import { UITemplatedToolCallRenderer } from "./UITemplatedToolCallRenderer.js";
// Create sandboxProxyUrl at module level to avoid recreating on every render
const SANDBOX_PROXY_URL = new URL("http://localhost:8765/sandbox_proxy.html");
/**
* 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");
}
<!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="./dist/examples/example-host.js"></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>
import {
setupSandboxProxyIframe,
getToolUiResourceUri,
readToolUiResourceHtml,
} from "./app-host-utils.js";
import { AppBridge, PostMessageTransport } from "./app-bridge.js";
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 "./app";
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 sandboxProxyUrl = new URL("http://localhost:8765/sandbox_proxy.html");
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(sandboxProxyUrl);
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,
// usesOpenAiAppsSdk: resourceInfo.usesOpenAiAppsSdk,
// autoNotifySizeChanges: true,
});
// Note: usesOpenAiAppsSdk compatibility layer is already injected into html by readToolUiResourceHtml
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!" }),
}),
);
}
});
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { type Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import express, { Request, Response } from "express";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
CallToolResult,
GetPromptResult,
isInitializeRequest,
PrimitiveSchemaDefinition,
ReadResourceResult,
Resource,
ResourceLink,
} from "@modelcontextprotocol/sdk/types.js";
import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js";
import cors from "cors";
import { text } from "node:stream/consumers";
import path from "node:path";
import fs from "node:fs/promises";
// const isProduction = process.env.NODE_ENV !== 'development';
const exampleUiPath = __filename.endsWith(".ts")
? path.join(__dirname, "dist", "example-ui.html")
: path.join(__dirname, "example-server.html");
const exampleUiHtml = await fs.readFile(exampleUiPath, "utf-8");
// Create an MCP server with implementation details
const getServer = async () => {
const server = new McpServer(
{
name: "example-mcp-ui-server",
version: "1.0.0",
icons: [
{ src: "./mcp.svg", sizes: ["512x512"], mimeType: "image/svg+xml" },
],
websiteUrl: "https://github.com/modelcontextprotocol/typescript-sdk",
},
{ capabilities: { logging: {} } },
);
const exampleUiTemplateResource: Resource = {
name: "example-ui-template",
uri: "ui://example-ui-template",
title: "Greeting Template",
description: "A simple greeting UI",
mimeType: "text/html",
};
server.registerResource(
exampleUiTemplateResource.name,
exampleUiTemplateResource.uri,
exampleUiTemplateResource,
async (): Promise<ReadResourceResult> => {
return {
contents: [
{
uri: exampleUiTemplateResource.uri,
mimeType: exampleUiTemplateResource.mimeType,
text: exampleUiHtml,
},
],
};
},
);
server.registerTool(
"create-example-ui",
{
title: "Example UI",
description: "A tool that returns a simple UI",
inputSchema: {
message: z.string().describe("Name to greet"),
},
_meta: {
"ui/resourceUri": exampleUiTemplateResource.uri,
},
},
async ({ message }): Promise<CallToolResult> => {
const structuredContent = {
message,
};
return {
// content: [],
content: [
{
type: "text",
text: JSON.stringify(structuredContent),
},
],
structuredContent,
};
},
);
server.registerTool(
"get-weather",
{
title: "Get Weather",
description:
"A tool that returns the current weather for a given location",
inputSchema: {
location: z.string().describe("Location to get the weather for"),
},
},
async ({ location }): Promise<CallToolResult> => {
// For simplicity, return a fixed weather report
const temperature = 25; // Fixed temperature
const condition = "sunny";
const weatherReport = `The current weather in ${location} is ${condition} with a temperature of ${temperature}°C.`;
const structuredContent = {
temperature,
condition,
};
return {
content: [
{
type: "text",
text: weatherReport,
},
],
structuredContent,
};
},
);
return server;
};
const MCP_PORT = process.env.MCP_PORT
? parseInt(process.env.MCP_PORT, 10)
: 3001;
const MCP_HOST = process.env.MCP_HOST || "localhost";
const app = express();
app.use(express.json());
// Allow CORS all domains, expose the Mcp-Session-Id header
app.use(
cors({
origin: "*", // Allow all origins
exposedHeaders: ["Mcp-Session-Id"],
}),
);
// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
const mcpPostHandler = async (req: Request, res: Response) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (sessionId) {
console.log(`Received MCP request for session: ${sessionId}`);
} else {
console.log("Request body:", req.body);
}
try {
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
// Reuse existing transport
transport = transports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request
const eventStore = new InMemoryEventStore();
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore, // Enable resumability
onsessioninitialized: (sessionId) => {
// Store the transport by session ID when session is initialized
// This avoids race conditions where requests might come in before the session is stored
console.log(`Session initialized with ID: ${sessionId}`);
transports[sessionId] = transport;
},
});
// Set up onclose handler to clean up transport when closed
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && transports[sid]) {
console.log(
`Transport closed for session ${sid}, removing from transports map`,
);
delete transports[sid];
}
};
// Connect the transport to the MCP server BEFORE handling the request
// so responses can flow back through the same transport
const server = await getServer();
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
return; // Already handled
} else {
// Invalid request - no session ID or not initialization request
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: No valid session ID provided",
},
id: null,
});
return;
}
// Handle the request with existing transport - no need to reconnect
// The existing transport is already connected to the server
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error("Error handling MCP request:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Internal server error",
},
id: null,
});
}
}
};
app.post("/mcp", mcpPostHandler);
// Handle GET requests for SSE streams (using built-in support from StreamableHTTP)
const mcpGetHandler = async (req: Request, res: Response) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send("Invalid or missing session ID");
return;
}
// Check for Last-Event-ID header for resumability
const lastEventId = req.headers["last-event-id"] as string | undefined;
if (lastEventId) {
console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
} else {
console.log(`Establishing new SSE stream for session ${sessionId}`);
}
const transport = transports[sessionId];
await transport.handleRequest(req, res);
};
app.get("/mcp", mcpGetHandler);
// Handle DELETE requests for session termination (according to MCP spec)
const mcpDeleteHandler = async (req: Request, res: Response) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send("Invalid or missing session ID");
return;
}
console.log(`Received session termination request for session ${sessionId}`);
try {
const transport = transports[sessionId];
await transport.handleRequest(req, res);
} catch (error) {
console.error("Error handling session termination:", error);
if (!res.headersSent) {
res.status(500).send("Error processing session termination");
}
}
};
app.delete("/mcp", mcpDeleteHandler);
app.listen(MCP_PORT, (error) => {
if (error) {
console.error("Failed to start server:", error);
process.exit(1);
}
console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`);
});
// Handle server shutdown
process.on("SIGINT", async () => {
console.log("Shutting down server...");
// Close all active transports to properly clean up resources
for (const sessionId in transports) {
try {
console.log(`Closing transport for session ${sessionId}`);
await transports[sessionId].close();
delete transports[sessionId];
} catch (error) {
console.error(`Error closing transport for session ${sessionId}:`, error);
}
}
console.log("Server shutdown complete");
process.exit(0);
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP UI Client (React)</title>
</head>
<body>
<div id="root"></div>
<script src="./dist/examples/example-ui-react.js"></script>
</body>
</html>
import { useState, useCallback } from "react";
import { createRoot } from "react-dom/client";
import { useApp } from "./useApp";
import type {
CallToolResult,
Implementation,
} from "@modelcontextprotocol/sdk/types.js";
import {
McpUiSizeChangeNotificationSchema,
McpUiToolResultNotificationSchema,
} from "./types";
const APP_INFO: Implementation = {
name: "MCP UI React Example Client",
version: "1.0.0",
protocolVersion: "2025-06-18",
};
export function McpClientApp() {
const [toolResults, setToolResults] = useState<CallToolResult[]>([]);
const [messages, setMessages] = useState<string[]>([]);
const { app, isConnected, error } = useApp({
appInfo: APP_INFO,
capabilities: {},
onAppCreated: (app) => {
app.setNotificationHandler(
McpUiToolResultNotificationSchema,
async (notification) => {
setToolResults((prev) => [...prev, notification.params]);
},
);
app.setNotificationHandler(
McpUiSizeChangeNotificationSchema,
async (notification) => {
document.body.style.width = `${notification.params.width}px`;
document.body.style.height = `${notification.params.height}px`;
},
);
},
});
const handleGetWeather = useCallback(async () => {
if (!app) return;
try {
const result = await app.callServerTool({
name: "get-weather",
arguments: { city: "Tokyo" },
});
setMessages((prev) => [
...prev,
`Weather tool result: ${JSON.stringify(result)}`,
]);
} catch (e) {
setMessages((prev) => [...prev, `Tool call error: ${e}`]);
}
}, [app]);
const handleNotifyCart = useCallback(async () => {
if (!app) return;
await app.sendLog({ level: "info", data: "cart-updated" });
setMessages((prev) => [...prev, "Notification sent: cart-updated"]);
}, [app]);
const handlePromptWeather = useCallback(async () => {
if (!app) return;
const signal = AbortSignal.timeout(5000);
try {
const { isError } = await app.sendMessage(
{
role: "user",
content: [
{
type: "text",
text: "What is the weather in Tokyo?",
},
],
},
{ signal },
);
setMessages((prev) => [
...prev,
`Prompt result: ${isError ? "error" : "success"}`,
]);
} catch (e) {
if (signal.aborted) {
setMessages((prev) => [...prev, "Prompt request timed out"]);
return;
}
setMessages((prev) => [...prev, `Prompt error: ${e}`]);
}
}, [app]);
const handleOpenLink = useCallback(async () => {
if (!app) return;
const { isError } = await app.sendOpenLink({
url: "https://www.google.com",
});
setMessages((prev) => [
...prev,
`Open link result: ${isError ? "error" : "success"}`,
]);
}, [app]);
if (error) {
return (
<div style={{ color: "red" }}>Error connecting: {error.message}</div>
);
}
if (!isConnected) {
return <div>Connecting...</div>;
}
return (
<div style={{ padding: "20px", fontFamily: "system-ui, sans-serif" }}>
<h1>MCP UI Client (React)</h1>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "10px",
marginBottom: "20px",
}}
>
<button onClick={handleGetWeather}>Get Weather (Tool)</button>
<button onClick={handleNotifyCart}>Notify Cart Updated</button>
<button onClick={handlePromptWeather}>Prompt Weather in Tokyo</button>
<button onClick={handleOpenLink}>Open Link to Google</button>
</div>
{toolResults.length > 0 && (
<div style={{ marginBottom: "20px" }}>
<h2>Tool Results:</h2>
{toolResults.map((result, i) => (
<div
key={i}
style={{
padding: "10px",
marginBottom: "10px",
backgroundColor: result.isError ? "#fee" : "#efe",
border: "1px solid #ccc",
borderRadius: "4px",
}}
>
<strong>isError:</strong> {String(result.isError ?? false)}
<br />
<strong>content:</strong> {JSON.stringify(result.content)}
<br />
{result.structuredContent && (
<>
<strong>structuredContent:</strong>{" "}
{JSON.stringify(result.structuredContent)}
</>
)}
</div>
))}
</div>
)}
{messages.length > 0 && (
<div>
<h2>Messages:</h2>
{messages.map((msg, i) => (
<div
key={i}
style={{
padding: "8px",
marginBottom: "5px",
backgroundColor: "#f5f5f5",
border: "1px solid #ddd",
borderRadius: "4px",
}}
>
{msg}
</div>
))}
</div>
)}
</div>
);
}
window.addEventListener("load", () => {
const root = document.getElementById("root");
if (!root) {
throw new Error("Root element not found");
}
createRoot(root).render(<McpClientApp />);
});
<script src="./dist/examples/example-ui.js"></script>
<div id="root"></div>
import { App, PostMessageTransport } from "./app.js";
import {
McpUiToolInputNotificationSchema,
McpUiSizeChangeNotificationSchema,
McpUiToolResultNotificationSchema,
} from "./types.js";
window.addEventListener("load", async () => {
const root = document.getElementById("root")!;
const appendText = (textContent: string, opts = {}) => {
root.appendChild(
Object.assign(document.createElement("div"), {
textContent,
...opts,
}),
);
};
const appendError = (error: unknown) =>
appendText(
`Error: ${error instanceof Error ? error.message : String(error)}`,
{ style: "color: red;" },
);
const transport = new PostMessageTransport(window.parent);
const app = new App({
name: "MCP UI Client",
version: "1.0.0",
});
app.setNotificationHandler(
McpUiToolResultNotificationSchema,
async ({ params: { content, structuredContent, isError } }) => {
appendText(
`Tool call result received: isError=${isError}, content=${content}, structuredContent=${JSON.stringify(structuredContent)}`,
);
},
);
app.setNotificationHandler(
McpUiSizeChangeNotificationSchema,
async ({ params: { width, height } }) => {
appendText(
`Size change notification received: width=${width}, height=${height}`,
);
},
);
app.setNotificationHandler(
McpUiToolInputNotificationSchema,
async ({ params }) => {
appendText(
`Tool call input received: ${JSON.stringify(params.arguments)}`,
);
},
);
document.body.addEventListener("resize", () => {
app.sendSizeChange({
width: document.body.scrollWidth,
height: document.body.scrollHeight,
});
});
root.appendChild(
Object.assign(document.createElement("button"), {
textContent: "Get Weather (Tool)",
onclick: async () => {
const signal = AbortSignal.timeout(5000);
try {
const result = await app.callServerTool({
name: "get-weather",
arguments: { location: "Tokyo" },
});
appendText(`Weather tool result: ${JSON.stringify(result)}`);
} catch (e) {
appendError(e);
}
},
}),
);
root.appendChild(
Object.assign(document.createElement("button"), {
textContent: "Notify Cart Updated",
onclick: async () => {
try {
await app.sendLog({
level: "info",
data: "cart-updated",
});
} catch (e) {
appendError(e);
}
},
}),
);
root.appendChild(
Object.assign(document.createElement("button"), {
textContent: "Prompt Weather in Tokyo",
onclick: async () => {
const signal = AbortSignal.timeout(5000);
try {
const { isError } = await app.sendMessage(
{
role: "user", // Forced.
content: [
{
type: "text",
text: "What is the weather in Tokyo?",
},
],
},
{ signal },
);
appendText(`Prompt result: ${isError ? "error" : "success"}`);
} catch (e) {
if (signal.aborted) {
appendError("Prompt request timed out");
return;
}
appendError(e);
}
},
}),
);
root.appendChild(
Object.assign(document.createElement("button"), {
textContent: "Open Link to Google",
onclick: async () => {
try {
const { isError } = await app.sendOpenLink({
url: "https://www.google.com",
});
appendText(`Open link result: ${isError ? "error" : "success"}`);
} catch (e) {
appendError(e);
}
},
}),
);
await app.connect(transport);
});
#!/bin/bash
set -euo pipefail
# Copy from MCP App SDK
# cp ../ext-apps/examples/example-server.ts .
# cp ../ext-apps/examples/example-ui-react.tsx .
# cp ../ext-apps/examples/example-ui-react.html .
# cp ../ext-apps/examples/example-ui.ts .
# cp ../ext-apps/examples/example-ui.html .
cp ../ext-apps/src/app.ts .
cp ../ext-apps/src/app-bridge.ts .
cp ../ext-apps/src/types.ts .
cp ../ext-apps/src/message-transport.ts .
cp ../ext-apps/src/react/index.tsx .
cp ../ext-apps/src/react/useApp.tsx .
cp ../ext-apps/src/react/useAutoResize.ts .
# Copy from MCP Apps Host SDK
cp ../mcp-apps-host-sdk/examples/example-host.ts .
cp ../mcp-apps-host-sdk/examples/example-host.html .
cp ../mcp-apps-host-sdk/examples/example-host-react.tsx .
cp ../mcp-apps-host-sdk/examples/example-host-react.html .
cp ../mcp-apps-host-sdk/src/app-host-utils.ts .
cp ../mcp-apps-host-sdk/src/react/UITemplatedToolCallRenderer.tsx .
cp ../mcp-apps-host-sdk/src/sandbox_proxy.py .
cp ../mcp-apps-host-sdk/src/sandbox_proxy.html .
cp ../mcp-apps-host-sdk/src/mcp-ui-types.ts .
# Flatten all imports to current directory
node -e "
const fs = require('fs');
const path = require('path');
// Find all .ts and .tsx files in current directory
const files = fs.readdirSync('.').filter(f => /\.tsx?$/.test(f) && fs.statSync(f).isFile());
for (const file of files) {
let content = fs.readFileSync(file, 'utf8');
const original = content;
// Replace @modelcontextprotocol/ext-apps with ./app
content = content.replace(/@modelcontextprotocol\/ext-apps/g, './app');
// Flatten nested relative imports: ../src/foo/bar/baz.js -> ./baz.js
content = content.replace(/from ['\"](\\.\\.\\/)+src(\\/[\\w-]+)*\\/([\\w-]+(?:\\.[\\w]+)?)['\"]/g, 'from \"./\$3\"');
// Flatten any remaining ../xxx.js imports to ./xxx.js
content = content.replace(/from ['\"]\\.\\.\\/([\\w-]+(?:\\.[\\w]+)?)['\"]/g, 'from \"./\$1\"');
if (content !== original) {
fs.writeFileSync(file, content);
console.log('Updated:', file);
}
}
"
type GenericActionMessage = {
messageId?: string;
};
export type UIActionResultToolCall = GenericActionMessage & {
type: "tool";
payload: {
toolName: string;
params: Record<string, unknown>;
};
};
export type UIActionResultPrompt = GenericActionMessage & {
type: "prompt";
payload: {
prompt: string;
};
};
export type UIActionResultLink = GenericActionMessage & {
type: "link";
payload: {
url: string;
};
};
export type UIActionResultIntent = GenericActionMessage & {
type: "intent";
payload: {
intent: string;
params: Record<string, unknown>;
};
};
export type UIActionResultNotification = GenericActionMessage & {
type: "notify";
payload: {
message: string;
};
};
export type UIActionResult =
| UIActionResultToolCall
| UIActionResultPrompt
| UIActionResultLink
| UIActionResultIntent
| UIActionResultNotification;
import {
JSONRPCMessage,
JSONRPCMessageSchema,
MessageExtraInfo,
} from "@modelcontextprotocol/sdk/types.js";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import {
Transport,
TransportSendOptions,
} from "@modelcontextprotocol/sdk/shared/transport.js";
import { z } from "zod";
export class PostMessageTransport implements Transport {
private messageListener: (
this: Window,
ev: WindowEventMap["message"],
) => any | undefined;
constructor(
private eventTarget: Window = window.parent,
private eventSource?: MessageEventSource,
) {
this.messageListener = (event) => {
if (eventSource && event.source !== this.eventSource) {
console.error("Ignoring message from unknown source", event);
return;
}
const parsed = JSONRPCMessageSchema.safeParse(event.data);
if (parsed.success) {
console.info("[host] Parsed message", parsed.data);
this.onmessage?.(parsed.data);
} else {
console.error("Failed to parse message", parsed.error.message, event);
this.onerror?.(
new Error(
"Invalid JSON-RPC message received: " + parsed.error.message,
),
);
}
};
}
async start() {
window.addEventListener("message", this.messageListener);
}
async send(message: JSONRPCMessage, options?: TransportSendOptions) {
console.info("[host] Sending message", message);
this.eventTarget.postMessage(message, "*");
}
async close() {
window.removeEventListener("message", this.messageListener);
this.onclose?.();
}
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
sessionId?: string;
setProtocolVersion?: (version: string) => void;
}
{
"homepage": "https://gist.github.com/a9603ba2d6757d6038ce066eded4c354",
"name": "@ant/mcp-ui-sdk",
"version": "1.0.0",
"description": "Hey",
"type": "module",
"main": "./dist/mcp-ui-client.js",
"types": "./dist/mcp-ui-client.d.ts",
"exports": {
"./mcp-ui-client.js": {
"types": "./dist/mcp-ui-client.d.ts",
"default": "./dist/mcp-ui-client.js"
},
"./mcp-ui-host.js": {
"types": "./dist/mcp-ui-host.d.ts",
"default": "./dist/mcp-ui-host.js"
},
"./mcp-ui-proxy-server.js": {
"types": "./dist/mcp-ui-proxy-server.d.ts",
"default": "./dist/mcp-ui-proxy-server.js"
},
"./mcp-ui-types": {
"types": "./dist/mcp-ui-types.d.ts"
},
"./example-ui.js": "./dist/example-ui.js",
"./example-react-ui.js": "./dist/example-react-ui.js",
"./example-host.js": "./dist/example-host.js",
"./example-server.js": "./dist/example-server.js"
},
"files": [
"dist/**/*.js",
"dist/**/*.d.ts"
],
"scripts": {
"bun": "bun",
"start:example-host": "python3 -m http.server 8000",
"start:example-server": "bun run example-server.ts",
"start:example-server1": "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 && concurrently \"npm run start:example-server\" \"npm run start:example-host\" \"npm run start:sandbox-proxy\"",
"start:dev": "NODE_ENV npm start",
"build": "bun build.ts",
"prepare": "npm run build",
"test": "bun test"
},
"author": "Olivier Chafik",
"devDependencies": {
"@mcp-ui/client": "^5.13.0",
"@mcp-ui/server": "^5.12.0",
"@types/bun": "^1.3.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"bun": "^1.3.1",
"concurrently": "^9.2.1",
"esbuild": "^0.25.12",
"typescript": "^5.9.3"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.20.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
}
<!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: https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://unpkg.com http://localhost:* https://localhost:*; style-src * blob: data: 'unsafe-inline'; connect-src *; frame-src * blob: data: http://localhost:* https://localhost:* http://127.0.0.1:* https://127.0.0.1:*; 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>
// 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: {}
}, '*');
</script>
</body>
</html>
#!/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
PORT = 8765
# Serve from the current directory where this script is located
DIRECTORY = os.path.dirname(os.path.abspath(__file__))
class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=DIRECTORY, **kwargs)
def end_headers(self):
# 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():
# Change to the proxy directory
if not os.path.exists(DIRECTORY):
print(f"Error: Directory '{DIRECTORY}' does not exist!")
sys.exit(1)
print(f"Serving MCP-UI proxy from: {DIRECTORY}")
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()

SEP-...: MCP-UI: Interactive User Interfaces for MCP

  • Track: Extensions
  • Authors: Ido Salomon, Liad Yosef, Olivier Chafik, Jonathan Hefner, Anton Pidkuiko
  • Status: Draft
  • Created: 2025-11-11

Abstract

This SEP proposes an extension (per SEP-1724) to MCP that enables servers to deliver interactive user interfaces to hosts. MCP-UI introduces a standardized pattern for declaring UI resources via the ui:// URI scheme, associating them with tools through metadata, and facilitating bidirectional communication between the UI and the host using MCP's JSON-RPC base protocol. This extension addresses the growing community need for rich, interactive experiences in MCP-enabled applications, maintaining security, auditability, and alignment with MCP's core architecture. The initial specification focuses on HTML resources (text/html) with a clear path for future extensions.

Motivation

MCP lacks a standardized way for servers to deliver rich, interactive user interfaces to hosts. This gap blocks many use cases that require visual presentation and interactivity that goes beyond plain text or structured data. As more hosts adopt this capability, there's a growing risk of fragmentation and interoperability challenges.

The MCP-UI project has demonstrated the viability and value of UI resources, and served as a community playground for the UI spec and SDK. MCP-UI currently supports multiple rendering modes (HTML, external URLs, Remote DOM), has SDKs for TypeScript, Python, and Ruby, and has been adopted by multiple hosts, including Postman, Goose, LibreChat, and more.

However, without formal standardization:

  • Servers cannot reliably expect UI support
  • Each host may implement slightly different behaviors
  • Security and auditability patterns are inconsistent

This SEP addresses the current limitations through an optional, backwards-compatible extension.

Specification

Extension Identifier

This extension is identified as: modelcontextprotocol.io/ui

Overview

MCP-UI extends the Model Context Protocol to enable servers to deliver interactive user interfaces to hosts. This extension introduces:

  1. UI Resources: Predeclared resources using the ui:// URI scheme
  2. Tool-UI Linkage: Tools reference UI resources via metadata
  3. Bidirectional Communication: UI iframes communicate with hosts using standard MCP JSON-RPC protocol
  4. Security Model: Mandatory iframe sandboxing with auditable communication

This specification focuses on HTML content (text/html) as the initial content type, with extensibility for future formats.

As an extension, MCP-UI is optional and must be explicitly negotiated between clients and servers through the extension capabilities mechanism (see Capability Negotiation section).

UI Resource Format

UI resources are declared using the standard MCP resource pattern with specific conventions:

interface UIResource {
  uri: string;           // MUST start with 'ui://'
  name: string;          // Human-readable identifier
  description?: string;  // Description of the UI resource
  mimeType: string;      // SHOULD be 'text/html' in MVP
}

The resource content is returned via resources/read:

// resources/read response for UI resource
{
  contents: [{
    uri: string;                  // Matching UI resource URI
    mimeType: "text/html";        // MUST be "text/html"
    text?: string;                // HTML content as string
    blob?: string;                // OR base64-encoded HTML
    _meta?: {
      "mcp-ui/widgetCSP"?: {
        connect_domains?: string[];   // Origins for fetch/XHR/WebSocket
        resource_domains?: string[];  // Origins for images, scripts, styles
      };
      "mcp-ui/widgetDomain"?: string;	
      "mcp-ui/widgetPrefersBorder"?: boolean;
    };
  }];
}

Content Requirements:

  • URI MUST start with ui:// scheme
  • mimeType MUST be text/html (other types reserved for future extensions)
  • Content MUST be provided via either text (string) or blob (base64-encoded)
  • Content MUST be valid HTML5 document

Metadata Fields:

mcp-ui/widgetCSP - Content Security Policy configuration

Servers declare which external origins their UI needs to access. Hosts use this to enforce appropriate CSP headers.

  • connect_domains: Origins for network requests

    • Example: ["https://api.weather.com", "wss://realtime.service.com"]
    • Empty or omitted = no external connections (secure default)
    • Maps to CSP connect-src directive
  • resource_domains: Origins for static resources (images, scripts, stylesheets, fonts)

    • Example: ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"]
    • Empty or omitted = no external resources (secure default)
    • Wildcard subdomains supported: https://*.example.com
    • Maps to CSP img-src, script-src, style-src, font-src directives

mcp-ui/widgetDomain - Dedicated origin for widget

Optional domain for the widget's sandbox origin. Useful when widgets need dedicated origins for API key allowlists or cross-origin isolation.

  • Example: "https://weather-widget.example.com"
  • If omitted, Host uses default sandbox origin

mcp-ui/widgetPrefersBorder - Visual boundary preference

Boolean indicating the UI prefers a visible border. Useful for widgets that might blend with host background.

  • true: Request visible border (host decides styling)
  • false or omitted: No preference

Host Behavior:

  1. CSP Enforcement: Host MUST construct CSP headers based on declared domains
  2. Restrictive Default: If mcp-ui/widgetCSP is omitted, Host MUST use:
    default-src 'none';
    script-src 'self' 'unsafe-inline';
    style-src 'self' 'unsafe-inline';
    img-src 'self' data:;
    connect-src 'none';
    
  3. No Loosening: Host MAY further restrict but MUST NOT allow undeclared domains
  4. Audit Trail: Host SHOULD log CSP configurations for security review

Example:

// Resource declaration
{
  uri: "ui://weather-server/dashboard-template",
  name: "weather_dashboard",
  description: "Interactive weather dashboard widget",
  mimeType: "text/html"
}

// Resource content with metadata
{
  contents: [{
    uri: "ui://weather-server/dashboard-template",
    mimeType: "text/html",
    text: "<!DOCTYPE html><html>...</html>",
    _meta: {
      "mcp-ui/widgetCSP": {
        connect_domains: ["https://api.openweathermap.org"],
        resource_domains: ["https://cdn.jsdelivr.net"]
      },
      "mcp-ui/widgetPrefersBorder": true
    }
  }]
}

Resource Registration

UI resources MUST be registered and discoverable via the standard MCP resource discovery pattern:

  1. Server declares UI resources in resources/list response
  2. Host can enumerate available UI resources before tool execution
  3. Host can prefetch UI resource content via resources/read
  4. Host can cache UI resources for performance optimization

Benefits:

  • Performance: Host can preload templates before tool execution
  • Security: Host can review UI templates during connection setup
  • Caching: Separate template (static) from data (dynamic)
  • Auditability: All UI resources are enumerable and inspectable

Tool Metadata

Tools associate with UI resources through the _meta field:

interface Tool {
  name: string;
  description: string;
  inputSchema: object;
  _meta?: {
    // Required: URI of the UI resource to use for rendering
    "mcp-ui/resourceUri"?: string;
  };
}

Example:

{
  name: "get_weather",
  description: "Get current weather for a location",
  inputSchema: {
    type: "object",
    properties: {
      location: { type: "string" }
    }
  },
  _meta: {
    "mcp-ui/resourceUri": "ui://weather-server/dashboard-template",
  }
}

Behavior:

  • If mcp-ui/resourceUri is present and host supports MCP-UI, host renders tool results using the specified UI resource
  • If host does not support MCP-UI, tool behaves as standard tool (text-only fallback)
  • Host MUST validate that referenced URI exists in resources/list

Communication Protocol

MCP-UI reuses the standard MCP JSON-RPC 2.0 protocol over postMessage for iframe-host communication.

Transport Layer

UI iframes act as MCP clients, connecting to the host via a postMessage transport:

// UI iframe initializes MCP client
const transport = new MessageTransport(window.parent);
const client = new Client({ name: "ui-widget", version: "1.0.0" });
await client.connect(transport);

Note that you don’t really need any SDK to “talk MCP” with the host:

let nextId = 1;
function sendRequest(method: string, params: any) {
  const id = nextId++;
  window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, '*');
  return new Promise((resolve, reject) => {
    window.addEventListener('message', function listener(event) {
      const data: JSONRPCMessage = event.data;
      if (event.data?.id === id) {
        window.removeEventListener('message', listener);
        if (event.data?.result) {
          resolve(true);
        } else if (event.data?.error) {
          reject(new Error(event.data.error));
        }
      } else {
        reject(new Error(`Unsupported message: ${JSON.stringify(data)}`));
      }
    });
  });
}
function sendNotification(method: string, params: any) {
  window.parent.postMessage({ jsonrpc: "2.0", method, params }, '*');
}
function onNotification(method: string, handler: (params: any) => void) {
  window.addEventListener('message', function listener(event) {
    if (event.data?.method === method) {
      handler(event.data.params);
    }
  });
}


const initializeResult = await sendRequest("initialize", {
  capabilities: {},
  clientInfo: {name: "My UI", version: "1.0.0"},
  protocolVersion: "2025-06-18",
});

Hosts act as MCP servers (proxies to the actual MCP server), receiving and handling requests from UI iframes.

Sandbox proxy

If the Host is a web page, it MUST wrap the Guest UI and communicate with it through an intermediate Sandbox proxy.

The Host and the Sandbox MUST have different origins.

The Sandbox MUST have the following permissions: allow-scripts, allow-same-origin.

The Sandbox MUST send a mcp-ui/sandbox-ready notification to the host when it’s ready to process an mcp-ui/sandbox-resource-ready notification.

Once the Sandbox is ready, the Host MUST send the raw HTML resource to load in a mcp-ui/sandbox-resource-ready notification.

The Sandbox MUST load the raw HTML of the Guest UI with CSP settings that:

  • Enforce the domains declared in mcp-ui/widgetCSP metadata
  • Prevent nested iframes (frame-src 'none')
  • Block dangerous features (object-src 'none', base-uri 'self')
  • Apply restrictive defaults if no CSP metadata is provided

The Sandbox MUST forward messages sent by the Host to the Guest UI, and vice versa, with any method that doesn’t start with mcp-ui/sandbox-. This includes lifecycle messages, e.g. initialize request & initialized notification both sent by the Guest UI. The Host MUST NOT send any request or notification to the Guest UI before it receives an initialized notification.

The Sandbox SHOULD NOT create/send any requests to the Host or to the Guest UI (this would require synthesizing new request ids).

The Host MAY forward any message from the Guest UI (coming via the Sandbox) to the MCP-UI server, for any method that doesn’t start with mcp-ui/. While the Host SHOULD ensure the Guest UI’s MCP connection is spec-compliant, it MAY decide to block some messages or subject them to further user approval.

Standard MCP Messages (Reused)

UI iframes can use standard MCP protocol messages:

Tools:

  • tools/list - Enumerate available tools
  • tools/call - Execute a tool
  • notifications/tools/list_changed - Tool list updated

Resources:

  • resources/list - Enumerate resources
  • resources/read - Read resource content
  • resources/subscribe - Subscribe to resource updates
  • resources/unsubscribe - Unsubscribe from resource updates
  • notifications/resources/list_changed - Resource list updated
  • notifications/resources/updated - Resource content changed

Prompts:

  • prompts/list - Enumerate prompts
  • prompts/get - Get prompt details
  • notifications/prompts/list_changed - Prompt list updated

Notifications:

  • notifications/message - Log messages to host

Lifecycle:

  • initialize - Standard MCP handshake (replaces custom iframe-ready pattern)
  • ping - Connection health check

Host Context in InitializeResult

When the Guest UI sends an initialize request to the Host, the Host SHOULD include UI-specific context in the InitializeResult response via the _meta field:

interface HostContext {
  theme?: "light" | "dark" | "system";
  displayMode?: "inline" | "fullscreen" | "pip" | "carousel";
  availableDisplayModes?: string[];
  viewport?: {
    width: number;
    height: number;
    maxHeight?: number;
    maxWidth?: number;
  };
  locale?: string;           // BCP 47, e.g., "en-US"
  timeZone?: string;         // IANA, e.g., "America/New_York"
  userAgent?: string;
  platform?: "web" | "desktop" | "mobile";
  safeAreaInsets?: {
    top: number;
    right: number;
    bottom: number;
    left: number;
  };
}

Field Descriptions:
- theme: Current color theme preference
- displayMode: How the UI is currently displayed
- availableDisplayModes: Display modes the host supports
- viewport: Current and maximum dimensions available to the UI
- locale: User's language/region preference
- timeZone: User's timezone
- userAgent: Host application identifier
- platform: Platform type for responsive design
- safeAreaInsets: Mobile safe area boundaries in pixels

All fields are optional. Hosts SHOULD provide relevant context. Guest UIs SHOULD handle missing
 fields gracefully.

Example:

// Host responds with InitializeResult
{
  jsonrpc: "2.0",
  id: 1,
  result: {
    protocolVersion: "2025-06-18",
    capabilities: { /* ... */ },
    serverInfo: { name: "claude-desktop", version: "1.0.0" },
    _meta: {
      "mcp-ui/host-context": {
        theme: "dark",
        displayMode: "inline",
        viewport: { width: 400, height: 300 }
      }
    }
  }
}

#### MCP-UI Specific Messages

MCP-UI introduces additional JSON-RPC methods for UI-specific functionality:

##### Requests (UI  Host)

**`mcp-ui/open-link`** - Request host to open external URL

```typescript
// Request
{
  jsonrpc: "2.0",
  id: 1,
  method: "mcp-ui/open-link",
  params: {
    url: string  // URL to open in host's browser
  }
}

// Success Response
{
  jsonrpc: "2.0",
  id: 1,
  result: {}  // Empty result on success
}

// Error Response (if denied or failed)
{
  jsonrpc: "2.0",
  id: 1,
  error: {
    code: -32000,  // Implementation-defined error
    message: "Link opening denied by user" | "Invalid URL" | "Policy violation"
  }
}

Host SHOULD open the URL in the user's default browser or a new tab.

mcp-ui/message - Send message content to the host's chat interface

// Request
{
  jsonrpc: "2.0",
  id: 2,
  method: "mcp-ui/message",
  params: {
    role: "user",
    content: {
      type: "text",
      text: string
    }
  }
}

// Success Response
{
  jsonrpc: "2.0",
  id: 2,
  result: {}  // Empty result on success
}

// Error Response (if denied or failed)
{
  jsonrpc: "2.0",
  id: 2,
  error: {
    code: -32000,  // Implementation-defined error
    message: "Message sending denied" | "Invalid message format"
  }
}

Host SHOULD add the message to the conversation thread, preserving the specified role.

Notifications (Host → UI)

mcp-ui/notifications/tool-input - Host MUST send this notification with the complete tool arguments after the Guest UI's initialize request completes.

{
  jsonrpc: "2.0",
  method: "mcp-ui/notifications/tool-input",
  params: {
    arguments: Record<string, unknown>  // Tool input arguments
  }
}

Host sends this notification after the Guest UI's initialize request completes, when tool arguments become available. This notification is sent at most once and is required before sending mcp-ui/tool-result.

mcp-ui/notifications/tool-input-partial - Host MAY send this notification zero or more times while the agent is streaming tool arguments, before mcp-ui/notifications/tool-input is sent.

{
  jsonrpc: "2.0",
  method: "mcp-ui/notifications/tool-input-partial",
  params: {
    arguments: Record<string, unknown>  // Tool input arguments
  }
}

The arguments object represents best-effort recovery of incomplete JSON, with unclosed structures automatically closed to produce valid JSON. Host behavior (optional):

  • MAY parse the agent's partial JSON output by closing unclosed brackets/braces
  • MAY send recovered arguments as they become available during streaming
  • MUST stop sending once mcp-ui/notifications/tool-input is sent with complete arguments

Guest UI behavior (optional):

  • MAY ignore these notifications entirely
  • MAY render progressive loading/streaming states based on available fields
  • MUST NOT rely on partial arguments for critical operations
  • SHOULD gracefully handle missing or changing fields between notifications

mcp-ui/tool-result - Tool execution result

{
  jsonrpc: "2.0",
  method: "mcp-ui/tool-result",
  params: CallToolResult  // Standard MCP type
}

Host MUST send this notification when tool execution completes (if UI is displayed during tool execution).

mcp-ui/tool-cancelled - Tool execution was cancelled

{
  jsonrpc: "2.0",
  method: "mcp-ui/tool-cancelled",
  params: {
    reason?: string
  }
}

Host MUST send this notification if the tool execution was cancelled, for any reason (which can optionally be specified), including user action, sampling error, classifier intervention, etc

mcp-ui/size-change - Fired by client when its body size changes

{
  jsonrpc: "2.0",
  method: "mcp-ui/size-change",
  params: {
    width?: number,   // Viewport width in pixels
    height?: number   // Viewport height in pixels
  }
}

Guest UI SHOULD send this notification when rendered content body size changes (e.g. using ResizeObserver API to report up to date size)

mcp-ui/host-context-change - Host context has changed

{
  jsonrpc: "2.0",
  method: "mcp-ui/host-context-change",
  params: Partial<HostContext>  // See HostContext type above
}

Host MAY send this notification when any context field changes (e.g., theme toggle, display mode change, device orientation change, window/panel resize). This notification contains partial updates - Guest UI SHOULD merge received fields with its current context state.

Reserved Messages (Sandbox Proxy)

These messages are reserved for web-based hosts that implement the recommended double-iframe sandbox architecture:

mcp-ui/sandbox-ready - Sandbox proxy is ready

{
  jsonrpc: "2.0",
  method: "mcp-ui/sandbox-ready",
  params: {}
}

mcp-ui/sandbox-resource-ready - HTML resource ready to load

{
  jsonrpc: "2.0",
  method: "mcp-ui/sandbox-resource-ready",
  params: {
    html: string,        // HTML content to load
    sandbox?: string     // Optional override for inner iframe `sandbox` attribute
  }
}

These messages facilitate the communication between the outer sandbox proxy iframe and the host, enabling secure loading of untrusted HTML content.

Lifecycle

The typical lifecycle for rendering a UI resource:

1. Connection & Discovery

Server → Host: resources/list
  Response includes ui:// resources

Server → Host: tools/list
  Tools include mcp-ui/resourceUri metadata

2. Tool Invocation

User/Agent → Host: Execute tool with UI template
Host → Server: tools/call (tool_name, arguments)
Server → Host: Tool result with structuredContent

3. UI Initialization (Desktop/Native Hosts)

Host: Create iframe with UI resource HTML
UI → Host: initialize (standard MCP handshake)
Host → UI: InitializeResult (capabilities, serverInfo, _meta with mcp-ui/host-context)
UI → Host: notifications/initialized
Host → UI: mcp-ui/notifications/tool-input (tool arguments)
Host → UI: mcp-ui/tool-result (tool execution result, if available)
UI: Render using tool input and result data

4. UI Initialization (Web Hosts with Sandbox Proxy)

Host: Create sandbox proxy iframe (different origin)
Proxy → Host: mcp-ui/sandbox-ready
Host → Proxy: mcp-ui/sandbox-resource-ready (HTML content)
Proxy: Create inner iframe with HTML via srcdoc
UI → Proxy → Host: initialize (proxied MCP handshake)
Host → Proxy → UI: InitializeResult (with _meta containing mcp-ui/host-context)
Host → Proxy → UI: mcp-ui/notifications/tool-input
Host → Proxy → UI: mcp-ui/tool-result
UI: Render using tool input and result data

5. Interactive Phase

UI ↔ Host: Standard MCP requests (tools/call, resources/read, etc.)
UI ← Host: Notifications (size-change, tool-cancelled, etc.)

6. Cleanup

Host: Remove iframe when UI is no longer needed
Host: Clean up event listeners and message handlers

Key Differences from Pre-SEP MCP-UI:

  • Uses standard MCP initialize request instead of custom iframe-ready message
  • Capabilities negotiated via standard InitializeResult
  • Tool data passed via notification after initialization (not during iframe creation)

Data Passing

Tool execution results are passed to the UI through two mechanisms:

1. Tool Input (via mcp-ui/notifications/tool-input notification)

The original tool call arguments:

// Tool was called with:
tools/call("get_weather", { location: "San Francisco" })

// UI receives:
notification: mcp-ui/notifications/tool-input
params: {
  arguments: { location: "San Francisco" }
}

2. Tool Result (via mcp-ui/tool-result notification)

The tool's execution result:

// Server returns from tool execution:
{
  content: [
    { type: "text", text: "Current weather: Sunny, 72°F" }
  ],
  structuredContent: {
    temperature: 72,
    conditions: "sunny",
    humidity: 45
  },
  _meta: {
    timestamp: "2025-11-10T15:30:00Z",
    source: "weather-api"
  }
}

// UI receives:
notification: mcp-ui/tool-result
params: {
  content: [...],  // Same as above
  structuredContent: { temperature: 72, ... },
  _meta: { timestamp: "...", ... }
}

Best Practices:

  • content: Text representation for model context and text-only hosts
  • structuredContent: Structured data optimized for UI rendering (not added to model context)
  • _meta: Additional metadata (timestamps, version info, etc.) not intended for model context

3. Interactive Updates

UI can request fresh data by calling tools:

// UI requests updated data
await client.callTool("get_weather", { location: "New York" });

// Result returned via standard tools/call response

This pattern enables interactive, self-updating widgets. Note: The called tool may not appear in tools/list responses. MCP servers MAY expose private tools specifically designed for UI interaction that are not visible to the agent. UI implementations SHOULD attempt to call tools by name regardless of discoverability.

Capability Negotiation

Hosts and servers negotiate MCP-UI support through the standard MCP extensions capability mechanism defined in SEP-1724.

Client (Host) Capabilities

Hosts advertise MCP-UI support in the initialize request using the extension identifier modelcontextprotocol.io/ui:

{
  method: "initialize",
  params: {
    protocolVersion: "2024-11-05",
    capabilities: {
      extensions: {
        "modelcontextprotocol.io/ui": {
          mimeTypes: ["text/html"]
        }
      }
    },
    clientInfo: {
      name: "claude-desktop",
      version: "1.0.0"
    }
  }
}

Extension Settings:

  • mimeTypes: Array of supported content types (REQUIRED, e.g., ["text/html"])

Future versions may add additional settings:

  • contentTypes: Support for externalUrl, remoteDom, etc.
  • features: Specific feature support (e.g., ["streaming", "persistence"])
  • sandboxPolicies: Supported sandbox attribute configurations

Server Behavior

Servers SHOULD check client (host would-be) capabilities before registering UI-enabled tools:

const hasUISupport =
  clientCapabilities?.extensions?.["modelcontextprotocol.io/ui"]?.mimeTypes?.includes("text/html");

if (hasUISupport) {
  // Register tools with UI templates
  server.registerTool("get_weather", {
    description: "Get weather with interactive dashboard",
    inputSchema: { /* ... */ },
    _meta: {
      "mcp-ui/resourceUri": "ui://weather-server/dashboard"
    }
  });
} else {
  // Register text-only version
  server.registerTool("get_weather", {
    description: "Get weather as text",
    inputSchema: { /* ... */ }
    // No UI metadata
  });
}

Graceful Degradation:

  • Servers SHOULD provide text-only fallback behavior for all UI-enabled tools
  • Tools MUST return meaningful content array even when UI is available
  • Servers MAY register different tool variants based on host capabilities

Extensibility

This specification defines the Minimum Viable Product (MVP) for MCP-UI. Future extensions may include:

Content Types (deferred from MVP):

  • externalUrl: Embed external web applications
  • remoteDom: Shopify Remote DOM for native-like components

Advanced Features (see Future Considerations):

  • Support multiple UI resources in a tool response
  • State persistence and restoration
  • Partial tool input streaming
  • Private tools (not visible to agent)
  • Custom sandbox policies per resource
  • Widget-to-widget communication
  • Screenshot/preview generation APIs

Rationale

This proposal synthesizes feedback from the UI CWG and MCP-UI community, host implementors, and lessons from similar solutions. The guiding principle of this proposal is to start lean and expand in the future. There are breaking changes from existing solutions, which will be addressed via the MCP-UI SDK during the migration period.

Design Decisions

1. Predeclared Resources vs. Inline Embedding

Decision: Require UI resources to be registered and referenced in tool metadata.

Rationale:

  • Enables hosts to prefetch templates before tool execution, improving performance
  • Separates presentation (template) from data (tool results), improving caching
  • Allows hosts to review UI templates
  • Aligns with MCP's resource discovery pattern

Alternatives considered:

  • Embedded resources: Current MCP-UI approach, where resources are returned in tool results. Although it's more convenient for server development, it was deferred due to the gaps in performance optimization and the challenges in the UI review process.
  • Resource links: Predeclare the resources but return links in tool results. Deferred due to the gaps in performance optimization.

2. Host communication via MCP Transport

Decision: Use MCP's JSON-RPC base protocol over postMessage instead of custom message format.

Rationale:

  • Reuses existing MCP infrastructure (type definitions, error handling, timeouts)
  • UI developers can use standard MCP SDK (@modelcontextprotocol/sdk) or alternatives
  • Automatic compatibility with future MCP features (long-running tools, sampling, etc.)
  • Better auditability through structured JSON-RPC messages
  • Reduces maintenance burden (no parallel protocol to evolve)

Alternatives considered:

  • Custom message protocol: Current MCP-UI approach with message types like tool, intent, prompt, etc. These message types can be translated to a subset of the proposed JSON-RPC messages.
  • Global API object: Rejected because it requires host-specific injection and doesn't work with external iframe sources. Syntactic sugar may still be added on the server/UI side.

3. Support Raw HTML Content Type

Decision: MVP supports only text/html (rawHtml), with other types explicitly deferred.

Rationale:

  • HTML is universally supported and well-understood
  • Simplest security model (standard iframe sandbox)
  • Allows screenshot/preview generation (e.g., via html2canvas)
  • Sufficient for most observed use cases
  • Provides clear baseline for future extensions

Alternatives considered:

  • Include external URLs in MVP: This is one of the easiest content types for servers to adopt, as it's possible to embed regular apps. However, it was deferred due to concerns around model visibility, inability to screenshot content, and review process.
  • Support multiple content types: Deferred to maintain a lean MVP.

Backward Compatibility

The proposal builds on the existing core protocol. There are no incompatibilities.

Reference Implementation

The MCP-UI project (github.com/mcp-ui-org/mcp-ui) serves as a reference implementation demonstrating the core concept, though it uses pre-SEP patterns.

Olivier Chafik has developed a prototype demonstrating the pattern described in this SEP.

Security Implications

Hosting interactive UI content from potentially untrusted MCP servers requires careful security consideration.

Threat Model

Attackers may use the embedded UI in different scenarios. For example:

  1. Malicious server delivers harmful HTML content
  2. Compromised UI attempts to escape sandbox
  3. UI attempts unauthorized tool execution
  4. UI exfiltrates sensitive host data
  5. UI performs phishing or social engineering

Mitigations

1. Iframe Sandboxing

All UI content MUST be rendered in sandboxed iframes with restricted permissions.

The sandbox limits the UI from accessing the host or manipulating it. All communication with the host is done via postMessage, where the host is in control.

2. Auditable Communication

All UI-to-host communication goes through auditable MCP JSON-RPC messages.

Host behavior:

  • Validate all incoming messages from UI iframes
  • Reject malformed message types
  • Log UI-initiated RPC calls for security review

3. Predeclared Resource Review

Hosts receive UI templates during connection setup, before tool execution.

Host behavior:

  • Review HTML content for obvious malicious patterns
  • Generate hash/signature for resources
  • Warn users about suspicious content
  • Implement allowlists/blocklists based on resource hashes

4. User Consent for Tool Calls

Hosts MUST implement user consent for tool calls originating from UI.

Host behavior:

  • Require explicit user confirmation for “non-safe” tools
  • Implement automatic approval for "safe" tools
  • Show clear UI indication when UI is calling tools
  • Maintain separate permission model for UI vs. model-initiated calls

5. Content Security Policy Enforcement

Hosts MUST enforce Content Security Policies based on resource metadata.

CSP Construction from Metadata:

Content-Security-Policy:
  default-src 'none';
  script-src 'self' 'unsafe-inline';
  style-src 'self' 'unsafe-inline';
  connect-src 'self' ${widgetCSP.connect_domains?.join(' ') || ''};
  img-src 'self' data: ${widgetCSP.resource_domains?.join(' ') || ''};
  font-src 'self' ${widgetCSP.resource_domains?.join(' ') || ''};
  frame-src 'none';
  object-src 'none';
  base-uri 'self';

Security Requirements:

  • Host MUST block connections to undeclared domains
  • Host SHOULD warn users when UI requires external domain access
  • Host MAY implement global domain allowlists/blocklists

Other risks

  1. Social engineering: UI can still display misleading content. Hosts should clearly indicate sandboxed UI boundaries.
  2. Resource consumption: Malicious UI can consume CPU/memory. Hosts should implement resource limits.
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"jsx": "react-jsx",
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "./dist",
"rootDir": "./",
"moduleResolution": "bundler",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": [
"mcp-ui-client.ts",
"mcp-ui-proxy-server.ts",
"mcp-ui-types.ts",
"useMcpClient.tsx",
"example-ui-react.tsx"
],
"exclude": [
"node_modules",
"dist",
"example-*.ts",
"server-example.ts"
]
}
import {
CallToolResultSchema,
ContentBlockSchema,
EmptyResultSchema,
ImplementationSchema,
RequestId,
RequestIdSchema,
RequestSchema,
Tool,
ToolSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
export const LATEST_PROTOCOL_VERSION = "2025-11-21";
export const McpUiOpenLinkRequestSchema = RequestSchema.extend({
method: z.literal("ui/open-link"),
params: z.object({
url: z.string().url(),
}),
});
export type McpUiOpenLinkRequest = z.infer<typeof McpUiOpenLinkRequestSchema>;
export const McpUiOpenLinkResultSchema = z.object({
isError: z.boolean(),
});
export type McpUiOpenLinkResult = z.infer<typeof McpUiOpenLinkResultSchema>;
export const McpUiMessageRequestSchema = RequestSchema.extend({
method: z.literal("ui/message"),
params: z.object({
role: z.literal("user"),
content: z.array(ContentBlockSchema),
}),
});
export type McpUiMessageRequest = z.infer<typeof McpUiMessageRequestSchema>;
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(),
});
export type McpUiMessageResult = z.infer<typeof McpUiMessageResultSchema>;
// McpUiIframeReadyNotification removed - replaced by standard MCP initialization
// The SDK's oninitialized callback now handles the ready signal
export const McpUiSandboxProxyReadyNotificationSchema = z.object({
method: z.literal("ui/notifications/sandbox-proxy-ready"),
params: z.object({}),
});
export type McpUiSandboxProxyReadyNotification = z.infer<
typeof McpUiSandboxProxyReadyNotificationSchema
>;
export const McpUiSandboxResourceReadyNotificationSchema = z.object({
method: z.literal("ui/notifications/sandbox-resource-ready"),
params: z.object({
html: z.string(), //ReadResourceResultSchema,
sandbox: z.string().optional(),
}),
});
export type McpUiSandboxResourceReadyNotification = z.infer<
typeof McpUiSandboxResourceReadyNotificationSchema
>;
// Fired by the iframe when its body changes, and by the host when the viewport size changes.
export const McpUiSizeChangeNotificationSchema = z.object({
method: z.literal("ui/notifications/size-change"),
params: z.object({
width: z.number().optional(),
height: z.number().optional(),
}),
});
export type McpUiSizeChangeNotification = z.infer<
typeof McpUiSizeChangeNotificationSchema
>;
export const McpUiToolInputNotificationSchema = z.object({
method: z.literal("ui/notifications/tool-input"),
params: z.object({
arguments: z.record(z.unknown()).optional(),
}),
});
export type McpUiToolInputNotification = z.infer<
typeof McpUiToolInputNotificationSchema
>;
export const McpUiToolInputPartialNotificationSchema = z.object({
method: z.literal("ui/notifications/tool-input-partial"),
params: z.object({
arguments: z.record(z.unknown()).optional(),
}),
});
export type McpUiToolInputPartialNotification = z.infer<
typeof McpUiToolInputPartialNotificationSchema
>;
// Fired once both tool call returned *AND* host received ui/ui-lifecycle-iframe-ready.
export const McpUiToolResultNotificationSchema = z.object({
method: z.literal("ui/notifications/tool-result"),
params: CallToolResultSchema,
});
export type McpUiToolResultNotification = z.infer<
typeof McpUiToolResultNotificationSchema
>;
export const McpUiHostContextSchema = z.object({
toolInfo: z
.object({
// Metadata of the tool call that instantiated the App
id: RequestIdSchema, // JSON-RPC id of the tools/call request
tool: ToolSchema, // contains name, inputSchema, etc…
})
.optional(),
theme: z.enum(["light", "dark", "system"]).optional(),
displayMode: z.enum(["inline", "fullscreen", "pip", "carousel"]).optional(),
availableDisplayModes: z.array(z.string()).optional(),
viewport: z
.object({
width: z.number(),
height: z.number(),
maxHeight: z.number().optional(),
maxWidth: z.number().optional(),
})
.optional(),
locale: z.string().optional(), // BCP 47, e.g., "en-US"
timeZone: z.string().optional(), // IANA, e.g., "America/New_York"
userAgent: z.string().optional(),
platform: z.enum(["web", "desktop", "mobile"]).optional(),
deviceCapabilities: z
.object({
touch: z.boolean().optional(),
hover: z.boolean().optional(),
})
.optional(),
safeAreaInsets: z
.object({
top: z.number(),
right: z.number(),
bottom: z.number(),
left: z.number(),
})
.optional(),
});
export type McpUiHostContext = z.infer<typeof McpUiHostContextSchema>;
export const McpUiHostContextChangedNotificationSchema = z.object({
method: z.literal("ui/notifications/host-context-changed"),
params: McpUiHostContextSchema,
});
export type McpUiHostContextChangedNotification = z.infer<
typeof McpUiHostContextChangedNotificationSchema
>;
export const McpUiResourceTeardownRequestSchema = RequestSchema.extend({
method: z.literal("ui/resource-teardown"),
params: z.object({}),
});
export type McpUiResourceTeardownRequest = z.infer<
typeof McpUiResourceTeardownRequestSchema
>;
export const McpUiResourceTeardownResultSchema = EmptyResultSchema;
export type McpUiResourceTeardownResult = z.infer<
typeof McpUiResourceTeardownResultSchema
>;
export const McpUiHostCapabilitiesSchema = z.object({
experimental: z.object({}).optional(),
openLinks: z.object({}).optional(),
serverTools: z
.object({
listChanged: z.boolean().optional(),
})
.optional(),
serverResources: z
.object({
listChanged: z.boolean().optional(),
})
.optional(),
logging: z.object({}).optional(),
// TODO: elicitation, sampling...
});
export type McpUiHostCapabilities = z.infer<typeof McpUiHostCapabilitiesSchema>;
export const McpUiAppCapabilitiesSchema = z.object({
experimental: z.object({}).optional(),
// WebMCP-style tools exposed by the app to the host
tools: z
.object({
listChanged: z.boolean().optional(),
})
.optional(),
});
export type McpUiAppCapabilities = z.infer<typeof McpUiAppCapabilitiesSchema>;
export const McpUiInitializeRequestSchema = RequestSchema.extend({
method: z.literal("ui/initialize"),
params: z.object({
appInfo: ImplementationSchema,
appCapabilities: McpUiAppCapabilitiesSchema,
protocolVersion: z.string(),
}),
});
export type McpUiInitializeRequest = z.infer<
typeof McpUiInitializeRequestSchema
>;
export const McpUiHostInfoSchema = z.object({
name: z.string(),
version: z.string(),
});
export type McpUiHostInfo = z.infer<typeof McpUiHostInfoSchema>;
export const McpUiInitializeResultSchema = z.object({
protocolVersion: z.string(),
hostInfo: McpUiHostInfoSchema,
hostCapabilities: McpUiHostCapabilitiesSchema,
hostContext: McpUiHostContextSchema,
});
export type McpUiInitializeResult = z.infer<typeof McpUiInitializeResultSchema>;
export const McpUiInitializedNotificationSchema = z.object({
method: z.literal("ui/notifications/initialized"),
params: z.object({}),
});
export type McpUiInitializedNotification = z.infer<
typeof McpUiInitializedNotificationSchema
>;
import {
McpUiMessageRequestSchema,
McpUiOpenLinkRequestSchema,
McpUiSizeChangeNotificationSchema,
} from "./app";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
CallToolResult,
LoggingMessageNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { useEffect, useRef, useState } from "react";
import { AppBridge, PostMessageTransport } from "./app-bridge";
import {
getToolUiResourceUri,
readToolUiResourceHtml,
setupSandboxProxyIframe,
} from "./app-host-utils";
import type { UIActionResult } from "./mcp-ui-types";
/**
* 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;
// usesOpenAiAppsSdk?: boolean;
/** 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;
/** Callback invoked when the tool UI requests an action (intent, link, prompt, notify) */
onUIAction?: (result: UIActionResult) => Promise<unknown>;
/** Callback invoked when an error occurs during setup or message handling */
onError?: (error: Error) => void;
/** Whether to inject a script on the Guest UI HTML to automatically notify the host of size changes and initial size of the UI body. */
autoNotifySizeChanges?: boolean;
}
/**
* 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,
// usesOpenAiAppsSdk,
toolInput,
toolResult,
onUIAction,
onError,
autoNotifySizeChanges,
} = 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 onUIActionRef = useRef(onUIAction);
const onErrorRef = useRef(onError);
useEffect(() => {
onUIActionRef.current = onUIAction;
onErrorRef.current = onError;
});
// Effect 1: Setup sandbox proxy iframe
useEffect(() => {
let mounted = true;
const setup = async () => {
try {
// Step 1: Create iframe and wait for sandbox proxy ready
const { iframe, onReady } =
await setupSandboxProxyIframe(sandboxProxyUrl);
if (!mounted) return;
// Append iframe to DOM
iframeRef.current = iframe;
if (containerRef.current) {
containerRef.current.appendChild(iframe);
}
// Wait for sandbox proxy HTML to signal it's ready
await onReady;
if (!mounted) return;
// 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 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 MCP-UI specific request handlers
appBridge.setRequestHandler(McpUiOpenLinkRequestSchema, async (req) => {
try {
await onUIActionRef.current?.({
type: "link",
payload: { url: req.params.url },
});
return { isError: false };
} catch (e) {
console.error("[Host] Open link handler error:", e);
const error = e instanceof Error ? e : new Error(String(e));
onErrorRef.current?.(error);
return { isError: true };
}
});
appBridge.setRequestHandler(McpUiMessageRequestSchema, async (req) => {
try {
await onUIActionRef.current?.({
type: "prompt",
payload: {
prompt: req.params.content
.map((c: any) => (c.type === "text" ? c.text : ""))
.join("\n"),
},
});
return { isError: false };
} catch (e) {
console.error("[Host] Message handler error:", e);
const error = e instanceof Error ? e : new Error(String(e));
onErrorRef.current?.(error);
return { isError: true };
}
});
appBridge.setNotificationHandler(
McpUiSizeChangeNotificationSchema,
async (notif: any) => {
const { width, height } = notif.params;
if (iframeRef.current) {
if (width !== undefined) {
iframeRef.current.style.width = `${width}px`;
}
if (height !== undefined) {
iframeRef.current.style.height = `${height}px`;
}
}
},
);
appBridge.setNotificationHandler(
LoggingMessageNotificationSchema,
async (notif: any) => {
onUIAction?.({
type: "notify",
payload: {
message: notif.params.message,
},
});
},
);
// 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,
// usesOpenAiAppsSdk: usesOpenAiAppsSdk ?? false,
};
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,
// usesOpenAiAppsSdk: resourceInfo.usesOpenAiAppsSdk,
// autoNotifySizeChanges: autoNotifySizeChanges ?? true,
});
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>
);
};
import { useEffect, useState } from "react";
import { Implementation } from "@modelcontextprotocol/sdk/types.js";
import { Client } from "@modelcontextprotocol/sdk/client";
import { App, McpUiAppCapabilities, PostMessageTransport } from "./app";
export * from "./app";
export interface UseAppOptions {
appInfo: Implementation;
capabilities: McpUiAppCapabilities;
/**
* Called after client is created but before connection.
* Use this to register request/notification handlers via
* client.setRequestHandler() and client.setNotificationHandler().
*/
onAppCreated?: (app: App) => void;
}
export interface AppState {
app: App | null;
isConnected: boolean;
error: Error | null;
}
export function useApp({
appInfo,
capabilities,
onAppCreated,
}: UseAppOptions): AppState {
const [app, setApp] = useState<App | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let mounted = true;
async function connect() {
try {
const transport = new PostMessageTransport(window.parent);
const app = new App(appInfo, capabilities);
// Register handlers BEFORE connecting
onAppCreated?.(app);
await app.connect(transport);
if (mounted) {
setApp(app);
setIsConnected(true);
setError(null);
}
} catch (error) {
if (mounted) {
setApp(null);
setIsConnected(false);
setError(
error instanceof Error ? error : new Error("Failed to connect"),
);
}
}
}
connect();
return () => {
mounted = false;
};
}, []); // Intentionally not including options to avoid reconnection
return { app, isConnected, error };
}
import { useEffect, RefObject } from "react";
import { App } from "./app";
/**
* Custom hook that automatically reports size changes to the parent window.
*
* @param client - MCP UI client for sending size notifications
*/
export function useAutoResize(
app: App | null,
elementRef?: RefObject<HTMLElement | null>,
) {
useEffect(() => {
if (!app) {
return;
}
return app.setupSizeChangeNotifications();
}, [app, elementRef]);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment