Add basic-host and basic-server-react examples

Introduce new example implementations to replace simple-host and
simple-server:

- basic-host: Reference host implementation with double-iframe sandbox
  pattern for secure UI isolation (outer iframe validates and relays
  messages, inner iframe renders untrusted tool UI via srcdoc)

- basic-server-react: MCP server example demonstrating tool registration
  with linked UI resources and React UI using the useApp() hook

Build infrastructure updates:
- Add build:all script and simplify CI/pre-commit to use it
- Add examples:dev for watch-mode development workflow

Also change PostMessageTransport logging from info to debug level.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jonathan Hefner 2025-12-02 13:11:17 -06:00
commit 97310d50c5
6 changed files with 1808 additions and 25 deletions

View file

@ -0,0 +1,12 @@
* {
box-sizing: border-box;
}
html, body {
font-family: system-ui, -apple-system, sans-serif;
font-size: 1rem;
}
code {
font-size: 1em;
}

View file

@ -0,0 +1,257 @@
import { RESOURCE_URI_META_KEY, type McpUiSandboxProxyReadyNotification } from "@modelcontextprotocol/ext-apps";
import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
const SANDBOX_PROXY_URL = new URL("http://localhost:8081/sandbox.html");
const IMPLEMENTATION = { name: "MCP Apps Host", version: "1.0.0" };
export const log = {
info: console.log.bind(console, "[HOST]"),
warn: console.warn.bind(console, "[HOST]"),
error: console.error.bind(console, "[HOST]"),
};
export interface ServerInfo {
client: Client;
tools: Map<string, Tool>;
appHtmlCache: Map<string, string>;
}
export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
const client = new Client(IMPLEMENTATION);
log.info("Connecting to server:", serverUrl.href);
await client.connect(new StreamableHTTPClientTransport(serverUrl));
log.info("Connection successful");
const toolsList = await client.listTools();
const tools = new Map(toolsList.tools.map((tool) => [tool.name, tool]));
log.info("Server tools:", Array.from(tools.keys()));
return { client, tools, appHtmlCache: new Map() };
}
export interface ToolCallInfo {
serverInfo: ServerInfo;
tool: Tool;
input: Record<string, unknown>;
resultPromise: Promise<CallToolResult>;
appHtmlPromise?: Promise<string>;
}
export function hasAppHtml(toolCallInfo: ToolCallInfo): toolCallInfo is Required<ToolCallInfo> {
return !!toolCallInfo.appHtmlPromise;
}
export function callTool(
serverInfo: ServerInfo,
name: string,
input: Record<string, unknown>,
): ToolCallInfo {
log.info("Calling tool", name, "with input", input);
const resultPromise = serverInfo.client.callTool({ name, arguments: input }) as Promise<CallToolResult>;
const tool = serverInfo.tools.get(name);
if (!tool) {
throw new Error(`Unknown tool: ${name}`);
}
const toolCallInfo: ToolCallInfo = { serverInfo, tool, input, resultPromise };
const uiResourceUri = getUiResourceUri(tool);
if (uiResourceUri) {
toolCallInfo.appHtmlPromise = getUiResourceHtml(serverInfo, uiResourceUri);
}
return toolCallInfo;
}
function getUiResourceUri(tool: Tool): string | undefined {
const uri = tool._meta?.[RESOURCE_URI_META_KEY];
if (typeof uri === "string" && uri.startsWith("ui://")) {
return uri;
} else if (uri !== undefined) {
throw new Error(`Invalid UI resource URI: ${JSON.stringify(uri)}`);
}
}
async function getUiResourceHtml(serverInfo: ServerInfo, uri: string): Promise<string> {
let html = serverInfo.appHtmlCache.get(uri);
if (html) {
log.info("Read UI resource from cache:", uri);
return html;
}
log.info("Reading UI resource:", uri);
const resource = await serverInfo.client.readResource({ uri });
if (!resource) {
throw new Error(`Resource not found: ${uri}`);
}
if (resource.contents.length !== 1) {
throw new Error(`Unexpected contents count: ${resource.contents.length}`);
}
const content = resource.contents[0];
// Per the MCP App specification, "text/html+mcp" signals this resource is
// indeed for an MCP App UI.
if (content.mimeType !== "text/html+mcp") {
throw new Error(`Unsupported MIME type: ${content.mimeType}`);
}
html = "blob" in content ? atob(content.blob) : content.text;
serverInfo.appHtmlCache.set(uri, html);
return html;
}
export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise<boolean> {
// Prevent reload
if (iframe.src) return Promise.resolve(false);
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
const readyNotification: McpUiSandboxProxyReadyNotification["method"] =
"ui/notifications/sandbox-proxy-ready";
const readyPromise = new Promise<boolean>((resolve) => {
const listener = ({ source, data }: MessageEvent) => {
if (source === iframe.contentWindow && data?.method === readyNotification) {
log.info("Sandbox proxy loaded")
window.removeEventListener("message", listener);
resolve(true);
}
};
window.addEventListener("message", listener);
});
log.info("Loading sandbox proxy...");
iframe.src = SANDBOX_PROXY_URL.href;
return readyPromise;
}
export async function initializeApp(
iframe: HTMLIFrameElement,
appBridge: AppBridge,
{ input, resultPromise, appHtmlPromise }: Required<ToolCallInfo>,
): Promise<void> {
const appInitializedPromise = hookInitializedCallback(appBridge);
// Connect app bridge (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!),
);
// Load inner iframe HTML
log.info("Sending UI resource HTML to MCP App");
await appBridge.sendSandboxResourceReady({ html: await appHtmlPromise });
// Wait for inner iframe to be ready
log.info("Waiting for MCP App to initialize...");
await appInitializedPromise;
log.info("MCP App initialized");
// Send tool call input to iframe
log.info("Sending tool call input to MCP App:", input);
appBridge.sendToolInput({ arguments: input });
// Schedule tool call result to be sent to MCP App
resultPromise.then((result) => {
log.info("Sending tool call result to MCP App:", result);
appBridge.sendToolResult(result);
});
}
/**
* Hooks into `AppBridge.oninitialized` and returns a Promise that resolves when
* the MCP App is initialized (i.e., when the inner iframe is ready).
*/
function hookInitializedCallback(appBridge: AppBridge): Promise<void> {
const oninitialized = appBridge.oninitialized;
return new Promise<void>((resolve) => {
appBridge.oninitialized = (...args) => {
resolve();
appBridge.oninitialized = oninitialized;
appBridge.oninitialized?.(...args);
};
});
}
export function newAppBridge(serverInfo: ServerInfo, iframe: HTMLIFrameElement): AppBridge {
const serverCapabilities = serverInfo.client.getServerCapabilities();
const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, {
openLinks: {},
serverTools: serverCapabilities?.tools,
serverResources: serverCapabilities?.resources,
});
// Register all handlers before calling connect(). The Guest UI can start
// sending requests immediately after the initialization handshake, so any
// handlers registered after connect() might miss early requests.
appBridge.onmessage = async (params, _extra) => {
log.info("Message from MCP App:", params);
return {};
};
appBridge.onopenlink = async (params, _extra) => {
log.info("Open link request:", params);
window.open(params.url, "_blank", "noopener,noreferrer");
return {};
};
appBridge.onloggingmessage = (params) => {
log.info("Log message from MCP App:", params);
};
appBridge.onsizechange = async ({ width, height }) => {
// The MCP App has requested a `width` and `height`, but if
// `box-sizing: border-box` is applied to the outer iframe element, then we
// must add border thickness to `width` and `height` to compute the actual
// necessary width and height (in order to prevent a resize feedback loop).
const style = getComputedStyle(iframe);
const isBorderBox = style.boxSizing === "border-box";
// Animate the change for a smooth transition.
const from: Keyframe = {};
const to: Keyframe = {};
if (width !== undefined) {
if (isBorderBox) {
width += parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth);
}
from.width = `${iframe.offsetWidth}px`;
iframe.style.width = to.width = `${width}px`;
}
if (height !== undefined) {
if (isBorderBox) {
height += parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
}
from.height = `${iframe.offsetHeight}px`;
iframe.style.height = to.height = `${height}px`;
}
iframe.animate([from, to], { duration: 300, easing: "ease-out" });
};
return appBridge;
}

