diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 981dd3a..90056ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,18 +25,6 @@ jobs: - run: npm install - - run: npm run build - - - name: Build simple-host example - working-directory: examples/simple-host - run: | - npm install - npm run build - - - name: Build simple-server example - working-directory: examples/simple-server - run: | - npm install - npm run build + - run: npm run build:all - run: npm run prettier diff --git a/.husky/pre-commit b/.husky/pre-commit index f474323..cffd562 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,2 @@ -npm run build +npm run build:all npm run prettier:fix -( cd examples/simple-host && npm run build ) -( cd examples/simple-server && npm run build ) diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..0de301e --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +examples/basic-host/**/*.ts +examples/basic-host/**/*.tsx +examples/basic-server-react/**/*.ts +examples/basic-server-react/**/*.tsx diff --git a/README.md b/README.md index 0819471..8137cce 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Embed and communicate with MCP Apps in your chat application. - **SDK for Hosts**: `@modelcontextprotocol/ext-apps/app-bridge` — [API Docs](https://modelcontextprotocol.github.io/ext-apps/api/modules/app-bridge.html) -There's no _supported_ host implementation in this repo (beyond the [examples/simple-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-host) example). +There's no _supported_ host implementation in this repo (beyond the [examples/basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example). We have [contributed a tentative implementation](https://github.com/MCP-UI-Org/mcp-ui/pull/147) of hosting / iframing / sandboxing logic to the [MCP-UI](https://github.com/idosal/mcp-ui) repository, and expect OSS clients may use it, while other clients might roll their own hosting logic. @@ -54,14 +54,14 @@ Your `package.json` will then look like: ## Examples -- [examples/simple-server](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-server) — Example MCP server with tools that return UI Apps -- [examples/simple-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-host) — Bare-bones example of hosting MCP Apps +- [`examples/basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) — Example MCP server with tools that return UI Apps +- [`examples/basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) — Bare-bones example of hosting MCP Apps To run the examples end-to-end: ``` npm i -npm start +npm run examples:start ``` Then open http://localhost:8080/ diff --git a/examples/basic-host/README.md b/examples/basic-host/README.md new file mode 100644 index 0000000..24be409 --- /dev/null +++ b/examples/basic-host/README.md @@ -0,0 +1,37 @@ +# Example: Basic Host + +A reference implementation showing how to build an MCP Host application that connects to MCP servers and renders tool UIs in a secure sandbox. + +This basic host can also be used to test MCP Apps during local development. + +## Key Files + +- [`index.html`](index.html) / [`src/index.tsx`](src/index.tsx) - React UI host with tool selection, parameter input, and iframe management +- [`sandbox.html`](sandbox.html) / [`src/sandbox.ts`](src/sandbox.ts) - Outer iframe proxy with security validation and bidirectional message relay +- [`src/implementation.ts`](src/implementation.ts) - Core logic: server connection, tool calling, and AppBridge setup + +## Getting Started + +```bash +npm install +npm run dev +# Open http://localhost:8080 +``` + +## Architecture + +This example uses a double-iframe sandbox pattern for secure UI isolation: + +``` +Host (port 8080) + └── Outer iframe (port 8081) - sandbox proxy + └── Inner iframe (srcdoc) - untrusted tool UI +``` + +**Why two iframes?** + +- The outer iframe runs on a separate origin (port 8081) preventing direct access to the host +- The inner iframe receives HTML via `srcdoc` and is restricted by sandbox attributes +- Messages flow through the outer iframe which validates and relays them bidirectionally + +This architecture ensures that even if tool UI code is malicious, it cannot access the host application's DOM, cookies, or JavaScript context. diff --git a/examples/basic-host/index.html b/examples/basic-host/index.html new file mode 100644 index 0000000..b865ebe --- /dev/null +++ b/examples/basic-host/index.html @@ -0,0 +1,13 @@ + + + + + + MCP Apps Host + + + +
+ + + diff --git a/examples/basic-host/package.json b/examples/basic-host/package.json new file mode 100644 index 0000000..6a686b6 --- /dev/null +++ b/examples/basic-host/package.json @@ -0,0 +1,36 @@ +{ + "homepage": "https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host", + "name": "@modelcontextprotocol/ext-apps-basic-host", + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "concurrently 'INPUT=index.html vite build' 'INPUT=sandbox.html vite build'", + "watch": "concurrently 'INPUT=index.html vite build --watch' 'INPUT=sandbox.html vite build --watch'", + "serve": "bun serve.ts", + "start": "NODE_ENV=development npm run build && npm run serve", + "dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve'" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "bun": "^1.3.2", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "express": "^5.1.0", + "prettier": "^3.6.2", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } +} diff --git a/examples/basic-host/sandbox.html b/examples/basic-host/sandbox.html new file mode 100644 index 0000000..f09d494 --- /dev/null +++ b/examples/basic-host/sandbox.html @@ -0,0 +1,48 @@ + + + + + + + MCP-UI Proxy + + + + + + diff --git a/examples/basic-host/serve.ts b/examples/basic-host/serve.ts new file mode 100644 index 0000000..7e6af90 --- /dev/null +++ b/examples/basic-host/serve.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env npx tsx +/** + * HTTP servers for the MCP UI example: + * - Host server (port 8080): serves host HTML files (React and Vanilla examples) + * - Sandbox server (port 8081): serves sandbox.html with permissive CSP + * + * Running on separate ports ensures proper origin isolation for security. + */ + +import express from "express"; +import cors from "cors"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const HOST_PORT = parseInt(process.env.HOST_PORT || "8080", 10); +const SANDBOX_PORT = parseInt(process.env.SANDBOX_PORT || "8081", 10); +const DIRECTORY = join(__dirname, "dist"); + +// ============ Host Server (port 8080) ============ +const hostApp = express(); +hostApp.use(cors()); + +// Exclude sandbox.html from host server +hostApp.use((req, res, next) => { + if (req.path === "/sandbox.html") { + res.status(404).send("Sandbox is served on a different port"); + return; + } + next(); +}); + +hostApp.use(express.static(DIRECTORY)); + +hostApp.get("/", (_req, res) => { + res.redirect("/index.html"); +}); + +// ============ Sandbox Server (port 8081) ============ +const sandboxApp = express(); +sandboxApp.use(cors()); + +// Permissive CSP for sandbox content +sandboxApp.use((_req, res, next) => { + const csp = [ + "default-src 'self'", + "img-src * data: blob: 'unsafe-inline'", + "style-src * blob: data: 'unsafe-inline'", + "script-src * blob: data: 'unsafe-inline' 'unsafe-eval'", + "connect-src *", + "font-src * blob: data:", + "media-src * blob: data:", + "frame-src * blob: data:", + ].join("; "); + res.setHeader("Content-Security-Policy", csp); + res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + next(); +}); + +sandboxApp.get(["/", "/sandbox.html"], (_req, res) => { + res.sendFile(join(DIRECTORY, "sandbox.html")); +}); + +sandboxApp.use((_req, res) => { + res.status(404).send("Only sandbox.html is served on this port"); +}); + +// ============ Start both servers ============ +hostApp.listen(HOST_PORT, () => { + console.log(`Host server: http://localhost:${HOST_PORT}`); +}); + +sandboxApp.listen(SANDBOX_PORT, () => { + console.log(`Sandbox server: http://localhost:${SANDBOX_PORT}`); + console.log("\nPress Ctrl+C to stop\n"); +}); diff --git a/examples/basic-host/src/global.css b/examples/basic-host/src/global.css new file mode 100644 index 0000000..97cda44 --- /dev/null +++ b/examples/basic-host/src/global.css @@ -0,0 +1,12 @@ +* { + box-sizing: border-box; +} + +html, body { + font-family: system-ui, -apple-system, sans-serif; + font-size: 1rem; +} + +code { + font-size: 1em; +} diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts new file mode 100644 index 0000000..8761d79 --- /dev/null +++ b/examples/basic-host/src/implementation.ts @@ -0,0 +1,257 @@ +import { RESOURCE_URI_META_KEY, type McpUiSandboxProxyReadyNotification } from "@modelcontextprotocol/ext-apps"; +import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; + + +const SANDBOX_PROXY_URL = new URL("http://localhost:8081/sandbox.html"); +const IMPLEMENTATION = { name: "MCP Apps Host", version: "1.0.0" }; + + +export const log = { + info: console.log.bind(console, "[HOST]"), + warn: console.warn.bind(console, "[HOST]"), + error: console.error.bind(console, "[HOST]"), +}; + + +export interface ServerInfo { + client: Client; + tools: Map; + appHtmlCache: Map; +} + + +export async function connectToServer(serverUrl: URL): Promise { + const client = new Client(IMPLEMENTATION); + + log.info("Connecting to server:", serverUrl.href); + await client.connect(new StreamableHTTPClientTransport(serverUrl)); + log.info("Connection successful"); + + const toolsList = await client.listTools(); + const tools = new Map(toolsList.tools.map((tool) => [tool.name, tool])); + log.info("Server tools:", Array.from(tools.keys())); + + return { client, tools, appHtmlCache: new Map() }; +} + + +export interface ToolCallInfo { + serverInfo: ServerInfo; + tool: Tool; + input: Record; + resultPromise: Promise; + appHtmlPromise?: Promise; +} + + +export function hasAppHtml(toolCallInfo: ToolCallInfo): toolCallInfo is Required { + return !!toolCallInfo.appHtmlPromise; +} + + +export function callTool( + serverInfo: ServerInfo, + name: string, + input: Record, +): ToolCallInfo { + log.info("Calling tool", name, "with input", input); + const resultPromise = serverInfo.client.callTool({ name, arguments: input }) as Promise; + + const tool = serverInfo.tools.get(name); + if (!tool) { + throw new Error(`Unknown tool: ${name}`); + } + + const toolCallInfo: ToolCallInfo = { serverInfo, tool, input, resultPromise }; + + const uiResourceUri = getUiResourceUri(tool); + if (uiResourceUri) { + toolCallInfo.appHtmlPromise = getUiResourceHtml(serverInfo, uiResourceUri); + } + + return toolCallInfo; +} + + +function getUiResourceUri(tool: Tool): string | undefined { + const uri = tool._meta?.[RESOURCE_URI_META_KEY]; + if (typeof uri === "string" && uri.startsWith("ui://")) { + return uri; + } else if (uri !== undefined) { + throw new Error(`Invalid UI resource URI: ${JSON.stringify(uri)}`); + } +} + + +async function getUiResourceHtml(serverInfo: ServerInfo, uri: string): Promise { + let html = serverInfo.appHtmlCache.get(uri); + if (html) { + log.info("Read UI resource from cache:", uri); + return html; + } + + log.info("Reading UI resource:", uri); + const resource = await serverInfo.client.readResource({ uri }); + + if (!resource) { + throw new Error(`Resource not found: ${uri}`); + } + + if (resource.contents.length !== 1) { + throw new Error(`Unexpected contents count: ${resource.contents.length}`); + } + + const content = resource.contents[0]; + + // Per the MCP App specification, "text/html+mcp" signals this resource is + // indeed for an MCP App UI. + if (content.mimeType !== "text/html+mcp") { + throw new Error(`Unsupported MIME type: ${content.mimeType}`); + } + + html = "blob" in content ? atob(content.blob) : content.text; + serverInfo.appHtmlCache.set(uri, html); + return html; +} + + +export function loadSandboxProxy(iframe: HTMLIFrameElement): Promise { + // Prevent reload + if (iframe.src) return Promise.resolve(false); + + iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); + + const readyNotification: McpUiSandboxProxyReadyNotification["method"] = + "ui/notifications/sandbox-proxy-ready"; + + const readyPromise = new Promise((resolve) => { + const listener = ({ source, data }: MessageEvent) => { + if (source === iframe.contentWindow && data?.method === readyNotification) { + log.info("Sandbox proxy loaded") + window.removeEventListener("message", listener); + resolve(true); + } + }; + window.addEventListener("message", listener); + }); + + log.info("Loading sandbox proxy..."); + iframe.src = SANDBOX_PROXY_URL.href; + + return readyPromise; +} + + +export async function initializeApp( + iframe: HTMLIFrameElement, + appBridge: AppBridge, + { input, resultPromise, appHtmlPromise }: Required, +): Promise { + const appInitializedPromise = hookInitializedCallback(appBridge); + + // Connect app bridge (triggers MCP initialization handshake) + // + // IMPORTANT: Pass `iframe.contentWindow` as BOTH target and source to ensure + // this proxy only responds to messages from its specific iframe. + await appBridge.connect( + new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!), + ); + + // Load inner iframe HTML + log.info("Sending UI resource HTML to MCP App"); + await appBridge.sendSandboxResourceReady({ html: await appHtmlPromise }); + + // Wait for inner iframe to be ready + log.info("Waiting for MCP App to initialize..."); + await appInitializedPromise; + log.info("MCP App initialized"); + + // Send tool call input to iframe + log.info("Sending tool call input to MCP App:", input); + appBridge.sendToolInput({ arguments: input }); + + // Schedule tool call result to be sent to MCP App + resultPromise.then((result) => { + log.info("Sending tool call result to MCP App:", result); + appBridge.sendToolResult(result); + }); +} + +/** + * Hooks into `AppBridge.oninitialized` and returns a Promise that resolves when + * the MCP App is initialized (i.e., when the inner iframe is ready). + */ +function hookInitializedCallback(appBridge: AppBridge): Promise { + const oninitialized = appBridge.oninitialized; + return new Promise((resolve) => { + appBridge.oninitialized = (...args) => { + resolve(); + appBridge.oninitialized = oninitialized; + appBridge.oninitialized?.(...args); + }; + }); +} + + +export function newAppBridge(serverInfo: ServerInfo, iframe: HTMLIFrameElement): AppBridge { + const serverCapabilities = serverInfo.client.getServerCapabilities(); + const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, { + openLinks: {}, + serverTools: serverCapabilities?.tools, + serverResources: serverCapabilities?.resources, + }); + + // Register all handlers before calling connect(). The Guest UI can start + // sending requests immediately after the initialization handshake, so any + // handlers registered after connect() might miss early requests. + + appBridge.onmessage = async (params, _extra) => { + log.info("Message from MCP App:", params); + return {}; + }; + + appBridge.onopenlink = async (params, _extra) => { + log.info("Open link request:", params); + window.open(params.url, "_blank", "noopener,noreferrer"); + return {}; + }; + + appBridge.onloggingmessage = (params) => { + log.info("Log message from MCP App:", params); + }; + + appBridge.onsizechange = async ({ width, height }) => { + // The MCP App has requested a `width` and `height`, but if + // `box-sizing: border-box` is applied to the outer iframe element, then we + // must add border thickness to `width` and `height` to compute the actual + // necessary width and height (in order to prevent a resize feedback loop). + const style = getComputedStyle(iframe); + const isBorderBox = style.boxSizing === "border-box"; + + // Animate the change for a smooth transition. + const from: Keyframe = {}; + const to: Keyframe = {}; + + if (width !== undefined) { + if (isBorderBox) { + width += parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth); + } + from.width = `${iframe.offsetWidth}px`; + iframe.style.width = to.width = `${width}px`; + } + if (height !== undefined) { + if (isBorderBox) { + height += parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth); + } + from.height = `${iframe.offsetHeight}px`; + iframe.style.height = to.height = `${height}px`; + } + + iframe.animate([from, to], { duration: 300, easing: "ease-out" }); + }; + + return appBridge; +} diff --git a/examples/basic-host/src/index.module.css b/examples/basic-host/src/index.module.css new file mode 100644 index 0000000..684b2c7 --- /dev/null +++ b/examples/basic-host/src/index.module.css @@ -0,0 +1,129 @@ +.callToolPanel, .toolCallInfoPanel { + margin: 0 auto; + padding: 1rem; + border: 1px solid #ddd; + border-radius: 4px; + + * + & { + margin-top: 1rem; + } +} + +.callToolPanel { + max-width: 480px; + + form { + display: flex; + flex-direction: column; + gap: 1rem; + } + + label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-weight: 600; + } + + select, + textarea { + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 4px; + font-family: monospace; + font-size: inherit; + } + + textarea { + min-height: 6rem; + resize: vertical; + + &[aria-invalid="true"] { + background-color: #fdd; + } + } + + button { + align-self: center; + min-width: 200px; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + background-color: #1e3a5f; + font-size: inherit; + font-weight: 600; + color: white; + cursor: pointer; + + &:hover:not(:disabled) { + background-color: #2d4a7c; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + +.toolCallInfoPanel { + display: flex; + gap: 1rem; + max-width: 1120px; + animation: slideDown 0.3s ease-out; +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-12px); } +} + +.inputInfoPanel { + display: flex; + flex: 3; + flex-direction: column; + gap: 0.5rem; + min-width: 0; + + .toolName { + margin: 0; + font-family: monospace; + font-size: 1.5rem; + } +} + +.outputInfoPanel { + flex: 4; + min-width: 0; +} + +.jsonBlock { + flex-grow: 1; + min-height: 0; + margin: 0; + padding: 1rem; + border-radius: 4px; + background-color: #f5f5f5; + overflow: auto; +} + +.appIframePanel { + display: flex; + justify-content: center; + width: 100%; + min-height: 200px; + + iframe { + flex-shrink: 0; + height: 100%; + max-width: 100%; + box-sizing: border-box; + border: 3px dashed #888; + border-radius: 4px; + } +} + +.error { + padding: 1.5rem; + background-color: #ddd; + color: #d00; +} diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx new file mode 100644 index 0000000..26df1ff --- /dev/null +++ b/examples/basic-host/src/index.tsx @@ -0,0 +1,195 @@ +import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo } from "./implementation"; +import styles from "./index.module.css"; + + +const MCP_SERVER_URL = new URL("http://localhost:3001/mcp"); + + +interface HostProps { + serverInfoPromise: Promise; +} +function Host({ serverInfoPromise }: HostProps) { + const serverInfo = use(serverInfoPromise); + const [toolCallInfos, setToolCallInfos] = useState([]); + + return ( + <> + {toolCallInfos.map((info, i) => ( + + ))} + setToolCallInfos([...toolCallInfos, info])} + /> + + ); +} + + +interface CallToolPanelProps { + serverInfo: ServerInfo; + addToolCallInfo: (toolCallInfo: ToolCallInfo) => void; +} +function CallToolPanel({ serverInfo, addToolCallInfo }: CallToolPanelProps) { + const toolNames = Array.from(serverInfo.tools.keys()); + const [selectedTool, setSelectedTool] = useState(toolNames[0] ?? ""); + const [inputJson, setInputJson] = useState("{}"); + + const isValidJson = useMemo(() => { + try { + JSON.parse(inputJson); + return true; + } catch { + return false; + } + }, [inputJson]); + + const handleSubmit = () => { + const toolCallInfo = callTool(serverInfo, selectedTool, JSON.parse(inputJson)); + addToolCallInfo(toolCallInfo); + }; + + return ( +
+
{ e.preventDefault(); handleSubmit(); }}> + +