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
30 changed files with 1808 additions and 25 deletions

View file

@ -25,18 +25,6 @@ jobs:
- run: npm install
- run: npm run build
- name: Build simple-host example
working-directory: examples/simple-host
run: |
npm install
npm run build
- name: Build simple-server example
working-directory: examples/simple-server
run: |
npm install
npm run build
- run: npm run build:all
- run: npm run prettier

View file

@ -1,4 +1,2 @@
npm run build
npm run build:all
npm run prettier:fix
( cd examples/simple-host && npm run build )
( cd examples/simple-server && npm run build )

4
.prettierignore Normal file
View file

@ -0,0 +1,4 @@
examples/basic-host/**/*.ts
examples/basic-host/**/*.tsx
examples/basic-server-react/**/*.ts
examples/basic-server-react/**/*.tsx

View file

@ -23,7 +23,7 @@ Embed and communicate with MCP Apps in your chat application.
- **SDK for Hosts**: `@modelcontextprotocol/ext-apps/app-bridge` — [API Docs](https://modelcontextprotocol.github.io/ext-apps/api/modules/app-bridge.html)
There's no _supported_ host implementation in this repo (beyond the [examples/simple-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-host) example).
There's no _supported_ host implementation in this repo (beyond the [examples/basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example).
We have [contributed a tentative implementation](https://github.com/MCP-UI-Org/mcp-ui/pull/147) of hosting / iframing / sandboxing logic to the [MCP-UI](https://github.com/idosal/mcp-ui) repository, and expect OSS clients may use it, while other clients might roll their own hosting logic.
@ -54,14 +54,14 @@ Your `package.json` will then look like:
## Examples
- [examples/simple-server](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-server) — Example MCP server with tools that return UI Apps
- [examples/simple-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-host) — Bare-bones example of hosting MCP Apps
- [`examples/basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) — Example MCP server with tools that return UI Apps
- [`examples/basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) — Bare-bones example of hosting MCP Apps
To run the examples end-to-end:
```
npm i
npm start
npm run examples:start
```
Then open http://localhost:8080/

View file

@ -0,0 +1,37 @@
# Example: Basic Host
A reference implementation showing how to build an MCP Host application that connects to MCP servers and renders tool UIs in a secure sandbox.
This basic host can also be used to test MCP Apps during local development.
## Key Files
- [`index.html`](index.html) / [`src/index.tsx`](src/index.tsx) - React UI host with tool selection, parameter input, and iframe management
- [`sandbox.html`](sandbox.html) / [`src/sandbox.ts`](src/sandbox.ts) - Outer iframe proxy with security validation and bidirectional message relay
- [`src/implementation.ts`](src/implementation.ts) - Core logic: server connection, tool calling, and AppBridge setup
## Getting Started
```bash
npm install
npm run dev
# Open http://localhost:8080
```
## Architecture
This example uses a double-iframe sandbox pattern for secure UI isolation:
```
Host (port 8080)
└── Outer iframe (port 8081) - sandbox proxy
└── Inner iframe (srcdoc) - untrusted tool UI
```
**Why two iframes?**
- The outer iframe runs on a separate origin (port 8081) preventing direct access to the host
- The inner iframe receives HTML via `srcdoc` and is restricted by sandbox attributes
- Messages flow through the outer iframe which validates and relays them bidirectionally
This architecture ensures that even if tool UI code is malicious, it cannot access the host application's DOM, cookies, or JavaScript context.

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP Apps Host</title>
<link rel="stylesheet" href="/src/global.css">
</head>
<body>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

View file

@ -0,0 +1,36 @@
{
"homepage": "https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host",
"name": "@modelcontextprotocol/ext-apps-basic-host",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "concurrently 'INPUT=index.html vite build' 'INPUT=sandbox.html vite build'",
"watch": "concurrently 'INPUT=index.html vite build --watch' 'INPUT=sandbox.html vite build --watch'",
"serve": "bun serve.ts",
"start": "NODE_ENV=development npm run build && npm run serve",
"dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'"
},
"dependencies": {
"@modelcontextprotocol/ext-apps": "../..",
"@modelcontextprotocol/sdk": "^1.22.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.3.4",
"bun": "^1.3.2",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"express": "^5.1.0",
"prettier": "^3.6.2",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}

View file

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

View file

@ -0,0 +1,80 @@
#!/usr/bin/env npx tsx
/**
* HTTP servers for the MCP UI example:
* - Host server (port 8080): serves host HTML files (React and Vanilla examples)
* - Sandbox server (port 8081): serves sandbox.html with permissive CSP
*
* Running on separate ports ensures proper origin isolation for security.
*/
import express from "express";
import cors from "cors";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const HOST_PORT = parseInt(process.env.HOST_PORT || "8080", 10);
const SANDBOX_PORT = parseInt(process.env.SANDBOX_PORT || "8081", 10);
const DIRECTORY = join(__dirname, "dist");
// ============ Host Server (port 8080) ============
const hostApp = express();
hostApp.use(cors());
// Exclude sandbox.html from host server
hostApp.use((req, res, next) => {
if (req.path === "/sandbox.html") {
res.status(404).send("Sandbox is served on a different port");
return;
}
next();
});
hostApp.use(express.static(DIRECTORY));
hostApp.get("/", (_req, res) => {
res.redirect("/index.html");
});
// ============ Sandbox Server (port 8081) ============
const sandboxApp = express();
sandboxApp.use(cors());
// Permissive CSP for sandbox content
sandboxApp.use((_req, res, next) => {
const csp = [
"default-src 'self'",
"img-src * data: blob: 'unsafe-inline'",
"style-src * blob: data: 'unsafe-inline'",
"script-src * blob: data: 'unsafe-inline' 'unsafe-eval'",
"connect-src *",
"font-src * blob: data:",
"media-src * blob: data:",
"frame-src * blob: data:",
].join("; ");
res.setHeader("Content-Security-Policy", csp);
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
next();
});
sandboxApp.get(["/", "/sandbox.html"], (_req, res) => {
res.sendFile(join(DIRECTORY, "sandbox.html"));
});
sandboxApp.use((_req, res) => {
res.status(404).send("Only sandbox.html is served on this port");
});
// ============ Start both servers ============
hostApp.listen(HOST_PORT, () => {
console.log(`Host server: http://localhost:${HOST_PORT}`);
});
sandboxApp.listen(SANDBOX_PORT, () => {
console.log(`Sandbox server: http://localhost:${SANDBOX_PORT}`);
console.log("\nPress Ctrl+C to stop\n");
});

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

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "*.ts"]
}

