diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 981dd3a9..90056ba9 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 f4743237..cffd5624 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 00000000..e2a4cdb1
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,6 @@
+examples/basic-host/**/*.ts
+examples/basic-host/**/*.tsx
+examples/basic-server-react/**/*.ts
+examples/basic-server-react/**/*.tsx
+examples/basic-server-vanillajs/**/*.ts
+examples/basic-server-vanillajs/**/*.tsx
diff --git a/README.md b/README.md
index 08194713..41b3955a 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,20 +54,22 @@ 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 (React)
+- [`examples/basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) — Example MCP server with tools that return UI Apps (vanilla JS)
+- [`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/
## Resources
+- [Quickstart Guide](https://modelcontextprotocol.github.io/ext-apps/api/documents/Quickstart.html)
- [API Documentation](https://modelcontextprotocol.github.io/ext-apps/api/)
- [Draft Specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx)
- [SEP-1865 Discussion](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865)
diff --git a/docs/quickstart.md b/docs/quickstart.md
new file mode 100644
index 00000000..79a1cfa3
--- /dev/null
+++ b/docs/quickstart.md
@@ -0,0 +1,251 @@
+---
+title: Quickstart
+---
+
+# Build Your First MCP App
+
+This tutorial walks you through building an MCP App—a tool with an interactive UI that renders inside MCP hosts like Claude Desktop.
+
+## What You'll Build
+
+A simple app that fetches the current server time and displays it in a clickable UI. You'll learn the core pattern: **MCP Apps = Tool + UI Resource**.
+
+## Prerequisites
+
+- Node.js 18+
+- Familiarity with the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
+
+## 1. Project Setup
+
+Create a new directory and initialize:
+
+```bash
+mkdir my-mcp-app && cd my-mcp-app
+npm init -y
+```
+
+Install dependencies:
+
+```bash
+npm install github:modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
+npm install -D typescript vite vite-plugin-singlefile express cors @types/express @types/cors tsx
+```
+
+Create `tsconfig.json`:
+
+```json
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "outDir": "dist"
+ },
+ "include": ["*.ts", "src/**/*.ts"]
+}
+```
+
+Create `vite.config.ts` — this bundles your UI into a single HTML file:
+
+```typescript
+import { defineConfig } from "vite";
+import { viteSingleFile } from "vite-plugin-singlefile";
+
+export default defineConfig({
+ plugins: [viteSingleFile()],
+ build: {
+ outDir: "dist",
+ rollupOptions: {
+ input: process.env.INPUT,
+ },
+ },
+});
+```
+
+Add to your `package.json`:
+
+```json
+{
+ "type": "module",
+ "scripts": {
+ "build": "INPUT=mcp-app.html vite build",
+ "serve": "npx tsx server.ts"
+ }
+}
+```
+
+**Full files:** [`package.json`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/package.json), [`tsconfig.json`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/tsconfig.json), [`vite.config.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/vite.config.ts)
+
+## 2. Create the Server
+
+MCP Apps use a **two-part registration**:
+
+1. A **tool** that the LLM/host calls
+2. A **resource** that serves the UI HTML
+
+The tool's `_meta` field links them together.
+
+Create `server.ts`:
+
+```typescript
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import { RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps";
+import cors from "cors";
+import express from "express";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { z } from "zod";
+
+const server = new McpServer({
+ name: "My MCP App Server",
+ version: "1.0.0",
+});
+
+// Two-part registration: tool + resource
+const resourceUri = "ui://get-time/mcp-app.html";
+
+server.registerTool(
+ "get-time",
+ {
+ title: "Get Time",
+ description: "Returns the current server time.",
+ inputSchema: {},
+ outputSchema: { time: z.string() },
+ _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, // Links tool to UI
+ },
+ async () => {
+ const time = new Date().toISOString();
+ return {
+ content: [{ type: "text", text: time }],
+ structuredContent: { time },
+ };
+ },
+);
+
+server.registerResource(resourceUri, resourceUri, {}, async () => {
+ const html = await fs.readFile(
+ path.join(import.meta.dirname, "dist", "mcp-app.html"),
+ "utf-8",
+ );
+ return {
+ contents: [{ uri: resourceUri, mimeType: "text/html+mcp", text: html }],
+ };
+});
+
+// Express server for MCP endpoint
+const app = express();
+app.use(cors());
+app.use(express.json());
+
+app.post("/mcp", async (req, res) => {
+ const transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: undefined,
+ enableJsonResponse: true,
+ });
+ res.on("close", () => transport.close());
+ await server.connect(transport);
+ await transport.handleRequest(req, res, req.body);
+});
+
+app.listen(3001, () => {
+ console.log("Server listening on http://localhost:3001/mcp");
+});
+```
+
+**Full file:** [`server.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/server.ts)
+
+## 3. Build the UI
+
+Create `mcp-app.html`:
+
+```html
+
+
+
+
+ Get Time App
+
+
+
+ Server Time: Loading...
+
+
+
+
+
+```
+
+Create `src/mcp-app.ts`:
+
+```typescript
+import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
+
+// Get element references
+const serverTimeEl = document.getElementById("server-time")!;
+const getTimeBtn = document.getElementById("get-time-btn")!;
+
+// Create app instance
+const app = new App({ name: "Get Time App", version: "1.0.0" });
+
+// Register handlers BEFORE connecting
+app.ontoolresult = (result) => {
+ const { time } = (result.structuredContent as { time?: string }) ?? {};
+ serverTimeEl.textContent = time ?? "[ERROR]";
+};
+
+// Wire up button click
+getTimeBtn.addEventListener("click", async () => {
+ const result = await app.callServerTool({ name: "get-time", arguments: {} });
+ const { time } = (result.structuredContent as { time?: string }) ?? {};
+ serverTimeEl.textContent = time ?? "[ERROR]";
+});
+
+// Connect to host
+app.connect(new PostMessageTransport(window.parent));
+```
+
+Build the UI:
+
+```bash
+npm run build
+```
+
+This produces `dist/mcp-app.html` containing your bundled app.
+
+**Full files:** [`mcp-app.html`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/mcp-app.html), [`src/mcp-app.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/src/mcp-app.ts)
+
+## 4. Test It
+
+You'll need two terminals.
+
+**Terminal 1** — Build and start your server:
+
+```bash
+npm run build && npm run serve
+```
+
+**Terminal 2** — Run the test host (from the [ext-apps repo](https://github.com/modelcontextprotocol/ext-apps)):
+
+```bash
+git clone https://github.com/modelcontextprotocol/ext-apps.git
+cd ext-apps/examples/basic-host
+npm install
+npm run start
+```
+
+Open http://localhost:8080 in your browser:
+
+1. Select **get-time** from the "Tool Name" dropdown
+2. Click **Call Tool**
+3. Your UI renders in the sandbox below
+4. Click **Get Server Time** — the current time appears!
+
+## Next Steps
+
+- **Host communication**: Add [`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), and [`sendOpenLink()`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendopenlink) to interact with the host — see [`src/mcp-app.ts`](https://github.com/modelcontextprotocol/ext-apps/blob/main/examples/basic-server-vanillajs/src/mcp-app.ts)
+- **React version**: Compare with [`basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) for a React-based UI
+- **API reference**: See the full [API documentation](https://modelcontextprotocol.github.io/ext-apps/api/)
diff --git a/examples/basic-host/README.md b/examples/basic-host/README.md
new file mode 100644
index 00000000..24be409c
--- /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 00000000..b865ebee
--- /dev/null
+++ b/examples/basic-host/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ MCP Apps Host
+
+
+
+
+
+
+
diff --git a/examples/simple-host/package.json b/examples/basic-host/package.json
similarity index 59%
rename from examples/simple-host/package.json
rename to examples/basic-host/package.json
index 6c4dc329..6a686b64 100644
--- a/examples/simple-host/package.json
+++ b/examples/basic-host/package.json
@@ -1,12 +1,14 @@
{
- "homepage": "https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-host",
- "name": "@modelcontextprotocol/ext-apps-host",
+ "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": {
- "start": "vite dev",
- "start:server": "vite preview",
- "build": "vite build"
+ "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": "../..",
@@ -28,6 +30,7 @@
"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/simple-host/sandbox.html b/examples/basic-host/sandbox.html
similarity index 100%
rename from examples/simple-host/sandbox.html
rename to examples/basic-host/sandbox.html
diff --git a/examples/basic-host/serve.ts b/examples/basic-host/serve.ts
new file mode 100644
index 00000000..7e6af90d
--- /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 00000000..97cda440
--- /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 00000000..8761d799
--- /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 00000000..684b2c70
--- /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 00000000..26df1ffa
--- /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 00000000..0de6ec70
--- /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 00000000..11f02fe2
--- /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 00000000..9d7c3bca
--- /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/simple-server/vite.config.ts b/examples/basic-host/vite.config.ts
similarity index 99%
rename from examples/simple-server/vite.config.ts
rename to examples/basic-host/vite.config.ts
index 28d7e5bf..e2f69582 100644
--- a/examples/simple-server/vite.config.ts
+++ b/examples/basic-host/vite.config.ts
@@ -15,7 +15,6 @@ export default defineConfig({
sourcemap: isDevelopment ? "inline" : undefined,
cssMinify: !isDevelopment,
minify: !isDevelopment,
-
rollupOptions: {
input: INPUT,
},
diff --git a/examples/basic-server-react/README.md b/examples/basic-server-react/README.md
new file mode 100644
index 00000000..120f4ce5
--- /dev/null
+++ b/examples/basic-server-react/README.md
@@ -0,0 +1,30 @@
+# Example: Basic Server (React)
+
+An MCP App example with a React UI.
+
+> [!TIP]
+> Looking for a vanilla JavaScript example? See [`basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs)!
+
+## 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 00000000..771b73e2
--- /dev/null
+++ b/examples/basic-server-react/mcp-app.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Get Time App
+
+
+
+
+
+
+
diff --git a/examples/simple-server/package.json b/examples/basic-server-react/package.json
similarity index 68%
rename from examples/simple-server/package.json
rename to examples/basic-server-react/package.json
index 52eb4703..ed079f3d 100644
--- a/examples/simple-server/package.json
+++ b/examples/basic-server-react/package.json
@@ -1,12 +1,14 @@
{
- "name": "simple-server",
+ "name": "basic-server-react",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
- "start": "NODE_ENV=development npm run build && npm run server",
- "build": "concurrently 'INPUT=ui-raw.html vite build' 'INPUT=ui-vanilla.html vite build' 'INPUT=ui-react.html vite build'",
- "server": "bun server.ts"
+ "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": "../..",
@@ -16,6 +18,7 @@
"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",
@@ -24,7 +27,7 @@
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"express": "^5.1.0",
- "typescript": "^5.7.2",
+ "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 00000000..b5c792c5
--- /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 00000000..97cda440
--- /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 00000000..49caa845
--- /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 00000000..c3163fd1
--- /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}
+
+
+
+
+
+
+
+
+ setLogText(e.target.value)} />
+
+
+
+
+ setLinkUrl(e.target.value)} />
+
+
+
+ );
+}
+
+
+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 00000000..11f02fe2
--- /dev/null
+++ b/examples/basic-server-react/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/simple-server/tsconfig.json b/examples/basic-server-react/tsconfig.json
similarity index 73%
rename from examples/simple-server/tsconfig.json
rename to examples/basic-server-react/tsconfig.json
index a4c834a6..fc3c2101 100644
--- a/examples/simple-server/tsconfig.json
+++ b/examples/basic-server-react/tsconfig.json
@@ -1,20 +1,20 @@
{
"compilerOptions": {
- "target": "ES2020",
- "useDefineForClassFields": true,
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "target": "ESNext",
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
- "skipLibCheck": true,
"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"]
+ "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 00000000..da0af84e
--- /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/examples/basic-server-vanillajs/README.md b/examples/basic-server-vanillajs/README.md
new file mode 100644
index 00000000..8d227266
--- /dev/null
+++ b/examples/basic-server-vanillajs/README.md
@@ -0,0 +1,56 @@
+# Example: Basic Server (Vanilla JS)
+
+An MCP App example with a vanilla JavaScript UI (no framework).
+
+> [!TIP]
+> Looking for a React-based example? See [`basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react)!
+
+## Overview
+
+- Tool registration with a linked UI resource
+- Vanilla JS UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class directly
+- 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.ts`](src/mcp-app.ts) - Vanilla JS UI using `App` class
+
+## 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.
+
+## Vanilla JS Pattern
+
+```typescript
+import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
+
+// Get element references from static HTML
+const button = document.getElementById("my-button")!;
+
+// Create app instance
+const app = new App({ name: "My App", version: "1.0.0" });
+
+// Register handlers BEFORE connecting
+app.ontoolresult = (result) => {
+ /* handle result */
+};
+app.onerror = console.error;
+
+// Add event listeners
+button.addEventListener("click", () => {
+ /* ... */
+});
+
+// Connect to host
+app.connect(new PostMessageTransport(window.parent));
+```
diff --git a/examples/basic-server-vanillajs/mcp-app.html b/examples/basic-server-vanillajs/mcp-app.html
new file mode 100644
index 00000000..1a88d60a
--- /dev/null
+++ b/examples/basic-server-vanillajs/mcp-app.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+ Get Time App
+
+
+
+ Watch activity in the DevTools console!
+
+
+
Server Time: Loading...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/basic-server-vanillajs/package.json b/examples/basic-server-vanillajs/package.json
new file mode 100644
index 00000000..3c1b7ff0
--- /dev/null
+++ b/examples/basic-server-vanillajs/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "basic-server-vanillajs",
+ "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",
+ "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"
+ }
+}
diff --git a/examples/basic-server-vanillajs/server.ts b/examples/basic-server-vanillajs/server.ts
new file mode 100644
index 00000000..b5c792c5
--- /dev/null
+++ b/examples/basic-server-vanillajs/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-vanillajs/src/global.css b/examples/basic-server-vanillajs/src/global.css
new file mode 100644
index 00000000..97cda440
--- /dev/null
+++ b/examples/basic-server-vanillajs/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-vanillajs/src/mcp-app.css b/examples/basic-server-vanillajs/src/mcp-app.css
new file mode 100644
index 00000000..49caa845
--- /dev/null
+++ b/examples/basic-server-vanillajs/src/mcp-app.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-vanillajs/src/mcp-app.ts b/examples/basic-server-vanillajs/src/mcp-app.ts
new file mode 100644
index 00000000..9c9364a0
--- /dev/null
+++ b/examples/basic-server-vanillajs/src/mcp-app.ts
@@ -0,0 +1,91 @@
+/**
+ * @file App that demonstrates a few features using MCP Apps SDK with vanilla JS.
+ */
+import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
+import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import "./global.css";
+import "./mcp-app.css";
+
+
+const log = {
+ info: console.log.bind(console, "[APP]"),
+ warn: console.warn.bind(console, "[APP]"),
+ error: console.error.bind(console, "[APP]"),
+};
+
+
+function extractTime(result: CallToolResult): string {
+ const { time } = (result.structuredContent as { time?: string }) ?? {};
+ return time ?? "[ERROR]";
+}
+
+
+// Get element references
+const serverTimeEl = document.getElementById("server-time")!;
+const getTimeBtn = document.getElementById("get-time-btn")!;
+const messageText = document.getElementById("message-text") as HTMLTextAreaElement;
+const sendMessageBtn = document.getElementById("send-message-btn")!;
+const logText = document.getElementById("log-text") as HTMLInputElement;
+const sendLogBtn = document.getElementById("send-log-btn")!;
+const linkUrl = document.getElementById("link-url") as HTMLInputElement;
+const openLinkBtn = document.getElementById("open-link-btn")!;
+
+
+// Create app instance
+const app = new App({ name: "Get Time App", version: "1.0.0" });
+
+
+// Register handlers BEFORE connecting
+app.ontoolinput = (params) => {
+ log.info("Received tool call input:", params);
+};
+
+app.ontoolresult = (result) => {
+ log.info("Received tool call result:", result);
+ serverTimeEl.textContent = extractTime(result);
+};
+
+app.onerror = log.error;
+
+
+// Add event listeners
+getTimeBtn.addEventListener("click", async () => {
+ try {
+ log.info("Calling get-time tool...");
+ const result = await app.callServerTool({ name: "get-time", arguments: {} });
+ log.info("get-time result:", result);
+ serverTimeEl.textContent = extractTime(result);
+ } catch (e) {
+ log.error(e);
+ serverTimeEl.textContent = "[ERROR]";
+ }
+});
+
+sendMessageBtn.addEventListener("click", async () => {
+ const signal = AbortSignal.timeout(5000);
+ try {
+ log.info("Sending message text to Host:", messageText.value);
+ const { isError } = await app.sendMessage(
+ { role: "user", content: [{ type: "text", text: messageText.value }] },
+ { signal },
+ );
+ log.info("Message", isError ? "rejected" : "accepted");
+ } catch (e) {
+ log.error("Message send error:", signal.aborted ? "timed out" : e);
+ }
+});
+
+sendLogBtn.addEventListener("click", async () => {
+ log.info("Sending log text to Host:", logText.value);
+ await app.sendLog({ level: "info", data: logText.value });
+});
+
+openLinkBtn.addEventListener("click", async () => {
+ log.info("Sending open link request to Host:", linkUrl.value);
+ const { isError } = await app.sendOpenLink({ url: linkUrl.value });
+ log.info("Open link request", isError ? "rejected" : "accepted");
+});
+
+
+// Connect to host
+app.connect(new PostMessageTransport(window.parent));
diff --git a/examples/basic-server-vanillajs/tsconfig.json b/examples/basic-server-vanillajs/tsconfig.json
new file mode 100644
index 00000000..535267b2
--- /dev/null
+++ b/examples/basic-server-vanillajs/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src", "server.ts"]
+}
diff --git a/examples/basic-server-vanillajs/vite.config.ts b/examples/basic-server-vanillajs/vite.config.ts
new file mode 100644
index 00000000..6ff6d997
--- /dev/null
+++ b/examples/basic-server-vanillajs/vite.config.ts
@@ -0,0 +1,24 @@
+import { defineConfig } from "vite";
+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: [viteSingleFile()],
+ build: {
+ sourcemap: isDevelopment ? "inline" : undefined,
+ cssMinify: !isDevelopment,
+ minify: !isDevelopment,
+
+ rollupOptions: {
+ input: INPUT,
+ },
+ outDir: "dist",
+ emptyOutDir: false,
+ },
+});
diff --git a/examples/simple-host/example-host-react.html b/examples/simple-host/example-host-react.html
deleted file mode 100644
index fc71204e..00000000
--- a/examples/simple-host/example-host-react.html
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
- Example MCP-UI React Host
-
-
-
-
-
-
diff --git a/examples/simple-host/example-host-vanilla.html b/examples/simple-host/example-host-vanilla.html
deleted file mode 100644
index cc4f2dfe..00000000
--- a/examples/simple-host/example-host-vanilla.html
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
-
- Example MCP View Host
-
-
- Example MCP View Host
-
-
-
-
-
\ No newline at end of file
diff --git a/examples/simple-host/index.html b/examples/simple-host/index.html
deleted file mode 100644
index 1bae39df..00000000
--- a/examples/simple-host/index.html
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/examples/simple-host/src/AppRenderer.tsx b/examples/simple-host/src/AppRenderer.tsx
deleted file mode 100644
index 146ec2d7..00000000
--- a/examples/simple-host/src/AppRenderer.tsx
+++ /dev/null
@@ -1,341 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-
-import { Client } from "@modelcontextprotocol/sdk/client/index.js";
-import {
- type CallToolResult,
- type LoggingMessageNotification,
- McpError,
- ErrorCode,
-} from "@modelcontextprotocol/sdk/types.js";
-
-import {
- AppBridge,
- PostMessageTransport,
-} from "@modelcontextprotocol/ext-apps/app-bridge";
-
-import {
- getToolUiResourceUri,
- readToolUiResourceHtml,
- setupSandboxProxyIframe,
- ToolUiResourceInfo,
-} from "./app-host-utils";
-
-/**
- * Props for the AppRenderer component.
- */
-export interface AppRendererProps {
- /** URL to the sandbox proxy HTML that will host the tool UI iframe */
- sandboxProxyUrl: URL;
-
- /** MCP client connected to the server providing the tool */
- client: Client;
-
- /** Name of the MCP tool to render UI for */
- toolName: string;
-
- /** Optional pre-fetched resource URI. If not provided, will be fetched via getToolUiResourceUri() */
- toolResourceUri?: string;
-
- /** Optional input arguments to pass to the tool UI once it's ready */
- toolInput?: Record;
-
- /** Optional result from tool execution to pass to the tool UI once it's ready */
- toolResult?: CallToolResult;
-
- onopenlink?: AppBridge["onopenlink"];
- onmessage?: AppBridge["onmessage"];
- onloggingmessage?: AppBridge["onloggingmessage"];
-
- /** Callback invoked when an error occurs during setup or message handling */
- onerror?: (error: Error) => void;
-}
-
-/**
- * React component that renders an MCP tool's custom UI in a sandboxed iframe.
- *
- * This component manages the complete lifecycle of an MCP-UI tool:
- * 1. Creates a sandboxed iframe with the proxy HTML
- * 2. Establishes MCP communication channel between host and iframe
- * 3. Fetches and loads the tool's UI resource (HTML)
- * 4. Sends tool inputs and results to the UI when ready
- * 5. Handles UI actions (intents, link opening, prompts, notifications)
- * 6. Automatically resizes iframe based on content size changes
- *
- * @example
- * ```tsx
- * {
- * if (action.type === 'intent') {
- * // Handle intent request from UI
- * console.log('Intent:', action.payload.intent);
- * }
- * }}
- * onerror={(error) => console.error('UI Error:', error)}
- * />
- * ```
- *
- * **Architecture:**
- * - Host (this component) ↔ Sandbox Proxy (iframe) ↔ Tool UI (nested iframe)
- * - Communication uses MCP protocol over postMessage
- * - Sandbox proxy provides CSP isolation for untrusted tool UIs
- * - Standard MCP initialization flow determines when UI is ready
- *
- * **Lifecycle:**
- * 1. `setupSandboxProxyIframe()` creates iframe and waits for proxy ready
- * 2. Component creates `McpUiProxyServer` instance
- * 3. Registers all handlers (BEFORE connecting to avoid race conditions)
- * 4. Connects proxy to iframe via `MessageTransport`
- * 5. MCP initialization completes → `onClientReady` callback fires
- * 6. Fetches tool UI resource and sends to sandbox proxy
- * 7. Sends tool inputs/results when iframe signals ready
- *
- * @param props - Component props
- * @returns React element containing the sandboxed tool UI iframe
- */
-export const AppRenderer = (props: AppRendererProps) => {
- const {
- client,
- sandboxProxyUrl,
- toolName,
- toolResourceUri,
- toolInput,
- toolResult,
- onmessage,
- onopenlink,
- onloggingmessage,
- onerror,
- } = props;
-
- // State
- const [appBridge, setAppBridge] = useState(null);
- const [iframeReady, setIframeReady] = useState(false);
- const [error, setError] = useState(null);
- const containerRef = useRef(null);
- const iframeRef = useRef(null);
-
- // Use refs for callbacks to avoid effect re-runs when they change
- const onmessageRef = useRef(onmessage);
- const onopenlinkRef = useRef(onopenlink);
- const onloggingmessageRef = useRef(onloggingmessage);
- const onerrorRef = useRef(onerror);
-
- useEffect(() => {
- onmessageRef.current = onmessage;
- onopenlinkRef.current = onopenlink;
- onloggingmessageRef.current = onloggingmessage;
- onerrorRef.current = onerror;
- });
-
- useEffect(() => {
- let mounted = true;
-
- const setup = async () => {
- try {
- const { iframe, onReady } =
- await setupSandboxProxyIframe(sandboxProxyUrl);
-
- if (!mounted) return;
-
- iframeRef.current = iframe;
- if (containerRef.current) {
- containerRef.current.appendChild(iframe);
- }
-
- await onReady;
-
- if (!mounted) return;
-
- const serverCapabilities = client.getServerCapabilities();
- const appBridge = new AppBridge(
- client,
- {
- name: "Example MCP UI Host",
- version: "1.0.0",
- },
- {
- openLinks: {},
- serverTools: serverCapabilities?.tools,
- serverResources: serverCapabilities?.resources,
- },
- );
-
- // Step 3: Register ALL handlers BEFORE connecting (critical for avoiding race conditions)
-
- // Hook into the standard MCP initialization to know when the inner iframe is ready
- appBridge.oninitialized = () => {
- if (!mounted) return;
- console.log("[Host] Inner iframe MCP client initialized");
- setIframeReady(true);
- };
-
- // Register handlers passed in via props
-
- appBridge.onmessage = (params, extra) => {
- if (!onmessageRef.current) {
- throw new McpError(ErrorCode.MethodNotFound, "Method not found");
- }
- return onmessageRef.current(params, extra);
- };
- appBridge.onopenlink = (params, extra) => {
- if (!onopenlinkRef.current) {
- throw new McpError(ErrorCode.MethodNotFound, "Method not found");
- }
- return onopenlinkRef.current(params, extra);
- };
- appBridge.onloggingmessage = (params) => {
- if (!onloggingmessageRef.current) {
- throw new McpError(ErrorCode.MethodNotFound, "Method not found");
- }
- return onloggingmessageRef.current(params);
- };
-
- appBridge.onsizechange = async ({ width, height }) => {
- if (iframeRef.current) {
- if (width !== undefined) {
- iframeRef.current.style.width = `${width}px`;
- }
- if (height !== undefined) {
- iframeRef.current.style.height = `${height}px`;
- }
- }
- };
-
- // Step 4: NOW connect (triggers MCP initialization handshake)
- // IMPORTANT: Pass iframe.contentWindow as BOTH target and source to ensure
- // this proxy only responds to messages from its specific iframe
- await appBridge.connect(
- new PostMessageTransport(
- iframe.contentWindow!,
- iframe.contentWindow!,
- ),
- );
-
- if (!mounted) return;
-
- // Step 5: Store proxy in state
- setAppBridge(appBridge);
- } catch (err) {
- console.error("[AppRenderer] Error:", err);
- if (!mounted) return;
- const error = err instanceof Error ? err : new Error(String(err));
- setError(error);
- onerrorRef.current?.(error);
- }
- };
-
- setup();
-
- return () => {
- mounted = false;
- // Cleanup: remove iframe from DOM
- if (
- iframeRef.current &&
- containerRef.current?.contains(iframeRef.current)
- ) {
- containerRef.current.removeChild(iframeRef.current);
- }
- };
- }, [client, sandboxProxyUrl]);
-
- // Effect 2: Fetch and send UI resource
- useEffect(() => {
- if (!appBridge) return;
-
- let mounted = true;
-
- const fetchAndSendResource = async () => {
- try {
- // Get the resource URI (use prop if provided, otherwise fetch)
- let resourceInfo: ToolUiResourceInfo;
-
- if (toolResourceUri) {
- // When URI is provided directly, assume it's NOT OpenAI Apps SDK format
- resourceInfo = {
- uri: toolResourceUri,
- };
- console.log(
- `[Host] Using provided resource URI: ${resourceInfo.uri}`,
- );
- } else {
- console.log(`[Host] Fetching resource URI for tool: ${toolName}`);
- const info = await getToolUiResourceUri(client, toolName);
- if (!info) {
- throw new Error(
- `Tool ${toolName} has no UI resource (no ui/resourceUri or openai/outputTemplate in tool._meta)`,
- );
- }
- resourceInfo = info;
- console.log(`[Host] Got resource URI: ${resourceInfo.uri}`);
- }
-
- if (!resourceInfo.uri) {
- throw new Error(`Tool ${toolName}: URI is undefined or empty`);
- }
-
- if (!mounted) return;
-
- // Read the HTML content
- console.log(`[Host] Reading resource HTML from: ${resourceInfo.uri}`);
- const html = await readToolUiResourceHtml(client, {
- uri: resourceInfo.uri,
- });
-
- if (!mounted) return;
-
- // Send the resource to the sandbox proxy
- console.log("[Host] Sending sandbox resource ready");
- await appBridge.sendSandboxResourceReady({ html });
- } catch (err) {
- if (!mounted) return;
- const error = err instanceof Error ? err : new Error(String(err));
- setError(error);
- onerrorRef.current?.(error);
- }
- };
-
- fetchAndSendResource();
-
- return () => {
- mounted = false;
- };
- }, [appBridge, toolName, toolResourceUri, client]);
-
- // Effect 3: Send tool input when ready
- useEffect(() => {
- if (appBridge && iframeReady && toolInput) {
- console.log("[Host] Sending tool input:", toolInput);
- appBridge.sendToolInput({ arguments: toolInput });
- }
- }, [appBridge, iframeReady, toolInput]);
-
- // Effect 4: Send tool result when ready
- useEffect(() => {
- if (appBridge && iframeReady && toolResult) {
- console.log("[Host] Sending tool result:", toolResult);
- appBridge.sendToolResult(toolResult);
- }
- }, [appBridge, iframeReady, toolResult]);
-
- // Render
- return (
-
- {error && (
-
- Error: {error.message}
-
- )}
-
- );
-};
diff --git a/examples/simple-host/src/app-host-utils.ts b/examples/simple-host/src/app-host-utils.ts
deleted file mode 100644
index deee1460..00000000
--- a/examples/simple-host/src/app-host-utils.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import type { McpUiSandboxProxyReadyNotification } from "@modelcontextprotocol/ext-apps";
-import { Client } from "@modelcontextprotocol/sdk/client/index.js";
-import { Tool } from "@modelcontextprotocol/sdk/types.js";
-
-const MCP_UI_RESOURCE_META_KEY = "ui/resourceUri";
-
-export async function setupSandboxProxyIframe(sandboxProxyUrl: URL): Promise<{
- iframe: HTMLIFrameElement;
- onReady: Promise;
-}> {
- const SANDBOX_PROXY_READY_METHOD: McpUiSandboxProxyReadyNotification["method"] =
- "ui/notifications/sandbox-proxy-ready";
-
- const iframe = document.createElement("iframe");
- iframe.style.width = "100%";
- iframe.style.height = "600px";
- iframe.style.border = "none";
- iframe.style.backgroundColor = "transparent";
- iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
-
- const onReady = new Promise((resolve, _reject) => {
- const initialListener = async (event: MessageEvent) => {
- if (event.source === iframe.contentWindow) {
- if (event.data && event.data.method === SANDBOX_PROXY_READY_METHOD) {
- window.removeEventListener("message", initialListener);
- resolve();
- }
- }
- };
- window.addEventListener("message", initialListener);
- });
-
- iframe.src = sandboxProxyUrl.href;
-
- return { iframe, onReady };
-}
-
-export type ToolUiResourceInfo = {
- uri: string;
-};
-
-export async function getToolUiResourceUri(
- client: Client,
- toolName: string,
-): Promise {
- let tool: Tool | undefined;
- let cursor: string | undefined = undefined;
- do {
- const toolsResult = await client.listTools({ cursor });
- tool = toolsResult.tools.find((t) => t.name === toolName);
- cursor = toolsResult.nextCursor;
- } while (!tool && cursor);
- if (!tool) {
- throw new Error(`tool ${toolName} not found`);
- }
- if (!tool._meta) {
- return null;
- }
-
- let uri: string;
- if (MCP_UI_RESOURCE_META_KEY in tool._meta) {
- uri = String(tool._meta[MCP_UI_RESOURCE_META_KEY]);
- } else {
- return null;
- }
- if (!uri.startsWith("ui://")) {
- throw new Error(
- `tool ${toolName} has unsupported output template URI: ${uri}`,
- );
- }
- return { uri };
-}
-
-export async function readToolUiResourceHtml(
- client: Client,
- opts: {
- uri: string;
- },
-): Promise {
- const resource = await client.readResource({ uri: opts.uri });
-
- if (!resource) {
- throw new Error("UI resource not found: " + opts.uri);
- }
- if (resource.contents.length !== 1) {
- throw new Error(
- "Unsupported UI resource content length: " + resource.contents.length,
- );
- }
- const content = resource.contents[0];
- let html: string;
- const isHtml = (t?: string) => t === "text/html;profile=mcp-app";
-
- if (
- "text" in content &&
- typeof content.text === "string" &&
- isHtml(content.mimeType)
- ) {
- html = content.text;
- } else if (
- "blob" in content &&
- typeof content.blob === "string" &&
- isHtml(content.mimeType)
- ) {
- html = atob(content.blob);
- } else {
- throw new Error(
- "Unsupported UI resource content format: " + JSON.stringify(content),
- );
- }
-
- return html;
-}
diff --git a/examples/simple-host/src/example-host-react.tsx b/examples/simple-host/src/example-host-react.tsx
deleted file mode 100644
index 4156ce7b..00000000
--- a/examples/simple-host/src/example-host-react.tsx
+++ /dev/null
@@ -1,264 +0,0 @@
-import { useEffect, useState } from "react";
-import { createRoot } from "react-dom/client";
-
-import { Client } from "@modelcontextprotocol/sdk/client/index.js";
-import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
-import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
-import { Tool } from "@modelcontextprotocol/sdk/types.js";
-import { AppRenderer, AppRendererProps } from "../src/AppRenderer";
-import { AppBridge } from "../../../dist/src/app-bridge";
-
-// We use '[::1]' for the sandbox to ensure it's a different origin from 'localhost'.
-const SANDBOX_PROXY_URL = new URL(
- "/sandbox.html",
- location.href.replace("localhost:", "[::1]:"),
-);
-
-/**
- * Example React application demonstrating the AppRenderer component.
- *
- * This shows how to:
- * - Connect to an MCP server
- * - List available tools
- * - Render tool UIs using AppRenderer
- * - Handle UI actions from the tool
- */
-function ExampleApp() {
- const [client, setClient] = useState(null);
- const [tools, setTools] = useState([]);
- const [activeTools, setActiveTools] = useState<
- Array<{ id: string; name: string; input: Record }>
- >([]);
- const [error, setError] = useState(null);
-
- // Connect to MCP server on mount
- useEffect(() => {
- let mounted = true;
-
- async function connect() {
- try {
- const newClient = new Client({
- name: "MCP UI React Host",
- version: "1.0.0",
- });
-
- const mcpServerUrl = new URL("http://localhost:3001/mcp");
- console.log(
- "[React Host] Attempting SSE connection to",
- mcpServerUrl.href,
- );
-
- try {
- await newClient.connect(new SSEClientTransport(mcpServerUrl));
- console.log("[React Host] SSE connection successful");
- } catch (err) {
- console.warn(
- "[React Host] SSE connection failed, falling back to HTTP:",
- err,
- );
- await newClient.connect(
- new StreamableHTTPClientTransport(mcpServerUrl),
- );
- console.log("[React Host] HTTP connection successful");
- }
-
- if (!mounted) return;
-
- // List available tools
- const toolsResult = await newClient.listTools();
-
- if (!mounted) return;
-
- setClient(newClient);
- setTools(toolsResult.tools);
- } catch (err) {
- if (!mounted) return;
- const errorMsg = err instanceof Error ? err.message : String(err);
- setError(`Failed to connect to MCP server: ${errorMsg}`);
- console.error("[React Host] Connection error:", err);
- }
- }
-
- connect();
-
- return () => {
- mounted = false;
- };
- }, []);
-
- const handleAddToolUI = (
- toolName: string,
- input: Record,
- ) => {
- const id = `${toolName}-${Date.now()}`;
- setActiveTools((prev) => [...prev, { id, name: toolName, input }]);
- };
-
- const handleRemoveToolUI = (id: string) => {
- setActiveTools((prev) => prev.filter((t) => t.id !== id));
- };
-
- const handleMessage: AppRendererProps["onmessage"] = async (
- params,
- _extra,
- ) => {
- console.log("[React Host] Message:", params);
- return {};
- };
-
- const handleLoggingMessage: AppRendererProps["onloggingmessage"] = (
- params,
- ) => {
- console.log("[React Host] Logging message:", params);
- };
-
- const handleOpenLink: AppRendererProps["onopenlink"] = async (
- params,
- _extra,
- ) => {
- console.log("[React Host] Open link request:", params);
- window.open(params.url, "_blank", "noopener,noreferrer");
- return { isError: false };
- };
-
- const handleError = (toolId: string, err: Error) => {
- console.error(`[React Host] Error from tool ${toolId}:`, err);
- setError(`Tool ${toolId}: ${err.message}`);
- };
-
- if (error) {
- return (
-
-
Example MCP-UI React Host
-
- Error: {error}
-
-
-
- );
- }
-
- if (!client) {
- return (
-
-
Example MCP-UI React Host
-
Connecting to MCP server...
-
- );
- }
-
- return (
-
-
Example MCP-UI React Host
-
-
-
Available Tools
- {tools.length === 0 ? (
-
No tools available with UI resources.
- ) : (
-
- {tools.map((tool) => (
-
- ))}
-
- )}
-
-
-
- {activeTools.map((tool) => (
-
-
-
{tool.name}
-
-
-
-
handleError(tool.id, err)}
- />
-
- ))}
-
- {activeTools.length === 0 && (
-
- No tool UIs active. Click a button above to add one.
-
- )}
-
-
- );
-}
-
-// Mount the React app
-const root = document.getElementById("root");
-if (root) {
- createRoot(root).render();
-} else {
- console.error("Root element not found");
-}
diff --git a/examples/simple-host/src/example-host-vanilla.ts b/examples/simple-host/src/example-host-vanilla.ts
deleted file mode 100644
index 249aabf8..00000000
--- a/examples/simple-host/src/example-host-vanilla.ts
+++ /dev/null
@@ -1,163 +0,0 @@
-import {
- AppBridge,
- PostMessageTransport,
-} from "@modelcontextprotocol/ext-apps/app-bridge";
-import {
- setupSandboxProxyIframe,
- getToolUiResourceUri,
- readToolUiResourceHtml,
-} from "../src/app-host-utils";
-
-import { Client } from "@modelcontextprotocol/sdk/client/index.js";
-import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
-import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
-import { LoggingMessageNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
-import {
- McpUiOpenLinkRequestSchema,
- McpUiMessageRequestSchema,
- McpUiSizeChangeNotificationSchema,
-} from "@modelcontextprotocol/ext-apps";
-
-// We use '[::1]' for the sandbox to ensure it's a different origin from 'localhost'.
-const SANDBOX_PROXY_URL = new URL(
- "/sandbox.html",
- location.href.replace("localhost:", "[::1]:"),
-);
-
-window.addEventListener("load", async () => {
- const client = new Client({
- name: "MCP UI Proxy Server",
- version: "1.0.0",
- });
- const mcpServerUrl = new URL("http://localhost:3001/mcp");
- console.log("[Client] Attempting SSE connection to", mcpServerUrl.href);
- try {
- await client.connect(new SSEClientTransport(mcpServerUrl));
- console.log("[Client] SSE connection successful");
- } catch (error) {
- console.warn(
- "[Client] SSE connection failed, falling back to HTTP:",
- error,
- );
- await client.connect(new StreamableHTTPClientTransport(mcpServerUrl));
- console.log("[Client] HTTP connection successful");
- }
-
- const tools = Object.fromEntries(
- (await client.listTools()).tools.map((t) => [t.name, t]),
- );
-
- const controlsDiv = document.getElementById("controls") as HTMLDivElement;
- const chatRootDiv = document.getElementById("chat-root") as HTMLDivElement;
-
- /**
- * Creates a tool UI instance for the given tool name.
- * This demonstrates the new simplified API where the host manages the full proxy lifecycle.
- */
- async function createToolUI(
- toolName: string,
- toolInput: Record,
- ) {
- try {
- // Step 1: Create iframe and wait for sandbox proxy ready
- const { iframe, onReady } =
- await setupSandboxProxyIframe(SANDBOX_PROXY_URL);
-
- chatRootDiv.appendChild(iframe);
-
- // Wait for sandbox proxy to be ready
- await onReady;
-
- // Step 2: Create proxy server instance
- const serverCapabilities = client.getServerCapabilities();
- const appBridge = new AppBridge(
- client,
- {
- name: "Example MCP UI Host",
- version: "1.0.0",
- },
- {
- openLinks: {},
- serverTools: serverCapabilities?.tools,
- serverResources: serverCapabilities?.resources,
- },
- );
-
- // Step 3: Register handlers BEFORE connecting
- appBridge.oninitialized = () => {
- console.log("[Example] Inner iframe MCP client initialized");
-
- // Send tool input once iframe is ready
- appBridge.sendToolInput({ arguments: toolInput });
- };
-
- appBridge.onopenlink = async ({ url }) => {
- console.log("[Example] Open link requested:", url);
- window.open(url, "_blank", "noopener,noreferrer");
- return { isError: false };
- };
-
- appBridge.onmessage = async (params) => {
- console.log("[Example] Message requested:", params);
- return { isError: false };
- };
-
- // Handle size changes by resizing the iframe
- appBridge.onsizechange = ({ width, height }) => {
- if (width !== undefined) {
- iframe.style.width = `${width}px`;
- }
- if (height !== undefined) {
- iframe.style.height = `${height}px`;
- }
- };
-
- appBridge.onloggingmessage = async (params) => {
- console.log("[Tool UI Log]", params);
- };
-
- // Step 4: Connect proxy to iframe (triggers MCP initialization)
- // Pass iframe.contentWindow as both target and source for proper message filtering
- await appBridge.connect(
- new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!),
- );
-
- // Step 5: Fetch and send UI resource
- const resourceInfo = await getToolUiResourceUri(client, toolName);
- if (!resourceInfo) {
- throw new Error(`Tool ${toolName} has no UI resource`);
- }
-
- const html = await readToolUiResourceHtml(client, {
- uri: resourceInfo.uri,
- });
- await appBridge.sendSandboxResourceReady({ html });
-
- console.log("[Example] Tool UI setup complete for:", toolName);
- } catch (error) {
- console.error("[Example] Error setting up tool UI:", error);
- }
- }
-
- // Create buttons for available tools
- if ([...Object.keys(tools)].some((n) => n.startsWith("pizza-"))) {
- for (const t of Object.values(tools)) {
- if (t.name.startsWith("pizza-")) {
- controlsDiv.appendChild(
- Object.assign(document.createElement("button"), {
- innerText: t.name,
- onclick: () => createToolUI(t.name, { pizzaTopping: "Mushrooms" }),
- }),
- );
- }
- }
- } else {
- controlsDiv.appendChild(
- Object.assign(document.createElement("button"), {
- innerText: "Add MCP UI View",
- onclick: () =>
- createToolUI("create-ui-vanilla", { message: "Hello from Host!" }),
- }),
- );
- }
-});
diff --git a/examples/simple-host/src/sandbox.ts b/examples/simple-host/src/sandbox.ts
deleted file mode 100644
index 5b507047..00000000
--- a/examples/simple-host/src/sandbox.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import type {
- McpUiSandboxProxyReadyNotification,
- McpUiSandboxResourceReadyNotification,
-} from "../../../dist/src/types";
-
-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(/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/)) {
- throw new Error(
- `Embedding domain not allowed in referrer ${document.referrer} (update the validation logic to allow your domain)`,
- );
-}
-
-// Try and break out of this iframe
-try {
- window.top!.alert("If you see this, the sandbox is not setup securely.");
-
- throw new Error(
- "Managed to break out of iframe, the sandbox is not setup securely.",
- );
-} catch (e) {
- // Ignore
-}
-
-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);
-
-window.addEventListener("message", async (event) => {
- // Note: in production you'll also want to validate event.origin against your outer domain.
- if (event.source === window.parent) {
- if (
- event.data &&
- event.data.method ===
- ("ui/notifications/sandbox-resource-ready" as McpUiSandboxResourceReadyNotification["method"])
- ) {
- const { html, sandbox } = event.data.params;
- if (typeof sandbox === "string") {
- inner.setAttribute("sandbox", sandbox);
- }
- if (typeof html === "string") {
- inner.srcdoc = html;
- }
- } else {
- if (inner && inner.contentWindow) {
- inner.contentWindow.postMessage(event.data, "*");
- }
- }
- } else if (event.source === inner.contentWindow) {
- // Relay messages from inner to parent
- window.parent.postMessage(event.data, "*");
- }
-});
-
-// Notify parent that proxy is ready to receive HTML (distinct event)
-window.parent.postMessage(
- {
- jsonrpc: "2.0",
- method:
- "ui/notifications/sandbox-proxy-ready" as McpUiSandboxProxyReadyNotification["method"],
- params: {},
- },
- "*",
-);
diff --git a/examples/simple-host/tsconfig.json b/examples/simple-host/tsconfig.json
deleted file mode 100644
index 5550165e..00000000
--- a/examples/simple-host/tsconfig.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2020",
- "module": "ESNext",
- "lib": ["ES2021", "DOM"],
- "jsx": "react-jsx",
- "declaration": true,
- "emitDeclarationOnly": true,
- "outDir": "./dist",
- "rootDir": "./",
- "moduleResolution": "bundler",
- "esModuleInterop": true,
- "forceConsistentCasingInFileNames": true,
- "strict": true,
- "skipLibCheck": true,
- "resolveJsonModule": true
- },
- "include": [
- "examples/**/*.ts",
- "examples/**/*.tsx",
- "src/**/*.ts",
- "src/**/*.tsx"
- ],
- "exclude": ["node_modules", "dist"]
-}
diff --git a/examples/simple-host/vite.config.ts b/examples/simple-host/vite.config.ts
deleted file mode 100644
index 14a4709c..00000000
--- a/examples/simple-host/vite.config.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { defineConfig } from "vite";
-import react from "@vitejs/plugin-react";
-
-export default defineConfig(({ mode }) => {
- const isDevelopment = mode === "development";
- return {
- plugins: [react()],
- build: {
- sourcemap: isDevelopment ? "inline" : undefined,
- cssMinify: !isDevelopment,
- minify: !isDevelopment,
- rollupOptions: {
- input: [
- "index.html",
- "example-host-vanilla.html",
- "example-host-react.html",
- "sandbox.html",
- ],
- },
- outDir: `dist`,
- emptyOutDir: false,
- },
- };
-});
diff --git a/examples/simple-server/server.ts b/examples/simple-server/server.ts
deleted file mode 100644
index b3b19bff..00000000
--- a/examples/simple-server/server.ts
+++ /dev/null
@@ -1,297 +0,0 @@
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import express, { Request, Response } from "express";
-import { randomUUID } from "node:crypto";
-import { z } from "zod";
-import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
-import {
- CallToolResult,
- isInitializeRequest,
- ReadResourceResult,
- Resource,
-} from "@modelcontextprotocol/sdk/types.js";
-import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js";
-import cors from "cors";
-import path from "node:path";
-import fs from "node:fs/promises";
-import { fileURLToPath } from "node:url";
-import { RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps";
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-
-// Load both UI HTML files from dist/
-const distDir = path.join(__dirname, "dist");
-const loadHtml = async (name: string) => {
- const htmlPath = path.join(distDir, `${name}.html`);
- return fs.readFile(htmlPath, "utf-8");
-};
-
-// Create an MCP server with both UI tools
-const getServer = async () => {
- const server = new McpServer(
- {
- name: "simple-mcp-server",
- version: "1.0.0",
- },
- { capabilities: { logging: {} } },
- );
-
- // Load HTML for both UIs
- const [rawHtml, vanillaHtml, reactHtml] = await Promise.all([
- loadHtml("ui-raw"),
- loadHtml("ui-vanilla"),
- loadHtml("ui-react"),
- ]);
-
- const registerResource = (resource: Resource, htmlContent: string) => {
- server.registerResource(
- resource.name,
- resource.uri,
- resource,
- async (): Promise => ({
- contents: [
- {
- uri: resource.uri,
- mimeType: resource.mimeType,
- text: htmlContent,
- },
- ],
- }),
- );
- return resource;
- };
-
- {
- const rawResource = registerResource(
- {
- name: "ui-raw-template",
- uri: "ui://raw",
- title: "Raw UI Template",
- description: "A simple raw HTML UI",
- mimeType: "text/html;profile=mcp-app",
- },
- rawHtml,
- );
-
- server.registerTool(
- "create-ui-raw",
- {
- title: "Raw UI",
- description: "A tool that returns a raw HTML UI (no Apps SDK runtime)",
- inputSchema: {
- message: z.string().describe("Message to display"),
- },
- _meta: {
- [RESOURCE_URI_META_KEY]: rawResource.uri,
- },
- },
- async ({ message }): Promise => ({
- content: [{ type: "text", text: JSON.stringify({ message }) }],
- structuredContent: { message },
- }),
- );
- }
-
- {
- const vanillaResource = registerResource(
- {
- name: "ui-vanilla-template",
- uri: "ui://vanilla",
- title: "Vanilla UI Template",
- description: "A simple vanilla JS UI",
- mimeType: "text/html;profile=mcp-app",
- },
- vanillaHtml,
- );
-
- server.registerTool(
- "create-ui-vanilla",
- {
- title: "Vanilla UI",
- description: "A tool that returns a vanilla TS + Apps SDK UI",
- inputSchema: {
- message: z.string().describe("Message to display"),
- },
- _meta: {
- [RESOURCE_URI_META_KEY]: vanillaResource.uri,
- },
- },
- async ({ message }): Promise => ({
- content: [{ type: "text", text: JSON.stringify({ message }) }],
- structuredContent: { message },
- }),
- );
- }
-
- {
- const reactResource = registerResource(
- {
- name: "ui-react-template",
- uri: "ui://react",
- title: "React UI Template",
- description: "A React-based UI",
- mimeType: "text/html;profile=mcp-app",
- },
- reactHtml,
- );
-
- server.registerTool(
- "create-ui-react",
- {
- title: "React UI",
- description: "A tool that returns a React-based UI",
- inputSchema: {
- message: z.string().describe("Message to display"),
- },
- _meta: {
- [RESOURCE_URI_META_KEY]: reactResource.uri,
- },
- },
- async ({ message }): Promise => ({
- content: [{ type: "text", text: JSON.stringify({ message }) }],
- structuredContent: { message },
- }),
- );
- }
-
- // --- Common tool: get-weather ---
- server.registerTool(
- "get-weather",
- {
- title: "Get Weather",
- description: "Returns current weather for a location",
- inputSchema: {
- location: z.string().describe("Location to get weather for"),
- },
- },
- async ({ location }): Promise => {
- const temperature = 25;
- const condition = "sunny";
- return {
- content: [
- {
- type: "text",
- text: `The weather in ${location} is ${condition}, ${temperature}°C.`,
- },
- ],
- structuredContent: { temperature, condition },
- };
- },
- );
-
- return server;
-};
-
-const MCP_PORT = process.env.MCP_PORT
- ? parseInt(process.env.MCP_PORT, 10)
- : 3001;
-
-const app = express();
-app.use(express.json());
-app.use(
- cors({
- origin: "*",
- exposedHeaders: ["Mcp-Session-Id"],
- }),
-);
-
-// Map to store transports by session ID
-const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
-
-const mcpPostHandler = async (req: Request, res: Response) => {
- const sessionId = req.headers["mcp-session-id"] as string | undefined;
-
- try {
- let transport: StreamableHTTPServerTransport;
- if (sessionId && transports[sessionId]) {
- transport = transports[sessionId];
- } else if (!sessionId && isInitializeRequest(req.body)) {
- const eventStore = new InMemoryEventStore();
- transport = new StreamableHTTPServerTransport({
- sessionIdGenerator: () => randomUUID(),
- eventStore,
- onsessioninitialized: (sessionId) => {
- console.log(`Session initialized: ${sessionId}`);
- transports[sessionId] = transport;
- },
- });
-
- transport.onclose = () => {
- const sid = transport.sessionId;
- if (sid && transports[sid]) {
- console.log(`Session closed: ${sid}`);
- delete transports[sid];
- }
- };
-
- const server = await getServer();
- await server.connect(transport);
- await transport.handleRequest(req, res, req.body);
- return;
- } else {
- res.status(400).json({
- jsonrpc: "2.0",
- error: { code: -32000, message: "Bad Request: No valid session ID" },
- id: null,
- });
- return;
- }
-
- await transport.handleRequest(req, res, req.body);
- } catch (error) {
- console.error("Error handling MCP request:", error);
- if (!res.headersSent) {
- res.status(500).json({
- jsonrpc: "2.0",
- error: { code: -32603, message: "Internal server error" },
- id: null,
- });
- }
- }
-};
-
-app.post("/mcp", mcpPostHandler);
-
-app.get("/mcp", async (req: Request, res: Response) => {
- const sessionId = req.headers["mcp-session-id"] as string | undefined;
- if (!sessionId || !transports[sessionId]) {
- res.status(400).send("Invalid or missing session ID");
- return;
- }
- const transport = transports[sessionId];
- await transport.handleRequest(req, res);
-});
-
-app.delete("/mcp", async (req: Request, res: Response) => {
- const sessionId = req.headers["mcp-session-id"] as string | undefined;
- if (!sessionId || !transports[sessionId]) {
- res.status(400).send("Invalid or missing session ID");
- return;
- }
- try {
- const transport = transports[sessionId];
- await transport.handleRequest(req, res);
- } catch (error) {
- console.error("Error handling session termination:", error);
- if (!res.headersSent) {
- res.status(500).send("Error processing session termination");
- }
- }
-});
-
-app.listen(MCP_PORT, () => {
- console.log(`MCP Server listening on http://localhost:${MCP_PORT}/mcp`);
-});
-
-process.on("SIGINT", async () => {
- console.log("Shutting down...");
- for (const sessionId in transports) {
- try {
- await transports[sessionId].close();
- delete transports[sessionId];
- } catch (error) {
- console.error(`Error closing session ${sessionId}:`, error);
- }
- }
- process.exit(0);
-});
diff --git a/examples/simple-server/src/ui-raw.ts b/examples/simple-server/src/ui-raw.ts
deleted file mode 100644
index 1cf2b2ea..00000000
--- a/examples/simple-server/src/ui-raw.ts
+++ /dev/null
@@ -1,235 +0,0 @@
-/**
- * @file App that does NOT depend on Apps SDK runtime.
- *
- * The Raw UI example has no runtime dependency to the Apps SDK
- * but still imports its types for static type safety.
- * Types can be just stripped, e.g. w/ the command line:
- *
- *
- * npx esbuild src/ui-raw.ts --bundle --outfile=dist/ui-raw.js --minify --sourcemap --platform=browser
- *
- *
- * We implement a barebones JSON-RPC message sender/receiver (see `app` object below),
- * but without timeouts or runtime type validation of any kind
- * (for that, use the Apps SDK / see ui-vanilla.ts or ui-react.ts).
- */
-
-import type {
- McpUiInitializeRequest,
- McpUiInitializeResult,
- McpUiInitializedNotification,
- McpUiToolResultNotification,
- McpUiHostContextChangedNotification,
- McpUiToolInputNotification,
- McpUiSizeChangeNotification,
- McpUiMessageRequest,
- McpUiMessageResult,
- McpUiOpenLinkRequest,
- McpUiOpenLinkResult,
-} from "@modelcontextprotocol/ext-apps";
-
-import type {
- CallToolRequest,
- CallToolResult,
- JSONRPCMessage,
- LoggingMessageNotification,
-} from "@modelcontextprotocol/sdk/types.js";
-
-const app = (() => {
- type Sendable = { method: string; params: any };
-
- let nextId = 1;
-
- return {
- sendRequest({ method, params }: T) {
- const id = nextId++;
- window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*");
- return new Promise((resolve, reject) => {
- window.addEventListener("message", function listener(event) {
- const data: JSONRPCMessage = event.data;
- if (event.data?.id === id) {
- window.removeEventListener("message", listener);
- if (event.data?.result) {
- resolve(event.data.result as Result);
- } else if (event.data?.error) {
- reject(new Error(event.data.error));
- }
- } else {
- reject(new Error(`Unsupported message: ${JSON.stringify(data)}`));
- }
- });
- });
- },
- sendNotification({ method, params }: T) {
- window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*");
- },
- onNotification(
- method: T["method"],
- handler: (params: T["params"]) => void,
- ) {
- window.addEventListener("message", function listener(event) {
- if (event.data?.method === method) {
- handler(event.data.params);
- }
- });
- },
- };
-})();
-
-window.addEventListener("load", async () => {
- const root = document.getElementById("root")!;
- const appendText = (textContent: string, opts = {}) => {
- root.appendChild(
- Object.assign(document.createElement("div"), {
- textContent,
- ...opts,
- }),
- );
- };
- const appendError = (error: unknown) =>
- appendText(
- `Error: ${error instanceof Error ? error.message : String(error)}`,
- { style: "color: red;" },
- );
-
- app.onNotification(
- "ui/notifications/tool-input",
- async (params) => {
- appendText(`Tool call input: ${JSON.stringify(params)}`);
- },
- );
- app.onNotification(
- "ui/notifications/tool-result",
- async (params) => {
- appendText(`Tool call result: ${JSON.stringify(params)}`);
- },
- );
- app.onNotification(
- "ui/notifications/host-context-changed",
- async (params) => {
- appendText(`Host context changed: ${JSON.stringify(params)}`);
- },
- );
-
- const initializeResult = await app.sendRequest<
- McpUiInitializeRequest,
- McpUiInitializeResult
- >({
- method: "ui/initialize",
- params: {
- appCapabilities: {},
- appInfo: { name: "My UI", version: "1.0.0" },
- protocolVersion: "2025-06-18",
- },
- });
-
- appendText(`Initialize result: ${JSON.stringify(initializeResult)}`);
-
- app.sendNotification({
- method: "ui/notifications/initialized",
- params: {},
- });
-
- new ResizeObserver(() => {
- const rect = (
- document.body.parentElement ?? document.body
- ).getBoundingClientRect();
- const width = Math.ceil(rect.width);
- const height = Math.ceil(rect.height);
- app.sendNotification({
- method: "ui/notifications/size-change",
- params: { width, height },
- });
- }).observe(document.body);
-
- root.appendChild(
- Object.assign(document.createElement("button"), {
- textContent: "Get Weather (Tool)",
- onclick: async () => {
- try {
- const result = await app.sendRequest(
- {
- method: "tools/call",
- params: {
- name: "get-weather",
- arguments: { location: "Tokyo" },
- },
- },
- );
-
- appendText(`Weather tool result: ${JSON.stringify(result)}`);
- } catch (e) {
- appendError(e);
- }
- },
- }),
- );
-
- root.appendChild(
- Object.assign(document.createElement("button"), {
- textContent: "Notify Cart Updated",
- onclick: async () => {
- app.sendNotification({
- method: "notifications/message",
- params: {
- level: "info",
- data: "cart-updated",
- },
- });
- },
- }),
- );
-
- root.appendChild(
- Object.assign(document.createElement("button"), {
- textContent: "Prompt Weather in Tokyo",
- onclick: async () => {
- try {
- const { isError } = await app.sendRequest<
- McpUiMessageRequest,
- McpUiMessageResult
- >({
- method: "ui/message",
- params: {
- role: "user",
- content: [
- {
- type: "text",
- text: "What is the weather in Tokyo?",
- },
- ],
- },
- });
-
- appendText(`Message result: ${isError ? "error" : "success"}`);
- } catch (e) {
- appendError(e);
- }
- },
- }),
- );
-
- root.appendChild(
- Object.assign(document.createElement("button"), {
- textContent: "Open Link to Google",
- onclick: async () => {
- try {
- const { isError } = await app.sendRequest<
- McpUiOpenLinkRequest,
- McpUiOpenLinkResult
- >({
- method: "ui/open-link",
- params: {
- url: "https://www.google.com",
- },
- });
- appendText(`Link result: ${isError ? "error" : "success"}`);
- } catch (e) {
- appendError(e);
- }
- },
- }),
- );
-
- console.log("Initialized with host info:", initializeResult.hostInfo);
-});
diff --git a/examples/simple-server/src/ui-react.tsx b/examples/simple-server/src/ui-react.tsx
deleted file mode 100644
index 45275fff..00000000
--- a/examples/simple-server/src/ui-react.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-/**
- * @file App that demonstrates a few features using React + the Apps SDK.
- */
-import { useState, useCallback } from "react";
-import { createRoot } from "react-dom/client";
-import {
- useApp,
- McpUiSizeChangeNotificationSchema,
- McpUiToolResultNotificationSchema,
-} from "@modelcontextprotocol/ext-apps/react";
-import type {
- CallToolResult,
- Implementation,
-} from "@modelcontextprotocol/sdk/types.js";
-
-const APP_INFO: Implementation = {
- name: "MCP UI React Example Client",
- version: "1.0.0",
-};
-
-export function McpClientApp() {
- const [toolResults, setToolResults] = useState([]);
- const [messages, setMessages] = useState([]);
-
- const { app, isConnected, error } = useApp({
- appInfo: APP_INFO,
- capabilities: {},
- onAppCreated: (app) => {
- app.ontoolresult = async (params) => {
- setToolResults((prev) => [...prev, params]);
- };
- },
- });
-
- const handleGetWeather = useCallback(async () => {
- if (!app) return;
- try {
- const result = await app.callServerTool({
- name: "get-weather",
- arguments: { location: "Tokyo" },
- });
- setMessages((prev) => [
- ...prev,
- `Weather tool result: ${JSON.stringify(result)}`,
- ]);
- } catch (e) {
- setMessages((prev) => [...prev, `Tool call error: ${e}`]);
- }
- }, [app]);
-
- const handleNotifyCart = useCallback(async () => {
- if (!app) return;
- await app.sendLog({ level: "info", data: "cart-updated" });
- setMessages((prev) => [...prev, "Notification sent: cart-updated"]);
- }, [app]);
-
- const handlePromptWeather = useCallback(async () => {
- if (!app) return;
- const signal = AbortSignal.timeout(5000);
- try {
- const { isError } = await app.sendMessage(
- {
- role: "user",
- content: [
- {
- type: "text",
- text: "What is the weather in Tokyo?",
- },
- ],
- },
- { signal },
- );
- setMessages((prev) => [
- ...prev,
- `Prompt result: ${isError ? "error" : "success"}`,
- ]);
- } catch (e) {
- if (signal.aborted) {
- setMessages((prev) => [...prev, "Prompt request timed out"]);
- return;
- }
- setMessages((prev) => [...prev, `Prompt error: ${e}`]);
- }
- }, [app]);
-
- const handleOpenLink = useCallback(async () => {
- if (!app) return;
- const { isError } = await app.sendOpenLink({
- url: "https://www.google.com",
- });
- setMessages((prev) => [
- ...prev,
- `Open link result: ${isError ? "error" : "success"}`,
- ]);
- }, [app]);
-
- if (error) {
- return (
- Error connecting: {error.message}
- );
- }
-
- if (!isConnected) {
- return Connecting...
;
- }
-
- return (
-
-
MCP UI Client (React)
-
-
-
-
-
-
-
-
-
-
-
- {toolResults.length > 0 && (
-
-
Tool Results:
- {toolResults.map((result, i) => (
-
- isError: {String(result.isError ?? false)}
-
- content: {JSON.stringify(result.content)}
-
- {result.structuredContent && (
- <>
- structuredContent:{" "}
- {JSON.stringify(result.structuredContent)}
- >
- )}
-
- ))}
-
- )}
-
- {messages.length > 0 && (
-
-
Messages:
- {messages.map((msg, i) => (
-
- {msg}
-
- ))}
-
- )}
-
- );
-}
-
-window.addEventListener("load", () => {
- const root = document.getElementById("root");
- if (!root) {
- throw new Error("Root element not found");
- }
-
- createRoot(root).render();
-});
diff --git a/examples/simple-server/src/ui-vanilla.ts b/examples/simple-server/src/ui-vanilla.ts
deleted file mode 100644
index f536a45b..00000000
--- a/examples/simple-server/src/ui-vanilla.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-/**
- * @file Demonstrate a few Apps SDK features.
- *
- * The vanilla (no React) UI uses the Apps SDK.
- *
- * The Apps SDK offers advantages over the Raw UI example,
- * such as ability to set timeouts, strong runtime type validation
- * and simpler methods for each request/response interaction.
- */
-import {
- App,
- PostMessageTransport,
- McpUiToolInputNotificationSchema,
- McpUiToolResultNotificationSchema,
- McpUiHostContextChangedNotificationSchema,
-} from "@modelcontextprotocol/ext-apps";
-
-window.addEventListener("load", async () => {
- const root = document.getElementById("root")!;
- const appendText = (textContent: string, opts = {}) => {
- root.appendChild(
- Object.assign(document.createElement("div"), {
- textContent,
- ...opts,
- }),
- );
- };
- const appendError = (error: unknown) =>
- appendText(
- `Error: ${error instanceof Error ? error.message : String(error)}`,
- { style: "color: red;" },
- );
-
- const app = new App({
- name: "MCP UI Client (Vanilla)",
- version: "1.0.0",
- });
-
- app.ontoolinput = (params) => {
- appendText(`Tool call input received: ${JSON.stringify(params.arguments)}`);
- };
- app.ontoolresult = ({ content, structuredContent, isError }) => {
- appendText(
- `Tool call result received: isError=${isError}, content=${content}, structuredContent=${JSON.stringify(structuredContent)}`,
- );
- };
- app.onhostcontextchanged = (params) => {
- appendText(`Host context changed: ${JSON.stringify(params)}`);
- };
-
- root.appendChild(
- Object.assign(document.createElement("button"), {
- textContent: "Get Weather (Tool)",
- onclick: async () => {
- try {
- const result = await app.callServerTool({
- name: "get-weather",
- arguments: { location: "Tokyo" },
- });
- appendText(`Weather tool result: ${JSON.stringify(result)}`);
- } catch (e) {
- appendError(e);
- }
- },
- }),
- );
-
- root.appendChild(
- Object.assign(document.createElement("button"), {
- textContent: "Notify Cart Updated",
- onclick: async () => {
- try {
- await app.sendLog({
- level: "info",
- data: "cart-updated",
- });
- } catch (e) {
- appendError(e);
- }
- },
- }),
- );
-
- root.appendChild(
- Object.assign(document.createElement("button"), {
- textContent: "Prompt Weather in Tokyo",
- onclick: async () => {
- const signal = AbortSignal.timeout(5000);
- try {
- const { isError } = await app.sendMessage(
- {
- role: "user",
- content: [
- {
- type: "text",
- text: "What is the weather in Tokyo?",
- },
- ],
- },
- { signal },
- );
- appendText(`Prompt result: ${isError ? "error" : "success"}`);
- } catch (e) {
- if (signal.aborted) {
- appendError("Prompt request timed out");
- return;
- }
- appendError(e);
- }
- },
- }),
- );
-
- root.appendChild(
- Object.assign(document.createElement("button"), {
- textContent: "Open Link to Google",
- onclick: async () => {
- try {
- const { isError } = await app.sendOpenLink({
- url: "https://www.google.com",
- });
- appendText(`Open link result: ${isError ? "error" : "success"}`);
- } catch (e) {
- appendError(e);
- }
- },
- }),
- );
-
- await app.connect(new PostMessageTransport(window.parent));
-});
diff --git a/examples/simple-server/ui-raw.html b/examples/simple-server/ui-raw.html
deleted file mode 100644
index cf320849..00000000
--- a/examples/simple-server/ui-raw.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
- MCP UI Client (Raw)
-
-
-
-
-
-
diff --git a/examples/simple-server/ui-react.html b/examples/simple-server/ui-react.html
deleted file mode 100644
index c0e7fd7b..00000000
--- a/examples/simple-server/ui-react.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
- MCP UI Client (React)
-
-
-
-
-
-
diff --git a/examples/simple-server/ui-vanilla.html b/examples/simple-server/ui-vanilla.html
deleted file mode 100644
index ecec6c87..00000000
--- a/examples/simple-server/ui-vanilla.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
- MCP UI Client (Vanilla)
-
-
-
-
-
-
diff --git a/package-lock.json b/package-lock.json
index f0ba358d..b33cef7c 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,9 +39,80 @@
"@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",
+ "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",
+ "extraneous": true,
"dependencies": {
"@modelcontextprotocol/ext-apps": "../..",
"@modelcontextprotocol/sdk": "^1.22.0",
@@ -66,6 +138,7 @@
},
"examples/simple-server": {
"version": "1.0.0",
+ "extraneous": true,
"dependencies": {
"@modelcontextprotocol/ext-apps": "../..",
"@modelcontextprotocol/sdk": "^1.22.0",
@@ -402,8 +475,8 @@
"resolved": "",
"link": true
},
- "node_modules/@modelcontextprotocol/ext-apps-host": {
- "resolved": "examples/simple-host",
+ "node_modules/@modelcontextprotocol/ext-apps-basic-host": {
+ "resolved": "examples/basic-host",
"link": true
},
"node_modules/@modelcontextprotocol/sdk": {
@@ -676,6 +749,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 +1056,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 +1109,27 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
+ "node_modules/basic-server-react": {
+ "resolved": "examples/basic-server-react",
+ "link": true
+ },
+ "node_modules/basic-server-vanillajs": {
+ "resolved": "examples/basic-server-vanillajs",
+ "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 +1352,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 +1406,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 +1823,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 +1894,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 +1991,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 +2009,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 +2040,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 +2270,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 +2513,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 +2586,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,
@@ -2473,9 +2839,31 @@
"dev": true,
"license": "ISC"
},
- "node_modules/simple-server": {
- "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",
@@ -2623,6 +3011,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 +3085,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 ffc4d493..efb6381c 100644
--- a/package.json
+++ b/package.json
@@ -26,11 +26,19 @@
"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' 'npm run --workspace=examples/basic-server-vanillajs 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",
+ "examples:start:basic-server-vanillajs": "npm run --workspace=examples/basic-server-vanillajs 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",
+ "examples:dev:basic-server-vanillajs": "npm run --workspace=examples/basic-server-vanillajs dev",
"prepare": "npm run build && husky",
"docs": "typedoc",
"docs:watch": "typedoc --watch",
@@ -48,6 +56,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 b997d7c6..9a9a248f 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, "*");
}
diff --git a/typedoc.json b/typedoc.json
index cb7d7790..8ef1cb0e 100644
--- a/typedoc.json
+++ b/typedoc.json
@@ -1,5 +1,6 @@
{
"$schema": "https://typedoc.org/schema.json",
+ "projectDocuments": ["docs/quickstart.md"],
"entryPoints": [
"src/app.ts",
"src/app-bridge.ts",