View file

@ -0,0 +1,129 @@
.callToolPanel, .toolCallInfoPanel {
margin: 0 auto;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
* + & {
margin-top: 1rem;
}
}
.callToolPanel {
max-width: 480px;
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-weight: 600;
}
select,
textarea {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-family: monospace;
font-size: inherit;
}
textarea {
min-height: 6rem;
resize: vertical;
&[aria-invalid="true"] {
background-color: #fdd;
}
}
button {
align-self: center;
min-width: 200px;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
background-color: #1e3a5f;
font-size: inherit;
font-weight: 600;
color: white;
cursor: pointer;
&:hover:not(:disabled) {
background-color: #2d4a7c;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.toolCallInfoPanel {
display: flex;
gap: 1rem;
max-width: 1120px;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-12px); }
}
.inputInfoPanel {
display: flex;
flex: 3;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
.toolName {
margin: 0;
font-family: monospace;
font-size: 1.5rem;
}
}
.outputInfoPanel {
flex: 4;
min-width: 0;
}
.jsonBlock {
flex-grow: 1;
min-height: 0;
margin: 0;
padding: 1rem;
border-radius: 4px;
background-color: #f5f5f5;
overflow: auto;
}
.appIframePanel {
display: flex;
justify-content: center;
width: 100%;
min-height: 200px;
iframe {
flex-shrink: 0;
height: 100%;
max-width: 100%;
box-sizing: border-box;
border: 3px dashed #888;
border-radius: 4px;
}
}
.error {
padding: 1.5rem;
background-color: #ddd;
color: #d00;
}

