diff --git a/examples/simple-host/package.json b/examples/simple-host/package.json index 03686fef..741e6484 100644 --- a/examples/simple-host/package.json +++ b/examples/simple-host/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "start": "NODE_ENV=development npm run build && concurrently 'npm run start:server'", - "start:server": "bun server.ts", + "start:server": "bun serve.ts", "build": "concurrently 'INPUT=example-host-vanilla.html vite build' 'INPUT=example-host-react.html vite build' 'INPUT=sandbox.html vite build'" }, "dependencies": { diff --git a/examples/simple-host/serve.ts b/examples/simple-host/serve.ts new file mode 100644 index 00000000..d02809f5 --- /dev/null +++ b/examples/simple-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("/example-host-react.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/simple-host/server.ts b/examples/simple-host/server.ts deleted file mode 100644 index b6019824..00000000 --- a/examples/simple-host/server.ts +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * Simple HTTP server to serve the host and sandbox html files with appropriate - * Content Security Policy (CSP) headers. - */ - -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 PORT = parseInt(process.env.PORT || "8080", 10); -const DIRECTORY = join(__dirname, "dist"); - -const app = express(); - -// CORS middleware for all routes -app.use(cors()); - -// Custom middleware for sandbox.html and root -app.use((req, res, next) => { - if (req.path === "/sandbox.html" || req.path === "/") { - // Permissive CSP to allow external resources (images, styles, scripts) - 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); - - // Disable caching to ensure fresh content on every request - res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); - res.setHeader("Pragma", "no-cache"); - res.setHeader("Expires", "0"); - } - next(); -}); - -// Serve static files from dist directory -app.use(express.static(DIRECTORY)); - -// Redirect root to example-host.html -app.get("/", (_req, res) => { - res.redirect("/example-host-react.html"); -}); - -app.listen(PORT, () => { - console.log(`Server running on: http://localhost:${PORT}`); - console.log("Press Ctrl+C to stop the server\n"); -}); diff --git a/examples/simple-host/src/example-host-react.tsx b/examples/simple-host/src/example-host-react.tsx index 8bc7318a..7e8be2cd 100644 --- a/examples/simple-host/src/example-host-react.tsx +++ b/examples/simple-host/src/example-host-react.tsx @@ -8,7 +8,7 @@ import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { AppRenderer, AppRendererProps } from "../src/AppRenderer"; import { AppBridge } from "../../../dist/src/app-bridge"; -const SANDBOX_PROXY_URL = URL.parse("/sandbox.html", location.href)!; +const SANDBOX_PROXY_URL = new URL("http://localhost:8081/sandbox.html"); /** * Example React application demonstrating the AppRenderer component. diff --git a/examples/simple-host/src/example-host-vanilla.ts b/examples/simple-host/src/example-host-vanilla.ts index 9a304d2e..dbd921c9 100644 --- a/examples/simple-host/src/example-host-vanilla.ts +++ b/examples/simple-host/src/example-host-vanilla.ts @@ -18,7 +18,7 @@ import { McpUiSizeChangeNotificationSchema, } from "@modelcontextprotocol/ext-apps"; -const SANDBOX_PROXY_URL = URL.parse("/sandbox.html", location.href)!; +const SANDBOX_PROXY_URL = new URL("http://localhost:8081/sandbox.html"); window.addEventListener("load", async () => { const client = new Client({ diff --git a/examples/simple-host/src/sandbox.ts b/examples/simple-host/src/sandbox.ts index 81947d0a..5b507047 100644 --- a/examples/simple-host/src/sandbox.ts +++ b/examples/simple-host/src/sandbox.ts @@ -1,16 +1,43 @@ -// Double-iframe raw HTML mode (HTML sent via postMessage) +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;"; -// sandbox will be set from postMessage payload; default minimal before html arrives inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); document.body.appendChild(inner); -// Wait for HTML content from parent 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" + event.data.method === + ("ui/notifications/sandbox-resource-ready" as McpUiSandboxResourceReadyNotification["method"]) ) { const { html, sandbox } = event.data.params; if (typeof sandbox === "string") { @@ -34,7 +61,8 @@ window.addEventListener("message", async (event) => { window.parent.postMessage( { jsonrpc: "2.0", - method: "ui/notifications/sandbox-proxy-ready", + method: + "ui/notifications/sandbox-proxy-ready" as McpUiSandboxProxyReadyNotification["method"], params: {}, }, "*",