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 ( +
+
{ e.preventDefault(); handleSubmit(); }}> + +
+ +
+ + +
+ +
+ + +
+ + + + 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",