View file

@ -0,0 +1,195 @@
import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo } from "./implementation";
import styles from "./index.module.css";
const MCP_SERVER_URL = new URL("http://localhost:3001/mcp");
interface HostProps {
serverInfoPromise: Promise<ServerInfo>;
}
function Host({ serverInfoPromise }: HostProps) {
const serverInfo = use(serverInfoPromise);
const [toolCallInfos, setToolCallInfos] = useState<ToolCallInfo[]>([]);
return (
<>
{toolCallInfos.map((info, i) => (
<ToolCallInfoPanel key={i} toolCallInfo={info} />
))}
<CallToolPanel
serverInfo={serverInfo}
addToolCallInfo={(info) => setToolCallInfos([...toolCallInfos, info])}
/>
</>
);
}
interface CallToolPanelProps {
serverInfo: ServerInfo;
addToolCallInfo: (toolCallInfo: ToolCallInfo) => void;
}
function CallToolPanel({ serverInfo, addToolCallInfo }: CallToolPanelProps) {
const toolNames = Array.from(serverInfo.tools.keys());
const [selectedTool, setSelectedTool] = useState(toolNames[0] ?? "");
const [inputJson, setInputJson] = useState("{}");
const isValidJson = useMemo(() => {
try {
JSON.parse(inputJson);
return true;
} catch {
return false;
}
}, [inputJson]);
const handleSubmit = () => {
const toolCallInfo = callTool(serverInfo, selectedTool, JSON.parse(inputJson));
addToolCallInfo(toolCallInfo);
};
return (
<div className={styles.callToolPanel}>
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<label>
Tool Name
<select
value={selectedTool}
onChange={(e) => setSelectedTool(e.target.value)}
>
{toolNames.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</label>
<label>
Tool Input
<textarea
aria-invalid={!isValidJson}
value={inputJson}
onChange={(e) => setInputJson(e.target.value)}
/>
</label>
<button type="submit" disabled={!selectedTool || !isValidJson}>
Call Tool
</button>
</form>
</div>
);
}
interface ToolCallInfoPanelProps {
toolCallInfo: ToolCallInfo;
}
function ToolCallInfoPanel({ toolCallInfo }: ToolCallInfoPanelProps) {
return (
<div className={styles.toolCallInfoPanel}>
<div className={styles.inputInfoPanel}>
<h2 className={styles.toolName}>{toolCallInfo.tool.name}</h2>
<JsonBlock value={toolCallInfo.input} />
</div>
<div className={styles.outputInfoPanel}>
<ErrorBoundary>
<Suspense fallback="Loading...">
{
hasAppHtml(toolCallInfo)
? <AppIFramePanel toolCallInfo={toolCallInfo} />
: <ToolResultPanel toolCallInfo={toolCallInfo} />
}
</Suspense>
</ErrorBoundary>
</div>
</div>
);
}
function JsonBlock({ value }: { value: object }) {
return (
<pre className={styles.jsonBlock}>
<code>{JSON.stringify(value, null, 2)}</code>
</pre>
);
}
interface AppIFramePanelProps {
toolCallInfo: Required<ToolCallInfo>;
}
function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
useEffect(() => {
const iframe = iframeRef.current!;
loadSandboxProxy(iframe).then((firstTime) => {
// The `firstTime` check guards against React Strict Mode's double
// invocation (mount → unmount → remount simulation in development).
// Outside of Strict Mode, this `useEffect` runs only once per
// `toolCallInfo`.
if (firstTime) {
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe);
initializeApp(iframe, appBridge, toolCallInfo);
}
});
}, [toolCallInfo]);
return (
<div className={styles.appIframePanel}>
<iframe ref={iframeRef} />
</div>
);
}
interface ToolResultPanelProps {
toolCallInfo: ToolCallInfo;
}
function ToolResultPanel({ toolCallInfo }: ToolResultPanelProps) {
const result = use(toolCallInfo.resultPromise);
return <JsonBlock value={result} />;
}
interface ErrorBoundaryProps {
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: unknown;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false, error: undefined };
// Called during render phase - must be pure (no side effects)
// Note: error is `unknown` because JS allows throwing any value
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
return { hasError: true, error };
}
// Called during commit phase - can have side effects (logging, etc.)
componentDidCatch(error: unknown, errorInfo: ErrorInfo): void {
log.error("Caught:", error, errorInfo.componentStack);
}
render(): ReactNode {
if (this.state.hasError) {
const { error } = this.state;
const message = error instanceof Error ? error.message : String(error);
return <div className={styles.error}><strong>ERROR:</strong> {message}</div>;
}
return this.props.children;
}
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Suspense fallback={<p>Connecting to server ({MCP_SERVER_URL.href})...</p>}>
<Host serverInfoPromise={connectToServer(MCP_SERVER_URL)} />
</Suspense>
</StrictMode>,
);

