Add basic-host and basic-server-react examples
Introduce new example implementations to replace simple-host and simple-server: - basic-host: Reference host implementation with double-iframe sandbox pattern for secure UI isolation (outer iframe validates and relays messages, inner iframe renders untrusted tool UI via srcdoc) - basic-server-react: MCP server example demonstrating tool registration with linked UI resources and React UI using the useApp() hook Build infrastructure updates: - Add build:all script and simplify CI/pre-commit to use it - Add examples:dev for watch-mode development workflow Also change PostMessageTransport logging from info to debug level. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
0af15322af
commit
97310d50c5
30 changed files with 1808 additions and 25 deletions
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,2 @@
|
|||
npm run build
|
||||
npm run build:all
|
||||
npm run prettier:fix
|
||||
( cd examples/simple-host && npm run build )
|
||||
( cd examples/simple-server && npm run build )
|
||||
|
|
|
|||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
examples/basic-host/**/*.ts
|
||||
examples/basic-host/**/*.tsx
|
||||
examples/basic-server-react/**/*.ts
|
||||
examples/basic-server-react/**/*.tsx
|
||||
|
|
@ -23,7 +23,7 @@ Embed and communicate with MCP Apps in your chat application.
|
|||
|
||||
- **SDK for Hosts**: `@modelcontextprotocol/ext-apps/app-bridge` — [API Docs](https://modelcontextprotocol.github.io/ext-apps/api/modules/app-bridge.html)
|
||||
|
||||
There's no _supported_ host implementation in this repo (beyond the [examples/simple-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-host) example).
|
||||
There's no _supported_ host implementation in this repo (beyond the [examples/basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example).
|
||||
|
||||
We have [contributed a tentative implementation](https://github.com/MCP-UI-Org/mcp-ui/pull/147) of hosting / iframing / sandboxing logic to the [MCP-UI](https://github.com/idosal/mcp-ui) repository, and expect OSS clients may use it, while other clients might roll their own hosting logic.
|
||||
|
||||
|
|
@ -54,14 +54,14 @@ Your `package.json` will then look like:
|
|||
|
||||
## Examples
|
||||
|
||||
- [examples/simple-server](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-server) — Example MCP server with tools that return UI Apps
|
||||
- [examples/simple-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-host) — Bare-bones example of hosting MCP Apps
|
||||
- [`examples/basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) — Example MCP server with tools that return UI Apps
|
||||
- [`examples/basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) — Bare-bones example of hosting MCP Apps
|
||||
|
||||
To run the examples end-to-end:
|
||||
|
||||
```
|
||||
npm i
|
||||
npm start
|
||||
npm run examples:start
|
||||
```
|
||||
|
||||
Then open http://localhost:8080/
|
||||
|
|
|
|||
37
examples/basic-host/README.md
Normal file
37
examples/basic-host/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Example: Basic Host
|
||||
|
||||
A reference implementation showing how to build an MCP Host application that connects to MCP servers and renders tool UIs in a secure sandbox.
|
||||
|
||||
This basic host can also be used to test MCP Apps during local development.
|
||||
|
||||
## Key Files
|
||||
|
||||
- [`index.html`](index.html) / [`src/index.tsx`](src/index.tsx) - React UI host with tool selection, parameter input, and iframe management
|
||||
- [`sandbox.html`](sandbox.html) / [`src/sandbox.ts`](src/sandbox.ts) - Outer iframe proxy with security validation and bidirectional message relay
|
||||
- [`src/implementation.ts`](src/implementation.ts) - Core logic: server connection, tool calling, and AppBridge setup
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
# Open http://localhost:8080
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This example uses a double-iframe sandbox pattern for secure UI isolation:
|
||||
|
||||
```
|
||||
Host (port 8080)
|
||||
└── Outer iframe (port 8081) - sandbox proxy
|
||||
└── Inner iframe (srcdoc) - untrusted tool UI
|
||||
```
|
||||
|
||||
**Why two iframes?**
|
||||
|
||||
- The outer iframe runs on a separate origin (port 8081) preventing direct access to the host
|
||||
- The inner iframe receives HTML via `srcdoc` and is restricted by sandbox attributes
|
||||
- Messages flow through the outer iframe which validates and relays them bidirectionally
|
||||
|
||||
This architecture ensures that even if tool UI code is malicious, it cannot access the host application's DOM, cookies, or JavaScript context.
|
||||
13
examples/basic-host/index.html
Normal file
13
examples/basic-host/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCP Apps Host</title>
|
||||
<link rel="stylesheet" href="/src/global.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
36
examples/basic-host/package.json
Normal file
36
examples/basic-host/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"homepage": "https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host",
|
||||
"name": "@modelcontextprotocol/ext-apps-basic-host",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "concurrently 'INPUT=index.html vite build' 'INPUT=sandbox.html vite build'",
|
||||
"watch": "concurrently 'INPUT=index.html vite build --watch' 'INPUT=sandbox.html vite build --watch'",
|
||||
"serve": "bun serve.ts",
|
||||
"start": "NODE_ENV=development npm run build && npm run serve",
|
||||
"dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/ext-apps": "../..",
|
||||
"@modelcontextprotocol/sdk": "^1.22.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"bun": "^1.3.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
48
examples/basic-host/sandbox.html
Normal file
48
examples/basic-host/sandbox.html
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<!-- Permissive CSP so nested content is not constrained by host CSP -->
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
default-src 'self';
|
||||
img-src * data: blob: 'unsafe-inline';
|
||||
media-src * blob: data:;
|
||||
font-src * blob: data:;
|
||||
script-src 'self'
|
||||
'wasm-unsafe-eval'
|
||||
'unsafe-inline'
|
||||
'unsafe-eval'
|
||||
blob: data: http://localhost:* https://localhost:*;
|
||||
style-src * blob: data: 'unsafe-inline';
|
||||
connect-src *;
|
||||
frame-src * blob: data: http://localhost:* https://localhost:*;
|
||||
base-uri 'self';
|
||||
" />
|
||||
<title>MCP-UI Proxy</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
iframe {
|
||||
background-color: transparent;
|
||||
border: 0px none transparent;
|
||||
padding: 0px;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="/src/sandbox.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
80
examples/basic-host/serve.ts
Normal file
80
examples/basic-host/serve.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* HTTP servers for the MCP UI example:
|
||||
* - Host server (port 8080): serves host HTML files (React and Vanilla examples)
|
||||
* - Sandbox server (port 8081): serves sandbox.html with permissive CSP
|
||||
*
|
||||
* Running on separate ports ensures proper origin isolation for security.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const HOST_PORT = parseInt(process.env.HOST_PORT || "8080", 10);
|
||||
const SANDBOX_PORT = parseInt(process.env.SANDBOX_PORT || "8081", 10);
|
||||
const DIRECTORY = join(__dirname, "dist");
|
||||
|
||||
// ============ Host Server (port 8080) ============
|
||||
const hostApp = express();
|
||||
hostApp.use(cors());
|
||||
|
||||
// Exclude sandbox.html from host server
|
||||
hostApp.use((req, res, next) => {
|
||||
if (req.path === "/sandbox.html") {
|
||||
res.status(404).send("Sandbox is served on a different port");
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
hostApp.use(express.static(DIRECTORY));
|
||||
|
||||
hostApp.get("/", (_req, res) => {
|
||||
res.redirect("/index.html");
|
||||
});
|
||||
|
||||
// ============ Sandbox Server (port 8081) ============
|
||||
const sandboxApp = express();
|
||||
sandboxApp.use(cors());
|
||||
|
||||
// Permissive CSP for sandbox content
|
||||
sandboxApp.use((_req, res, next) => {
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"img-src * data: blob: 'unsafe-inline'",
|
||||
"style-src * blob: data: 'unsafe-inline'",
|
||||
"script-src * blob: data: 'unsafe-inline' 'unsafe-eval'",
|
||||
"connect-src *",
|
||||
"font-src * blob: data:",
|
||||
"media-src * blob: data:",
|
||||
"frame-src * blob: data:",
|
||||
].join("; ");
|
||||
res.setHeader("Content-Security-Policy", csp);
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.setHeader("Pragma", "no-cache");
|
||||
res.setHeader("Expires", "0");
|
||||
next();
|
||||
});
|
||||
|
||||
sandboxApp.get(["/", "/sandbox.html"], (_req, res) => {
|
||||
res.sendFile(join(DIRECTORY, "sandbox.html"));
|
||||
});
|
||||
|
||||
sandboxApp.use((_req, res) => {
|
||||
res.status(404).send("Only sandbox.html is served on this port");
|
||||
});
|
||||
|
||||
// ============ Start both servers ============
|
||||
hostApp.listen(HOST_PORT, () => {
|
||||
console.log(`Host server: http://localhost:${HOST_PORT}`);
|
||||
});
|
||||
|
||||
sandboxApp.listen(SANDBOX_PORT, () => {
|
||||
console.log(`Sandbox server: http://localhost:${SANDBOX_PORT}`);
|
||||
console.log("\nPress Ctrl+C to stop\n");
|
||||
});
|
||||
12
examples/basic-host/src/global.css
Normal file
12
examples/basic-host/src/global.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 1em;
|
||||
}
|
||||
257
examples/basic-host/src/implementation.ts
Normal file
257
examples/basic-host/src/implementation.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { RESOURCE_URI_META_KEY, type McpUiSandboxProxyReadyNotification } from "@modelcontextprotocol/ext-apps";
|
||||
import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
|
||||
const SANDBOX_PROXY_URL = new URL("http://localhost:8081/sandbox.html");
|
||||
const IMPLEMENTATION = { name: "MCP Apps Host", version: "1.0.0" };
|
||||
|
||||
|
||||
export const log = {
|
||||
info: console.log.bind(console, "[HOST]"),
|
||||
warn: console.warn.bind(console, "[HOST]"),
|
||||
error: console.error.bind(console, "[HOST]"),
|
||||
};
|
||||
|
||||
|
||||
export interface ServerInfo {
|
||||
client: Client;
|
||||
tools: Map<string, Tool>;
|
||||
appHtmlCache: Map<string, string>;
|
||||
}
|
||||
|
||||
|
||||
export async function connectToServer(serverUrl: URL): Promise<ServerInfo> {
|
||||
const client = new Client(IMPLEMENTATION);
|
||||
|
||||
log.info("Connecting to server:", serverUrl.href);
|
||||
await client.connect(new StreamableHTTPClientTransport(serverUrl));
|
||||
log.info("Connection successful");
|
||||
|
||||
const toolsList = await client.listTools();
|
||||
const tools = new Map(toolsList.tools.map((tool) => [tool.name, tool]));
|
||||
log.info("Server tools:", Array.from(tools.keys()));
|
||||
|
||||
return { client, tools, appHtmlCache: new Map() };
|
||||
}
|
||||
|
||||
|
||||
export interface ToolCallInfo {
|
||||
serverInfo: ServerInfo;
|
||||
tool: Tool;
|
||||
input: Record<string, unknown>;
|
||||
resultPromise: Promise<CallToolResult>;
|
||||
appHtmlPromise?: Promise<string>;
|
||||
}
|
||||
|
||||
|
||||
export function hasAppHtml(toolCallInfo: ToolCallInfo): toolCallInfo is Required<ToolCallInfo> {
|
||||
return !!toolCallInfo.appHtmlPromise;
|
||||
}
|
||||
|
||||
|
||||
export function callTool(
|
||||
serverInfo: ServerInfo,
|
||||
name: string,
|
||||
input: Record<string, unknown>,
|
||||
): ToolCallInfo {
|
||||
log.info("Calling tool", name, "with input", input);
|
||||
const resultPromise = serverInfo.client.callTool({ name, arguments: input }) as Promise<CallToolResult>;
|
||||
|
||||
const tool = serverInfo.tools.get(name);
|
||||
if (!tool) {
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
|
||||
const toolCallInfo: ToolCallInfo = { serverInfo, tool, input, resultPromise };
|
||||
|
||||
const uiResourceUri = getUiResourceUri(tool);
|
||||
if (uiResourceUri) {
|
||||
toolCallInfo.appHtmlPromise = getUiResourceHtml(serverInfo, uiResourceUri);
|
||||
}
|
||||
|
||||
return toolCallInfo;
|
||||
}
|
||||
|
||||
|
||||
function getUiResourceUri(tool: Tool): string | undefined {
|
||||
const uri = tool._meta?.[RESOURCE_URI_META_KEY];
|
||||
if (typeof uri === "string" && uri.startsWith("ui://")) {
|
||||
return uri;
|
||||
} else if (uri !== undefined) {
|
||||
throw new Error(`Invalid UI resource URI: ${JSON.stringify(uri)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function getUiResourceHtml(serverInfo: ServerInfo, uri: string): Promise<string> {
|
||||
let html = serverInfo.appHtmlCache.get(uri);
|
||||
if (html) {
|
||||
log.info("Read UI resource from cache:", uri);
|
||||
return html;
|
||||
}
|
||||
|
||||
log.info("Reading UI resource:", uri);
|
||||
const resource = await serverInfo.client.readResource({ uri });
|
||||
|
||||
if (!resource) {
|
||||
throw new Error(`Resource not found: ${uri}`);
|
||||
}
|
||||
|
||||
if (resource.contents.length !== 1) {
|
||||
throw new Error(`Unexpected contents count: ${resource.contents.length}`);
|
||||
}
|
||||
|
||||
const content = resource.contents[0];
|
||||
|
||||
// Per the MCP App specification, "text/html+mcp" signals this resource is
|
||||
// indeed for an MCP App UI.
|
||||
if (content.mimeType !== "text/html+mcp") {
|
||||
throw new Error(`Unsupported MIME type: ${content.mimeType}`);
|
||||
}
|
||||
|
||||
html = "blob" in content ? atob(content.blob) : content.text;
|
||||
serverInfo.appHtmlCache.set(uri, html);
|
||||
return html;
|
||||
}
|
||||
|
||||
|
||||
export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise<boolean> {
|
||||
// Prevent reload
|
||||
if (iframe.src) return Promise.resolve(false);
|
||||
|
||||
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
|
||||
|
||||
const readyNotification: McpUiSandboxProxyReadyNotification["method"] =
|
||||
"ui/notifications/sandbox-proxy-ready";
|
||||
|
||||
const readyPromise = new Promise<boolean>((resolve) => {
|
||||
const listener = ({ source, data }: MessageEvent) => {
|
||||
if (source === iframe.contentWindow && data?.method === readyNotification) {
|
||||
log.info("Sandbox proxy loaded")
|
||||
window.removeEventListener("message", listener);
|
||||
resolve(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", listener);
|
||||
});
|
||||
|
||||
log.info("Loading sandbox proxy...");
|
||||
iframe.src = SANDBOX_PROXY_URL.href;
|
||||
|
||||
return readyPromise;
|
||||
}
|
||||
|
||||
|
||||
export async function initializeApp(
|
||||
iframe: HTMLIFrameElement,
|
||||
appBridge: AppBridge,
|
||||
{ input, resultPromise, appHtmlPromise }: Required<ToolCallInfo>,
|
||||
): Promise<void> {
|
||||
const appInitializedPromise = hookInitializedCallback(appBridge);
|
||||
|
||||
// Connect app bridge (triggers MCP initialization handshake)
|
||||
//
|
||||
// IMPORTANT: Pass `iframe.contentWindow` as BOTH target and source to ensure
|
||||
// this proxy only responds to messages from its specific iframe.
|
||||
await appBridge.connect(
|
||||
new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!),
|
||||
);
|
||||
|
||||
// Load inner iframe HTML
|
||||
log.info("Sending UI resource HTML to MCP App");
|
||||
await appBridge.sendSandboxResourceReady({ html: await appHtmlPromise });
|
||||
|
||||
// Wait for inner iframe to be ready
|
||||
log.info("Waiting for MCP App to initialize...");
|
||||
await appInitializedPromise;
|
||||
log.info("MCP App initialized");
|
||||
|
||||
// Send tool call input to iframe
|
||||
log.info("Sending tool call input to MCP App:", input);
|
||||
appBridge.sendToolInput({ arguments: input });
|
||||
|
||||
// Schedule tool call result to be sent to MCP App
|
||||
resultPromise.then((result) => {
|
||||
log.info("Sending tool call result to MCP App:", result);
|
||||
appBridge.sendToolResult(result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hooks into `AppBridge.oninitialized` and returns a Promise that resolves when
|
||||
* the MCP App is initialized (i.e., when the inner iframe is ready).
|
||||
*/
|
||||
function hookInitializedCallback(appBridge: AppBridge): Promise<void> {
|
||||
const oninitialized = appBridge.oninitialized;
|
||||
return new Promise<void>((resolve) => {
|
||||
appBridge.oninitialized = (...args) => {
|
||||
resolve();
|
||||
appBridge.oninitialized = oninitialized;
|
||||
appBridge.oninitialized?.(...args);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function newAppBridge(serverInfo: ServerInfo, iframe: HTMLIFrameElement): AppBridge {
|
||||
const serverCapabilities = serverInfo.client.getServerCapabilities();
|
||||
const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, {
|
||||
openLinks: {},
|
||||
serverTools: serverCapabilities?.tools,
|
||||
serverResources: serverCapabilities?.resources,
|
||||
});
|
||||
|
||||
// Register all handlers before calling connect(). The Guest UI can start
|
||||
// sending requests immediately after the initialization handshake, so any
|
||||
// handlers registered after connect() might miss early requests.
|
||||
|
||||
appBridge.onmessage = async (params, _extra) => {
|
||||
log.info("Message from MCP App:", params);
|
||||
return {};
|
||||
};
|
||||
|
||||
appBridge.onopenlink = async (params, _extra) => {
|
||||
log.info("Open link request:", params);
|
||||
window.open(params.url, "_blank", "noopener,noreferrer");
|
||||
return {};
|
||||
};
|
||||
|
||||
appBridge.onloggingmessage = (params) => {
|
||||
log.info("Log message from MCP App:", params);
|
||||
};
|
||||
|
||||
appBridge.onsizechange = async ({ width, height }) => {
|
||||
// The MCP App has requested a `width` and `height`, but if
|
||||
// `box-sizing: border-box` is applied to the outer iframe element, then we
|
||||
// must add border thickness to `width` and `height` to compute the actual
|
||||
// necessary width and height (in order to prevent a resize feedback loop).
|
||||
const style = getComputedStyle(iframe);
|
||||
const isBorderBox = style.boxSizing === "border-box";
|
||||
|
||||
// Animate the change for a smooth transition.
|
||||
const from: Keyframe = {};
|
||||
const to: Keyframe = {};
|
||||
|
||||
if (width !== undefined) {
|
||||
if (isBorderBox) {
|
||||
width += parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth);
|
||||
}
|
||||
from.width = `${iframe.offsetWidth}px`;
|
||||
iframe.style.width = to.width = `${width}px`;
|
||||
}
|
||||
if (height !== undefined) {
|
||||
if (isBorderBox) {
|
||||
height += parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
|
||||
}
|
||||
from.height = `${iframe.offsetHeight}px`;
|
||||
iframe.style.height = to.height = `${height}px`;
|
||||
}
|
||||
|
||||
iframe.animate([from, to], { duration: 300, easing: "ease-out" });
|
||||
};
|
||||
|
||||
return appBridge;
|
||||
}
|
||||
129
examples/basic-host/src/index.module.css
Normal file
129
examples/basic-host/src/index.module.css
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
.callToolPanel, .toolCallInfoPanel {
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
|
||||
* + & {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.callToolPanel {
|
||||
max-width: 480px;
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
select,
|
||||
textarea {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 6rem;
|
||||
resize: vertical;
|
||||
|
||||
&[aria-invalid="true"] {
|
||||
background-color: #fdd;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
align-self: center;
|
||||
min-width: 200px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: #1e3a5f;
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: #2d4a7c;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolCallInfoPanel {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
max-width: 1120px;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-12px); }
|
||||
}
|
||||
|
||||
.inputInfoPanel {
|
||||
display: flex;
|
||||
flex: 3;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
|
||||
.toolName {
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.outputInfoPanel {
|
||||
flex: 4;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jsonBlock {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
background-color: #f5f5f5;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.appIframePanel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
|
||||
iframe {
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 3px dashed #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 1.5rem;
|
||||
background-color: #ddd;
|
||||
color: #d00;
|
||||
}
|
||||
195
examples/basic-host/src/index.tsx
Normal file
195
examples/basic-host/src/index.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo } from "./implementation";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
|
||||
const MCP_SERVER_URL = new URL("http://localhost:3001/mcp");
|
||||
|
||||
|
||||
interface HostProps {
|
||||
serverInfoPromise: Promise<ServerInfo>;
|
||||
}
|
||||
function Host({ serverInfoPromise }: HostProps) {
|
||||
const serverInfo = use(serverInfoPromise);
|
||||
const [toolCallInfos, setToolCallInfos] = useState<ToolCallInfo[]>([]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{toolCallInfos.map((info, i) => (
|
||||
<ToolCallInfoPanel key={i} toolCallInfo={info} />
|
||||
))}
|
||||
<CallToolPanel
|
||||
serverInfo={serverInfo}
|
||||
addToolCallInfo={(info) => setToolCallInfos([...toolCallInfos, info])}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface CallToolPanelProps {
|
||||
serverInfo: ServerInfo;
|
||||
addToolCallInfo: (toolCallInfo: ToolCallInfo) => void;
|
||||
}
|
||||
function CallToolPanel({ serverInfo, addToolCallInfo }: CallToolPanelProps) {
|
||||
const toolNames = Array.from(serverInfo.tools.keys());
|
||||
const [selectedTool, setSelectedTool] = useState(toolNames[0] ?? "");
|
||||
const [inputJson, setInputJson] = useState("{}");
|
||||
|
||||
const isValidJson = useMemo(() => {
|
||||
try {
|
||||
JSON.parse(inputJson);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [inputJson]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const toolCallInfo = callTool(serverInfo, selectedTool, JSON.parse(inputJson));
|
||||
addToolCallInfo(toolCallInfo);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.callToolPanel}>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||
<label>
|
||||
Tool Name
|
||||
<select
|
||||
value={selectedTool}
|
||||
onChange={(e) => setSelectedTool(e.target.value)}
|
||||
>
|
||||
{toolNames.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Tool Input
|
||||
<textarea
|
||||
aria-invalid={!isValidJson}
|
||||
value={inputJson}
|
||||
onChange={(e) => setInputJson(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={!selectedTool || !isValidJson}>
|
||||
Call Tool
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface ToolCallInfoPanelProps {
|
||||
toolCallInfo: ToolCallInfo;
|
||||
}
|
||||
function ToolCallInfoPanel({ toolCallInfo }: ToolCallInfoPanelProps) {
|
||||
return (
|
||||
<div className={styles.toolCallInfoPanel}>
|
||||
<div className={styles.inputInfoPanel}>
|
||||
<h2 className={styles.toolName}>{toolCallInfo.tool.name}</h2>
|
||||
<JsonBlock value={toolCallInfo.input} />
|
||||
</div>
|
||||
<div className={styles.outputInfoPanel}>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback="Loading...">
|
||||
{
|
||||
hasAppHtml(toolCallInfo)
|
||||
? <AppIFramePanel toolCallInfo={toolCallInfo} />
|
||||
: <ToolResultPanel toolCallInfo={toolCallInfo} />
|
||||
}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function JsonBlock({ value }: { value: object }) {
|
||||
return (
|
||||
<pre className={styles.jsonBlock}>
|
||||
<code>{JSON.stringify(value, null, 2)}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface AppIFramePanelProps {
|
||||
toolCallInfo: Required<ToolCallInfo>;
|
||||
}
|
||||
function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current!;
|
||||
loadSandboxProxy(iframe).then((firstTime) => {
|
||||
// The `firstTime` check guards against React Strict Mode's double
|
||||
// invocation (mount → unmount → remount simulation in development).
|
||||
// Outside of Strict Mode, this `useEffect` runs only once per
|
||||
// `toolCallInfo`.
|
||||
if (firstTime) {
|
||||
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe);
|
||||
initializeApp(iframe, appBridge, toolCallInfo);
|
||||
}
|
||||
});
|
||||
}, [toolCallInfo]);
|
||||
|
||||
return (
|
||||
<div className={styles.appIframePanel}>
|
||||
<iframe ref={iframeRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface ToolResultPanelProps {
|
||||
toolCallInfo: ToolCallInfo;
|
||||
}
|
||||
function ToolResultPanel({ toolCallInfo }: ToolResultPanelProps) {
|
||||
const result = use(toolCallInfo.resultPromise);
|
||||
return <JsonBlock value={result} />;
|
||||
}
|
||||
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: unknown;
|
||||
}
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
state: ErrorBoundaryState = { hasError: false, error: undefined };
|
||||
|
||||
// Called during render phase - must be pure (no side effects)
|
||||
// Note: error is `unknown` because JS allows throwing any value
|
||||
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
// Called during commit phase - can have side effects (logging, etc.)
|
||||
componentDidCatch(error: unknown, errorInfo: ErrorInfo): void {
|
||||
log.error("Caught:", error, errorInfo.componentStack);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
const { error } = this.state;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return <div className={styles.error}><strong>ERROR:</strong> {message}</div>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<Suspense fallback={<p>Connecting to server ({MCP_SERVER_URL.href})...</p>}>
|
||||
<Host serverInfoPromise={connectToServer(MCP_SERVER_URL)} />
|
||||
</Suspense>
|
||||
</StrictMode>,
|
||||
);
|
||||
87
examples/basic-host/src/sandbox.ts
Normal file
87
examples/basic-host/src/sandbox.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import type { McpUiSandboxProxyReadyNotification, McpUiSandboxResourceReadyNotification } from "../../../dist/src/types";
|
||||
|
||||
const ALLOWED_REFERRER_PATTERN = /^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/;
|
||||
|
||||
if (window.self === window.top) {
|
||||
throw new Error("This file is only to be used in an iframe sandbox.");
|
||||
}
|
||||
|
||||
if (!document.referrer) {
|
||||
throw new Error("No referrer, cannot validate embedding site.");
|
||||
}
|
||||
|
||||
if (!document.referrer.match(ALLOWED_REFERRER_PATTERN)) {
|
||||
throw new Error(
|
||||
`Embedding domain not allowed in referrer ${document.referrer}. (Consider updating the validation logic to allow your domain.)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Security self-test: verify iframe isolation is working correctly.
|
||||
// This MUST throw a SecurityError -- if `window.top` is accessible, the sandbox
|
||||
// configuration is dangerously broken and untrusted content could escape.
|
||||
try {
|
||||
window.top!.alert("If you see this, the sandbox is not setup securely.");
|
||||
throw "FAIL";
|
||||
} catch (e) {
|
||||
if (e === "FAIL") {
|
||||
throw new Error("The sandbox is not setup securely.");
|
||||
}
|
||||
|
||||
// Expected: SecurityError confirms proper sandboxing.
|
||||
}
|
||||
|
||||
// Double-iframe sandbox architecture: THIS file is the outer sandbox proxy
|
||||
// iframe on a separate origin. It creates an inner iframe for untrusted HTML
|
||||
// content. Per the specification, the Host and the Sandbox MUST have different
|
||||
// origins.
|
||||
const inner = document.createElement("iframe");
|
||||
inner.style = "width:100%; height:100%; border:none;";
|
||||
inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
|
||||
document.body.appendChild(inner);
|
||||
|
||||
const RESOURCE_READY_NOTIFICATION: McpUiSandboxResourceReadyNotification["method"] =
|
||||
"ui/notifications/sandbox-resource-ready";
|
||||
const PROXY_READY_NOTIFICATION: McpUiSandboxProxyReadyNotification["method"] =
|
||||
"ui/notifications/sandbox-proxy-ready";
|
||||
|
||||
// Message relay: This Sandbox (outer iframe) acts as a bidirectional bridge,
|
||||
// forwarding messages between:
|
||||
//
|
||||
// Host (parent window) ↔ Sandbox (outer frame) ↔ Guest UI (inner iframe)
|
||||
//
|
||||
// Reason: the parent window and inner iframe have different origins and can't
|
||||
// communicate directly, so the outer iframe forwards messages in both
|
||||
// directions to connect them.
|
||||
//
|
||||
// Special case: The "ui/notifications/sandbox-proxy-ready" message is
|
||||
// intercepted here (not relayed) because the Sandbox uses it to configure and
|
||||
// load the inner iframe with the Guest UI HTML content.
|
||||
window.addEventListener("message", async (event) => {
|
||||
if (event.source === window.parent) {
|
||||
// NOTE: In production you'll also want to validate `event.origin` against
|
||||
// your Host domain.
|
||||
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
|
||||
const { html, sandbox } = event.data.params;
|
||||
if (typeof sandbox === "string") {
|
||||
inner.setAttribute("sandbox", sandbox);
|
||||
}
|
||||
if (typeof html === "string") {
|
||||
inner.srcdoc = html;
|
||||
}
|
||||
} else {
|
||||
if (inner && inner.contentWindow) {
|
||||
inner.contentWindow.postMessage(event.data, "*");
|
||||
}
|
||||
}
|
||||
} else if (event.source === inner.contentWindow) {
|
||||
// Relay messages from inner frame to parent window.
|
||||
window.parent.postMessage(event.data, "*");
|
||||
}
|
||||
});
|
||||
|
||||
// Notify the Host that the Sandbox is ready to receive Guest UI HTML.
|
||||
window.parent.postMessage({
|
||||
jsonrpc: "2.0",
|
||||
method: PROXY_READY_NOTIFICATION,
|
||||
params: {},
|
||||
}, "*");
|
||||
1
examples/basic-host/src/vite-env.d.ts
vendored
Normal file
1
examples/basic-host/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
19
examples/basic-host/tsconfig.json
Normal file
19
examples/basic-host/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "*.ts"]
|
||||
}
|
||||
24
examples/basic-host/vite.config.ts
Normal file
24
examples/basic-host/vite.config.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||
|
||||
const INPUT = process.env.INPUT;
|
||||
if (!INPUT) {
|
||||
throw new Error("INPUT environment variable is not set");
|
||||
}
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === "development";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), viteSingleFile()],
|
||||
build: {
|
||||
sourcemap: isDevelopment ? "inline" : undefined,
|
||||
cssMinify: !isDevelopment,
|
||||
minify: !isDevelopment,
|
||||
rollupOptions: {
|
||||
input: INPUT,
|
||||
},
|
||||
outDir: `dist`,
|
||||
emptyOutDir: false,
|
||||
},
|
||||
});
|
||||
27
examples/basic-server-react/README.md
Normal file
27
examples/basic-server-react/README.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Example: Basic Server (React)
|
||||
|
||||
An MCP App example with a React UI.
|
||||
|
||||
## Overview
|
||||
|
||||
- Tool registration with a linked UI resource
|
||||
- React UI using the [`useApp()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/_modelcontextprotocol_ext-apps_react.useApp.html) hook
|
||||
- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`sendOpenLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendopenlink)
|
||||
|
||||
## Key Files
|
||||
|
||||
- [`server.ts`](server.ts) - MCP server with tool and resource registration
|
||||
- [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.tsx`](src/mcp-app.tsx) - React UI using `useApp()` hook
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The server registers a `get-time` tool with metadata linking it to a UI HTML resource (`ui://get-time/mcp-app.html`).
|
||||
2. When the tool is invoked, the Host renders the UI from the resource.
|
||||
3. The UI uses the MCP App SDK API to communicate with the host and call server tools.
|
||||
13
examples/basic-server-react/mcp-app.html
Normal file
13
examples/basic-server-react/mcp-app.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Get Time App</title>
|
||||
<link rel="stylesheet" href="/src/global.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/mcp-app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
examples/basic-server-react/package.json
Normal file
34
examples/basic-server-react/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "basic-server-react",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "INPUT=mcp-app.html vite build",
|
||||
"watch": "INPUT=mcp-app.html vite build --watch",
|
||||
"serve": "bun server.ts",
|
||||
"start": "NODE_ENV=development npm run build && npm run serve",
|
||||
"dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/ext-apps": "../..",
|
||||
"@modelcontextprotocol/sdk": "^1.22.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"concurrently": "^9.2.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-singlefile": "^2.3.0"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue