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