diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 981dd3a..90056ba 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.husky/pre-commit b/.husky/pre-commit
index f474323..cffd562 100644
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -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 )
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..0de301e
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,4 @@
+examples/basic-host/**/*.ts
+examples/basic-host/**/*.tsx
+examples/basic-server-react/**/*.ts
+examples/basic-server-react/**/*.tsx
diff --git a/README.md b/README.md
index 0819471..8137cce 100644
--- a/README.md
+++ b/README.md
@@ -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/
diff --git a/examples/basic-host/README.md b/examples/basic-host/README.md
new file mode 100644
index 0000000..24be409
--- /dev/null
+++ b/examples/basic-host/README.md
@@ -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.
diff --git a/examples/basic-host/index.html b/examples/basic-host/index.html
new file mode 100644
index 0000000..b865ebe
--- /dev/null
+++ b/examples/basic-host/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ MCP Apps Host
+
+
+
+
+
+
+
diff --git a/examples/basic-host/package.json b/examples/basic-host/package.json
new file mode 100644
index 0000000..6a686b6
--- /dev/null
+++ b/examples/basic-host/package.json
@@ -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"
+ }
+}
diff --git a/examples/basic-host/sandbox.html b/examples/basic-host/sandbox.html
new file mode 100644
index 0000000..f09d494
--- /dev/null
+++ b/examples/basic-host/sandbox.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+ MCP-UI Proxy
+
+
+
+
+
+
diff --git a/examples/basic-host/serve.ts b/examples/basic-host/serve.ts
new file mode 100644
index 0000000..7e6af90
--- /dev/null
+++ b/examples/basic-host/serve.ts
@@ -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");
+});
diff --git a/examples/basic-host/src/global.css b/examples/basic-host/src/global.css
new file mode 100644
index 0000000..97cda44
--- /dev/null
+++ b/examples/basic-host/src/global.css
@@ -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;
+}
diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts
new file mode 100644
index 0000000..8761d79
--- /dev/null
+++ b/examples/basic-host/src/implementation.ts
@@ -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;
+ appHtmlCache: Map;
+}
+
+
+export async function connectToServer(serverUrl: URL): Promise {
+ 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;
+ resultPromise: Promise;
+ appHtmlPromise?: Promise;
+}
+
+
+export function hasAppHtml(toolCallInfo: ToolCallInfo): toolCallInfo is Required {
+ return !!toolCallInfo.appHtmlPromise;
+}
+
+
+export function callTool(
+ serverInfo: ServerInfo,
+ name: string,
+ input: Record,
+): ToolCallInfo {
+ log.info("Calling tool", name, "with input", input);
+ const resultPromise = serverInfo.client.callTool({ name, arguments: input }) as Promise;
+
+ 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 {
+ 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 {
+ // 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((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,
+): Promise {
+ 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 {
+ const oninitialized = appBridge.oninitialized;
+ return new Promise((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;
+}
diff --git a/examples/basic-host/src/index.module.css b/examples/basic-host/src/index.module.css
new file mode 100644
index 0000000..684b2c7
--- /dev/null
+++ b/examples/basic-host/src/index.module.css
@@ -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;
+}
diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx
new file mode 100644
index 0000000..26df1ff
--- /dev/null
+++ b/examples/basic-host/src/index.tsx
@@ -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;
+}
+function Host({ serverInfoPromise }: HostProps) {
+ const serverInfo = use(serverInfoPromise);
+ const [toolCallInfos, setToolCallInfos] = useState([]);
+
+ return (
+ <>
+ {toolCallInfos.map((info, i) => (
+
+ ))}
+ 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 (
+
+
+
+ );
+}
+
+
+interface ToolCallInfoPanelProps {
+ toolCallInfo: ToolCallInfo;
+}
+function ToolCallInfoPanel({ toolCallInfo }: ToolCallInfoPanelProps) {
+ return (
+
+
+
{toolCallInfo.tool.name}
+
+
+
+
+
+ {
+ hasAppHtml(toolCallInfo)
+ ?
+ :
+ }
+
+
+
+
+ );
+}
+
+
+function JsonBlock({ value }: { value: object }) {
+ return (
+
+ {JSON.stringify(value, null, 2)}
+
+ );
+}
+
+
+interface AppIFramePanelProps {
+ toolCallInfo: Required;
+}
+function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) {
+ const iframeRef = useRef(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 (
+
+
+
+ );
+}
+
+
+interface ToolResultPanelProps {
+ toolCallInfo: ToolCallInfo;
+}
+function ToolResultPanel({ toolCallInfo }: ToolResultPanelProps) {
+ const result = use(toolCallInfo.resultPromise);
+ return ;
+}
+
+
+interface ErrorBoundaryProps {
+ children: ReactNode;
+}
+interface ErrorBoundaryState {
+ hasError: boolean;
+ error: unknown;
+}
+class ErrorBoundary extends Component {
+ 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 ERROR: {message}
;
+ }
+ return this.props.children;
+ }
+}
+
+
+createRoot(document.getElementById("root")!).render(
+
+ Connecting to server ({MCP_SERVER_URL.href})...}>
+
+
+ ,
+);
diff --git a/examples/basic-host/src/sandbox.ts b/examples/basic-host/src/sandbox.ts
new file mode 100644
index 0000000..0de6ec7
--- /dev/null
+++ b/examples/basic-host/src/sandbox.ts
@@ -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: {},
+}, "*");
diff --git a/examples/basic-host/src/vite-env.d.ts b/examples/basic-host/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/examples/basic-host/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/basic-host/tsconfig.json b/examples/basic-host/tsconfig.json
new file mode 100644
index 0000000..9d7c3bc
--- /dev/null
+++ b/examples/basic-host/tsconfig.json
@@ -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"]
+}
diff --git a/examples/basic-host/vite.config.ts b/examples/basic-host/vite.config.ts
new file mode 100644
index 0000000..e2f6958
--- /dev/null
+++ b/examples/basic-host/vite.config.ts
@@ -0,0 +1,24 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import { viteSingleFile } from "vite-plugin-singlefile";
+
+const INPUT = process.env.INPUT;
+if (!INPUT) {
+ throw new Error("INPUT environment variable is not set");
+}
+
+const isDevelopment = process.env.NODE_ENV === "development";
+
+export default defineConfig({
+ plugins: [react(), viteSingleFile()],
+ build: {
+ sourcemap: isDevelopment ? "inline" : undefined,
+ cssMinify: !isDevelopment,
+ minify: !isDevelopment,
+ rollupOptions: {
+ input: INPUT,
+ },
+ outDir: `dist`,
+ emptyOutDir: false,
+ },
+});
diff --git a/examples/basic-server-react/README.md b/examples/basic-server-react/README.md
new file mode 100644
index 0000000..087904d
--- /dev/null
+++ b/examples/basic-server-react/README.md
@@ -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.
diff --git a/examples/basic-server-react/mcp-app.html b/examples/basic-server-react/mcp-app.html
new file mode 100644
index 0000000..771b73e
--- /dev/null
+++ b/examples/basic-server-react/mcp-app.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Get Time App
+
+
+
+
+
+
+
diff --git a/examples/basic-server-react/package.json b/examples/basic-server-react/package.json
new file mode 100644
index 0000000..ed079f3
--- /dev/null
+++ b/examples/basic-server-react/package.json
@@ -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"
+ }
+}
diff --git a/examples/basic-server-react/server.ts b/examples/basic-server-react/server.ts
new file mode 100644
index 0000000..b5c792c
--- /dev/null
+++ b/examples/basic-server-react/server.ts
@@ -0,0 +1,104 @@
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
+import cors from "cors";
+import express, { type Request, type Response } from "express";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { z } from "zod";
+import { RESOURCE_URI_META_KEY } from "../../dist/src/app";
+
+const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001;
+const DIST_DIR = path.join(import.meta.dirname, "dist");
+
+
+const server = new McpServer({
+ name: "MCP App Server",
+ version: "1.0.0",
+});
+
+
+// MCP Apps require two-part registration: a tool (what the LLM calls) and a
+// resource (the UI it renders). The `_meta` field on the tool links to the
+// resource URI, telling hosts which UI to display when the tool executes.
+{
+ const resourceUri = "ui://get-time/mcp-app.html";
+
+ server.registerTool(
+ "get-time",
+ {
+ title: "Get Time",
+ description: "Returns the current server time as an ISO 8601 string.",
+ inputSchema: {},
+ outputSchema: { time: z.string() },
+ _meta: { [RESOURCE_URI_META_KEY]: resourceUri },
+ },
+ async (): Promise => {
+ const time = new Date().toISOString();
+ return {
+ content: [{ type: "text", text: time }],
+ structuredContent: { time },
+ };
+ },
+ );
+
+ server.registerResource(
+ resourceUri,
+ resourceUri,
+ {},
+ async (): Promise => {
+ const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
+
+ return {
+ contents: [
+ // Per the MCP App specification, "text/html+mcp" signals to the Host
+ // that this resource is indeed for an MCP App UI.
+ { uri: resourceUri, mimeType: "text/html+mcp", text: html },
+ ],
+ };
+ },
+ );
+}
+
+
+const app = express();
+app.use(cors());
+app.use(express.json());
+
+app.post("/mcp", async (req: Request, res: Response) => {
+ try {
+ const transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: undefined,
+ enableJsonResponse: true,
+ });
+ res.on("close", () => { transport.close(); });
+
+ await server.connect(transport);
+
+ 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,
+ });
+ }
+ }
+});
+
+const httpServer = app.listen(PORT, () => {
+ console.log(`Server listening on http://localhost:${PORT}/mcp`);
+});
+
+function shutdown() {
+ console.log("\nShutting down...");
+ httpServer.close(() => {
+ console.log("Server closed");
+ process.exit(0);
+ });
+}
+
+process.on("SIGINT", shutdown);
+process.on("SIGTERM", shutdown);
diff --git a/examples/basic-server-react/src/global.css b/examples/basic-server-react/src/global.css
new file mode 100644
index 0000000..97cda44
--- /dev/null
+++ b/examples/basic-server-react/src/global.css
@@ -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;
+}
diff --git a/examples/basic-server-react/src/mcp-app.module.css b/examples/basic-server-react/src/mcp-app.module.css
new file mode 100644
index 0000000..49caa84
--- /dev/null
+++ b/examples/basic-server-react/src/mcp-app.module.css
@@ -0,0 +1,63 @@
+.main {
+ --color-primary: #2563eb;
+ --color-primary-hover: #1d4ed8;
+ --color-notice-bg: #eff6ff;
+
+ min-width: 425px;
+
+ > * {
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+
+ > * + * {
+ margin-top: 1.5rem;
+ }
+}
+
+.action {
+ > * {
+ margin-top: 0;
+ margin-bottom: 0;
+ width: 100%;
+ }
+
+ > * + * {
+ margin-top: 0.5rem;
+ }
+
+ /* Consistent font for form inputs (inherits from global.css) */
+ textarea,
+ input {
+ font-family: inherit;
+ font-size: inherit;
+ }
+
+ button {
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 6px;
+ color: white;
+ font-weight: bold;
+ background-color: var(--color-primary);
+ cursor: pointer;
+
+ &:hover,
+ &:focus-visible {
+ background-color: var(--color-primary-hover);
+ }
+ }
+}
+
+.notice {
+ padding: 0.5rem 0.75rem;
+ color: var(--color-primary);
+ text-align: center;
+ font-style: italic;
+ background-color: var(--color-notice-bg);
+
+ &::before {
+ content: "ℹ️ ";
+ font-style: normal;
+ }
+}
diff --git a/examples/basic-server-react/src/mcp-app.tsx b/examples/basic-server-react/src/mcp-app.tsx
new file mode 100644
index 0000000..c3163fd
--- /dev/null
+++ b/examples/basic-server-react/src/mcp-app.tsx
@@ -0,0 +1,141 @@
+/**
+ * @file App that demonstrates a few features using MCP Apps SDK + React.
+ */
+import type { App } from "@modelcontextprotocol/ext-apps";
+import { useApp } from "@modelcontextprotocol/ext-apps/react";
+import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import { StrictMode, useCallback, useEffect, useState } from "react";
+import { createRoot } from "react-dom/client";
+import styles from "./mcp-app.module.css";
+
+
+const IMPLEMENTATION = { name: "Get Time App", version: "1.0.0" };
+
+
+const log = {
+ info: console.log.bind(console, "[APP]"),
+ warn: console.warn.bind(console, "[APP]"),
+ error: console.error.bind(console, "[APP]"),
+};
+
+
+function extractTime(callToolResult: CallToolResult): string {
+ const { time } = (callToolResult.structuredContent as { time?: string }) ?? {};
+ return time ?? "[ERROR]";
+}
+
+
+function GetTimeApp() {
+ const [toolResult, setToolResult] = useState(null);
+ const { app, error } = useApp({
+ appInfo: IMPLEMENTATION,
+ capabilities: {},
+ onAppCreated: (app) => {
+ app.ontoolinput = async (input) => {
+ log.info("Received tool call input:", input);
+ };
+
+ app.ontoolresult = async (result) => {
+ log.info("Received tool call result:", result);
+ setToolResult(result);
+ };
+
+ app.onerror = log.error;
+ },
+ });
+
+ if (error) return ERROR: {error.message}
;
+ if (!app) return Connecting...
;
+
+ return ;
+}
+
+
+interface GetTimeAppInnerProps {
+ app: App;
+ toolResult: CallToolResult | null;
+}
+function GetTimeAppInner({ app, toolResult }: GetTimeAppInnerProps) {
+ const [serverTime, setServerTime] = useState("Loading...");
+ const [messageText, setMessageText] = useState("This is message text.");
+ const [logText, setLogText] = useState("This is log text.");
+ const [linkUrl, setLinkUrl] = useState("https://modelcontextprotocol.io/");
+
+ useEffect(() => {
+ if (toolResult) {
+ setServerTime(extractTime(toolResult));
+ }
+ }, [toolResult]);
+
+ const handleGetTime = useCallback(async () => {
+ try {
+ log.info("Calling get-time tool...");
+ const result = await app.callServerTool({ name: "get-time", arguments: {} });
+ log.info("get-time result:", result);
+ setServerTime(extractTime(result));
+ } catch (e) {
+ log.error(e);
+ setServerTime("[ERROR]");
+ }
+ }, [app]);
+
+ const handleSendMessage = useCallback(async () => {
+ const signal = AbortSignal.timeout(5000);
+ try {
+ log.info("Sending message text to Host:", messageText);
+ const { isError } = await app.sendMessage(
+ { role: "user", content: [{ type: "text", text: messageText }] },
+ { signal },
+ );
+ log.info("Message", isError ? "rejected" : "accepted");
+ } catch (e) {
+ log.error("Message send error:", signal.aborted ? "timed out" : e);
+ }
+ }, [app, messageText]);
+
+ const handleSendLog = useCallback(async () => {
+ log.info("Sending log text to Host:", logText);
+ await app.sendLog({ level: "info", data: logText });
+ }, [app, logText]);
+
+ const handleOpenLink = useCallback(async () => {
+ log.info("Sending open link request to Host:", linkUrl);
+ const { isError } = await app.sendOpenLink({ url: linkUrl });
+ log.info("Open link request", isError ? "rejected" : "accepted");
+ }, [app, linkUrl]);
+
+ return (
+
+ Watch activity in the DevTools console!
+
+
+
+ Server Time: {serverTime}
+
+
Get Server Time
+
+
+
+ setMessageText(e.target.value)} />
+ Send Message
+
+
+
+ setLogText(e.target.value)} />
+ Send Log
+
+
+
+ setLinkUrl(e.target.value)} />
+ Open Link
+
+
+ );
+}
+
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/examples/basic-server-react/src/vite-env.d.ts b/examples/basic-server-react/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/examples/basic-server-react/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/basic-server-react/tsconfig.json b/examples/basic-server-react/tsconfig.json
new file mode 100644
index 0000000..fc3c210
--- /dev/null
+++ b/examples/basic-server-react/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src", "server.ts"]
+}
diff --git a/examples/basic-server-react/vite.config.ts b/examples/basic-server-react/vite.config.ts
new file mode 100644
index 0000000..da0af84
--- /dev/null
+++ b/examples/basic-server-react/vite.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import { viteSingleFile } from "vite-plugin-singlefile";
+
+const INPUT = process.env.INPUT;
+if (!INPUT) {
+ throw new Error("INPUT environment variable is not set");
+}
+
+const isDevelopment = process.env.NODE_ENV === "development";
+
+export default defineConfig({
+ plugins: [react(), viteSingleFile()],
+ build: {
+ sourcemap: isDevelopment ? "inline" : undefined,
+ cssMinify: !isDevelopment,
+ minify: !isDevelopment,
+
+ rollupOptions: {
+ input: INPUT,
+ },
+ outDir: "dist",
+ emptyOutDir: false,
+ },
+});
diff --git a/package-lock.json b/package-lock.json
index f0ba358..0af4d12 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
"esbuild": "^0.25.12",
"express": "^5.1.0",
"husky": "^9.1.7",
+ "nodemon": "^3.1.0",
"prettier": "^3.6.2",
"typedoc": "^0.28.14",
"typescript": "^5.9.3"
@@ -38,6 +39,77 @@
"@rollup/rollup-win32-x64-msvc": "^4.53.3"
}
},
+ "examples/basic-host": {
+ "name": "@modelcontextprotocol/ext-apps-basic-host",
+ "version": "1.0.0",
+ "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",
+ "typescript": "^5.9.3",
+ "vite": "^6.0.0",
+ "vite-plugin-singlefile": "^2.3.0",
+ "vitest": "^3.2.4"
+ }
+ },
+ "examples/basic-server-react": {
+ "version": "1.0.0",
+ "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"
+ }
+ },
+ "examples/basic-server-vanillajs": {
+ "version": "1.0.0",
+ "extraneous": true,
+ "dependencies": {
+ "@modelcontextprotocol/ext-apps": "../..",
+ "@modelcontextprotocol/sdk": "^1.22.0",
+ "zod": "^3.25.0"
+ },
+ "devDependencies": {
+ "@types/cors": "^2.8.19",
+ "@types/express": "^5.0.0",
+ "@types/node": "^22.0.0",
+ "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"
+ }
+ },
"examples/simple-host": {
"name": "@modelcontextprotocol/ext-apps-host",
"version": "1.0.0",
@@ -402,6 +474,10 @@
"resolved": "",
"link": true
},
+ "node_modules/@modelcontextprotocol/ext-apps-basic-host": {
+ "resolved": "examples/basic-host",
+ "link": true
+ },
"node_modules/@modelcontextprotocol/ext-apps-host": {
"resolved": "examples/simple-host",
"link": true
@@ -676,6 +752,16 @@
"@types/node": "*"
}
},
+ "node_modules/@types/cors": {
+ "version": "2.8.19",
+ "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
+ "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"dev": true,
@@ -973,6 +1059,33 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/anymatch/node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/argparse": {
"version": "2.0.1",
"dev": true,
@@ -999,6 +1112,23 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
+ "node_modules/basic-server-react": {
+ "resolved": "examples/basic-server-react",
+ "link": true
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/body-parser": {
"version": "2.2.1",
"license": "MIT",
@@ -1221,6 +1351,31 @@
"node": ">= 16"
}
},
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
"node_modules/cliui": {
"version": "8.0.1",
"dev": true,
@@ -1250,6 +1405,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/concurrently": {
"version": "9.2.1",
"dev": true,
@@ -1660,6 +1822,21 @@
"node": ">= 0.8"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",
@@ -1716,6 +1893,19 @@
"node": ">= 0.4"
}
},
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"license": "MIT",
@@ -1800,6 +1990,13 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/ignore-by-default": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
+ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/inherits": {
"version": "2.0.4",
"license": "ISC"
@@ -1811,6 +2008,29 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"dev": true,
@@ -1819,6 +2039,19 @@
"node": ">=8"
}
},
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-number": {
"version": "7.0.0",
"dev": true,
@@ -2036,6 +2269,105 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/nodemon": {
+ "version": "3.1.11",
+ "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
+ "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^3.5.2",
+ "debug": "^4",
+ "ignore-by-default": "^1.0.1",
+ "minimatch": "^3.1.2",
+ "pstree.remy": "^1.1.8",
+ "semver": "^7.5.3",
+ "simple-update-notifier": "^2.0.0",
+ "supports-color": "^5.5.0",
+ "touch": "^3.1.0",
+ "undefsafe": "^2.0.5"
+ },
+ "bin": {
+ "nodemon": "bin/nodemon.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/nodemon"
+ }
+ },
+ "node_modules/nodemon/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/nodemon/node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/nodemon/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/nodemon/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/nodemon/node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"license": "MIT",
@@ -2180,6 +2512,13 @@
"node": ">= 0.10"
}
},
+ "node_modules/pstree.remy": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/punycode.js": {
"version": "2.3.1",
"dev": true,
@@ -2246,6 +2585,32 @@
"node": ">=0.10.0"
}
},
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/readdirp/node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"dev": true,
@@ -2477,6 +2842,32 @@
"resolved": "examples/simple-server",
"link": true
},
+ "node_modules/simple-update-notifier": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
+ "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/simple-update-notifier/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"dev": true,
@@ -2623,6 +3014,16 @@
"node": ">=0.6"
}
},
+ "node_modules/touch": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
+ "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "nodetouch": "bin/nodetouch.js"
+ }
+ },
"node_modules/tree-kill": {
"version": "1.2.2",
"dev": true,
@@ -2687,6 +3088,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/undefsafe": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"dev": true,
diff --git a/package.json b/package.json
index ffc4d49..aba6945 100644
--- a/package.json
+++ b/package.json
@@ -26,11 +26,17 @@
"examples/*"
],
"scripts": {
- "start:example-host": "cd examples/simple-host && npm start",
- "start:example-mcp-server": "cd examples/simple-server && npm start",
- "start": "NODE_ENV=development npm run build && concurrently 'npm run start:example-host' 'npm run start:example-mcp-server'",
"build": "bun build.bun.ts",
+ "build:all": "npm run build && npm run examples:build",
"test": "bun test",
+ "examples:build": "concurrently --kill-others-on-fail 'npm run --workspace=examples/basic-host build' 'npm run --workspace=examples/basic-server-react build'",
+ "examples:start": "NODE_ENV=development npm run build && concurrently 'npm run examples:start:basic-host' 'npm run examples:start:basic-server-react'",
+ "examples:start:basic-host": "npm run --workspace=examples/basic-host start",
+ "examples:start:basic-server-react": "npm run --workspace=examples/basic-server-react start",
+ "watch": "nodemon --watch src --ext ts,tsx --exec 'bun build.bun.ts'",
+ "examples:dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run examples:dev:basic-host' 'npm run examples:dev:basic-server-react'",
+ "examples:dev:basic-host": "npm run --workspace=examples/basic-host dev",
+ "examples:dev:basic-server-react": "npm run --workspace=examples/basic-server-react dev",
"prepare": "npm run build && husky",
"docs": "typedoc",
"docs:watch": "typedoc --watch",
@@ -48,6 +54,7 @@
"esbuild": "^0.25.12",
"express": "^5.1.0",
"husky": "^9.1.7",
+ "nodemon": "^3.1.0",
"prettier": "^3.6.2",
"typedoc": "^0.28.14",
"typescript": "^5.9.3"
diff --git a/src/message-transport.ts b/src/message-transport.ts
index b997d7c..9a9a248 100644
--- a/src/message-transport.ts
+++ b/src/message-transport.ts
@@ -82,7 +82,7 @@ export class PostMessageTransport implements Transport {
}
const parsed = JSONRPCMessageSchema.safeParse(event.data);
if (parsed.success) {
- console.info("[host] Parsed message", parsed.data);
+ console.debug("Parsed message", parsed.data);
this.onmessage?.(parsed.data);
} else {
console.error("Failed to parse message", parsed.error.message, event);
@@ -115,7 +115,7 @@ export class PostMessageTransport implements Transport {
* @param options - Optional send options (currently unused)
*/
async send(message: JSONRPCMessage, options?: TransportSendOptions) {
- console.info("[host] Sending message", message);
+ console.debug("Sending message", message);
this.eventTarget.postMessage(message, "*");
}