View file

@ -0,0 +1,24 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
const INPUT = process.env.INPUT;
if (!INPUT) {
throw new Error("INPUT environment variable is not set");
}
const isDevelopment = process.env.NODE_ENV === "development";
export default defineConfig({
plugins: [react(), viteSingleFile()],
build: {
sourcemap: isDevelopment ? "inline" : undefined,
cssMinify: !isDevelopment,
minify: !isDevelopment,
rollupOptions: {
input: INPUT,
},
outDir: `dist`,
emptyOutDir: false,
},
});

View file

@ -0,0 +1,27 @@
# Example: Basic Server (React)
An MCP App example with a React UI.
## Overview
- Tool registration with a linked UI resource
- React UI using the [`useApp()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/_modelcontextprotocol_ext-apps_react.useApp.html) hook
- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`sendOpenLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendopenlink)
## Key Files
- [`server.ts`](server.ts) - MCP server with tool and resource registration
- [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.tsx`](src/mcp-app.tsx) - React UI using `useApp()` hook
## Getting Started
```bash
npm install
npm run dev
```
## How It Works
1. The server registers a `get-time` tool with metadata linking it to a UI HTML resource (`ui://get-time/mcp-app.html`).
2. When the tool is invoked, the Host renders the UI from the resource.
3. The UI uses the MCP App SDK API to communicate with the host and call server tools.

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Get Time App</title>
<link rel="stylesheet" href="/src/global.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/mcp-app.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,34 @@
{
"name": "basic-server-react",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "INPUT=mcp-app.html vite build",
"watch": "INPUT=mcp-app.html vite build --watch",
"serve": "bun server.ts",
"start": "NODE_ENV=development npm run build && npm run serve",
"dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'"
},
"dependencies": {
"@modelcontextprotocol/ext-apps": "../..",
"@modelcontextprotocol/sdk": "^1.22.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"express": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0"
}
}

Some files were not shown because too many files have changed in this diff Show more