View file

@ -0,0 +1,87 @@
import type { McpUiSandboxProxyReadyNotification, McpUiSandboxResourceReadyNotification } from "../../../dist/src/types";
const ALLOWED_REFERRER_PATTERN = /^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/;
if (window.self === window.top) {
throw new Error("This file is only to be used in an iframe sandbox.");
}
if (!document.referrer) {
throw new Error("No referrer, cannot validate embedding site.");
}
if (!document.referrer.match(ALLOWED_REFERRER_PATTERN)) {
throw new Error(
`Embedding domain not allowed in referrer ${document.referrer}. (Consider updating the validation logic to allow your domain.)`,
);
}
// Security self-test: verify iframe isolation is working correctly.
// This MUST throw a SecurityError -- if `window.top` is accessible, the sandbox
// configuration is dangerously broken and untrusted content could escape.
try {
window.top!.alert("If you see this, the sandbox is not setup securely.");
throw "FAIL";
} catch (e) {
if (e === "FAIL") {
throw new Error("The sandbox is not setup securely.");
}
// Expected: SecurityError confirms proper sandboxing.
}
// Double-iframe sandbox architecture: THIS file is the outer sandbox proxy
// iframe on a separate origin. It creates an inner iframe for untrusted HTML
// content. Per the specification, the Host and the Sandbox MUST have different
// origins.
const inner = document.createElement("iframe");
inner.style = "width:100%; height:100%; border:none;";
inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
document.body.appendChild(inner);
const RESOURCE_READY_NOTIFICATION: McpUiSandboxResourceReadyNotification["method"] =
"ui/notifications/sandbox-resource-ready";
const PROXY_READY_NOTIFICATION: McpUiSandboxProxyReadyNotification["method"] =
"ui/notifications/sandbox-proxy-ready";
// Message relay: This Sandbox (outer iframe) acts as a bidirectional bridge,
// forwarding messages between:
//
// Host (parent window) ↔ Sandbox (outer frame) ↔ Guest UI (inner iframe)
//
// Reason: the parent window and inner iframe have different origins and can't
// communicate directly, so the outer iframe forwards messages in both
// directions to connect them.
//
// Special case: The "ui/notifications/sandbox-proxy-ready" message is
// intercepted here (not relayed) because the Sandbox uses it to configure and
// load the inner iframe with the Guest UI HTML content.
window.addEventListener("message", async (event) => {
if (event.source === window.parent) {
// NOTE: In production you'll also want to validate `event.origin` against
// your Host domain.
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
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 frame to parent window.
window.parent.postMessage(event.data, "*");
}
});
// Notify the Host that the Sandbox is ready to receive Guest UI HTML.
window.parent.postMessage({
jsonrpc: "2.0",
method: PROXY_READY_NOTIFICATION,
params: {},
}, "*");

1
examples/basic-host/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />