Merge pull request #15 from modelcontextprotocol/ochafik/simple-host-example
Add simple host example
This commit is contained in:
commit
123b74090c
18 changed files with 1248 additions and 24 deletions
49
README.md
49
README.md
|
|
@ -16,6 +16,10 @@ This repo provides:
|
||||||
- [ui-vanilla.tsx](./examples/simple-server/src/ui-vanilla.ts): vanilla App returned by the `create-ui-vanilla`
|
- [ui-vanilla.tsx](./examples/simple-server/src/ui-vanilla.ts): vanilla App returned by the `create-ui-vanilla`
|
||||||
- [ui-raw.tsx](./examples/simple-server/src/ui-raw.ts): same as vanilla App but doesn't use the SDK runtime (just its types)
|
- [ui-raw.tsx](./examples/simple-server/src/ui-raw.ts): same as vanilla App but doesn't use the SDK runtime (just its types)
|
||||||
|
|
||||||
|
- [examples/simple-host](./examples/simple-host): bare-bone examples on how to host MCP Apps (both use the [AppBridge](./src/app-bridge.ts) class to talk to a hosted App)
|
||||||
|
- [example-host-react.tsx](./examples/simple-host/src/example-host-react.tsx) uses React (esp. [AppRenderer.tsx](./examples/simple-host/src/AppRenderer.tsx))
|
||||||
|
- [example-host-vanilla.tsx](./examples/simple-host/src/example-host-vanilla.tsx) doesn't use React
|
||||||
|
|
||||||
- [message-transport](./src/message-transport.ts): `PostMessageTransport` class that uses `postMessage` to exchange JSON-RPC messages between windows / iframes
|
- [message-transport](./src/message-transport.ts): `PostMessageTransport` class that uses `postMessage` to exchange JSON-RPC messages between windows / iframes
|
||||||
|
|
||||||
- [app.ts](./src/app.ts): `App` class used by an App to talk to its host
|
- [app.ts](./src/app.ts): `App` class used by an App to talk to its host
|
||||||
|
|
@ -26,22 +30,47 @@ This repo provides:
|
||||||
|
|
||||||
What this repo does NOT provide:
|
What this repo does NOT provide:
|
||||||
|
|
||||||
- There's no host implementation here (beyond the `AppBridge` just used for communications).
|
- There's no _supported_ host implementation in this repo (beyond the [examples/simple-host](./examples/simple-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.
|
||||||
- A prior iteration of an e2e prototype w/ client, server and hosting parts is available [in this gist](https://gist.github.com/ochafik/a9603ba2d6757d6038ce066eded4c354)
|
|
||||||
|
|
||||||
## Installation
|
## Using the SDK
|
||||||
|
|
||||||
This repo is in flux and isn't published to npm (when it is, it will use the `@modelcontextprotocol/ext-apps` package). Please install it from git for now:
|
### Run examples
|
||||||
|
|
||||||
```bash
|
Run the examples in this repo end-to-end:
|
||||||
npm install git+https://github.com/modelcontextprotocol/ext-apps.git
|
|
||||||
|
```
|
||||||
|
npm i
|
||||||
|
npm start
|
||||||
|
open http://localhost:8080/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development Notes
|
> [!NOTE]
|
||||||
|
> Please bear with us while we add more examples!
|
||||||
|
|
||||||
### Build tools in dependencies
|
### Using the SDK in your project
|
||||||
|
|
||||||
The build tools (`esbuild`, `tsx`, `typescript`) are in `dependencies` rather than `devDependencies`. This is intentional: it allows the `prepare` script to run when the package is installed from git, since npm doesn't install devDependencies for git dependencies.
|
This repo is in flux and isn't published to npm yet: when it is, it will use the `@modelcontextprotocol/ext-apps` package.
|
||||||
|
|
||||||
Once the package is published to npm with pre-built `dist/`, these can be moved back to `devDependencies`.
|
In the meantime you can depend on the SDK library in a Node.js project by installing it w/ its git URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -S git+https://github.com/modelcontextprotocol/ext-apps.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Your `package.json` will then look like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
...
|
||||||
|
"dependencies": {
|
||||||
|
...
|
||||||
|
"@modelcontextprotocol/ext-apps": "git+https://github.com/modelcontextprotocol/ext-apps.git"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The build tools (`esbuild`, `tsx`, `typescript`) are in `dependencies` rather than `devDependencies`. This is intentional: it allows the `prepare` script to run when the package is installed from git, since npm doesn't install devDependencies for git dependencies.
|
||||||
|
>
|
||||||
|
> Once the package is published to npm with pre-built `dist/`, these can be moved back to `devDependencies`.
|
||||||
|
|
|
||||||
21
examples/simple-host/example-host-react.html
Normal file
21
examples/simple-host/example-host-react.html
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<title>Example MCP-UI React Host</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="/src/example-host-react.tsx" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
examples/simple-host/example-host-vanilla.html
Normal file
21
examples/simple-host/example-host-vanilla.html
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
#chat-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="/src/example-host-vanilla.ts" type="module"></script>
|
||||||
|
<title>Example MCP View Host</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Example MCP View Host</h1>
|
||||||
|
|
||||||
|
<div id="controls"></div>
|
||||||
|
<div id="chat-root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
examples/simple-host/package.json
Normal file
37
examples/simple-host/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"homepage": "https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-host",
|
||||||
|
"name": "@modelcontextprotocol/ext-apps-host",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start:server": "tsx server.ts",
|
||||||
|
"start:mcp-server": "cd ../simple-server && npm install && npm run start",
|
||||||
|
"build": "concurrently 'INPUT=example-host-vanilla.html vite build' 'INPUT=example-host-react.html vite build' 'INPUT=sandbox.html vite build'",
|
||||||
|
"server": "bun server.ts",
|
||||||
|
"start": "NODE_ENV=development npm run build && concurrently 'npm run start:server' 'npm run start:mcp-server'"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/ext-apps": "../..",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.22.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"zod": "^3.25.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/react-dom": "^19.2.2",
|
||||||
|
"@types/react": "^19.2.2",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"esbuild": "~0.19.10",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite-plugin-singlefile": "^2.3.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
examples/simple-host/sandbox.html
Normal file
48
examples/simple-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>
|
||||||
58
examples/simple-host/server.ts
Normal file
58
examples/simple-host/server.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
/**
|
||||||
|
* Simple HTTP server to serve the host and sandbox html files with appropriate
|
||||||
|
* Content Security Policy (CSP) headers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT || "8080", 10);
|
||||||
|
const DIRECTORY = join(__dirname, "dist");
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// CORS middleware for all routes
|
||||||
|
app.use(cors());
|
||||||
|
|
||||||
|
// Custom middleware for sandbox.html and root
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.path === "/sandbox.html" || req.path === "/") {
|
||||||
|
// Permissive CSP to allow external resources (images, styles, scripts)
|
||||||
|
const csp = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"img-src * data: blob: 'unsafe-inline'",
|
||||||
|
"style-src * blob: data: 'unsafe-inline'",
|
||||||
|
"script-src * blob: data: 'unsafe-inline' 'unsafe-eval'",
|
||||||
|
"connect-src *",
|
||||||
|
"font-src * blob: data:",
|
||||||
|
"media-src * blob: data:",
|
||||||
|
"frame-src * blob: data:",
|
||||||
|
].join("; ");
|
||||||
|
res.setHeader("Content-Security-Policy", csp);
|
||||||
|
|
||||||
|
// Disable caching to ensure fresh content on every request
|
||||||
|
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||||
|
res.setHeader("Pragma", "no-cache");
|
||||||
|
res.setHeader("Expires", "0");
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve static files from dist directory
|
||||||
|
app.use(express.static(DIRECTORY));
|
||||||
|
|
||||||
|
// Redirect root to example-host.html
|
||||||
|
app.get("/", (_req, res) => {
|
||||||
|
res.redirect("/example-host-react.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on: http://localhost:${PORT}`);
|
||||||
|
console.log("Press Ctrl+C to stop the server\n");
|
||||||
|
});
|
||||||
340
examples/simple-host/src/AppRenderer.tsx
Normal file
340
examples/simple-host/src/AppRenderer.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
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,
|
||||||
|
} 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<string, unknown>;
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
* <AppRenderer
|
||||||
|
* sandboxProxyUrl={new URL('http://localhost:8765/sandbox_proxy.html')}
|
||||||
|
* client={mcpClient}
|
||||||
|
* toolName="create-chart"
|
||||||
|
* toolInput={{ data: [1, 2, 3], type: 'bar' }}
|
||||||
|
* onUIAction={async (action) => {
|
||||||
|
* 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<AppBridge | null>(null);
|
||||||
|
const [iframeReady, setIframeReady] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement | null>(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: { uri: string };
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<div style={{ color: "red", padding: "1rem" }}>
|
||||||
|
Error: {error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
114
examples/simple-host/src/app-host-utils.ts
Normal file
114
examples/simple-host/src/app-host-utils.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { McpUiSandboxProxyReadyNotificationSchema } 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<void>;
|
||||||
|
}> {
|
||||||
|
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<void>((resolve, _reject) => {
|
||||||
|
const initialListener = async (event: MessageEvent) => {
|
||||||
|
if (event.source === iframe.contentWindow) {
|
||||||
|
if (
|
||||||
|
event.data &&
|
||||||
|
event.data.method ===
|
||||||
|
McpUiSandboxProxyReadyNotificationSchema.shape.method._def.value
|
||||||
|
) {
|
||||||
|
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<ToolUiResourceInfo | null> {
|
||||||
|
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<string> {
|
||||||
|
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+mcp";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
260
examples/simple-host/src/example-host-react.tsx
Normal file
260
examples/simple-host/src/example-host-react.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
const SANDBOX_PROXY_URL = URL.parse("/sandbox.html", location.href)!;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Client | null>(null);
|
||||||
|
const [tools, setTools] = useState<Tool[]>([]);
|
||||||
|
const [activeTools, setActiveTools] = useState<
|
||||||
|
Array<{ id: string; name: string; input: Record<string, unknown> }>
|
||||||
|
>([]);
|
||||||
|
const [error, setError] = useState<string | null>(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<string, unknown>,
|
||||||
|
) => {
|
||||||
|
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 (
|
||||||
|
<div style={{ padding: "20px" }}>
|
||||||
|
<h1>Example MCP-UI React Host</h1>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "red",
|
||||||
|
padding: "10px",
|
||||||
|
border: "1px solid red",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Error:</strong> {error}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => window.location.reload()}>Retry</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "20px" }}>
|
||||||
|
<h1>Example MCP-UI React Host</h1>
|
||||||
|
<p>Connecting to MCP server...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "20px", fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
<h1>Example MCP-UI React Host</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "20px",
|
||||||
|
padding: "15px",
|
||||||
|
backgroundColor: "#f5f5f5",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Available Tools</h2>
|
||||||
|
{tools.length === 0 ? (
|
||||||
|
<p>No tools available with UI resources.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<button
|
||||||
|
key={tool.name}
|
||||||
|
onClick={() => {
|
||||||
|
// Example input - in a real app, you'd have a form for this
|
||||||
|
const input = tool.name.startsWith("pizza-")
|
||||||
|
? { pizzaTopping: "Mushrooms" }
|
||||||
|
: { message: "Hello from React!" };
|
||||||
|
handleAddToolUI(tool.name, input);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
backgroundColor: "#007bff",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add {tool.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
||||||
|
{activeTools.map((tool) => (
|
||||||
|
<div
|
||||||
|
key={tool.id}
|
||||||
|
style={{
|
||||||
|
padding: "10px",
|
||||||
|
border: "2px solid #ddd",
|
||||||
|
borderRadius: "8px",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: 0 }}>{tool.name}</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveToolUI(tool.id)}
|
||||||
|
style={{
|
||||||
|
padding: "4px 12px",
|
||||||
|
backgroundColor: "#dc3545",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppRenderer
|
||||||
|
sandboxProxyUrl={SANDBOX_PROXY_URL}
|
||||||
|
client={client}
|
||||||
|
toolName={tool.name}
|
||||||
|
toolInput={tool.input}
|
||||||
|
onmessage={handleMessage}
|
||||||
|
onloggingmessage={handleLoggingMessage}
|
||||||
|
onopenlink={handleOpenLink}
|
||||||
|
onerror={(err) => handleError(tool.id, err)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{activeTools.length === 0 && (
|
||||||
|
<p style={{ color: "#666", textAlign: "center", padding: "40px" }}>
|
||||||
|
No tool UIs active. Click a button above to add one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount the React app
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if (root) {
|
||||||
|
createRoot(root).render(<ExampleApp />);
|
||||||
|
} else {
|
||||||
|
console.error("Root element not found");
|
||||||
|
}
|
||||||
159
examples/simple-host/src/example-host-vanilla.ts
Normal file
159
examples/simple-host/src/example-host-vanilla.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
const SANDBOX_PROXY_URL = URL.parse("/sandbox.html", location.href)!;
|
||||||
|
|
||||||
|
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<string, unknown>,
|
||||||
|
) {
|
||||||
|
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!" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
41
examples/simple-host/src/sandbox.ts
Normal file
41
examples/simple-host/src/sandbox.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// Double-iframe raw HTML mode (HTML sent via postMessage)
|
||||||
|
const inner = document.createElement("iframe");
|
||||||
|
inner.style = "width:100%; height:100%; border:none;";
|
||||||
|
// sandbox will be set from postMessage payload; default minimal before html arrives
|
||||||
|
inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
|
||||||
|
document.body.appendChild(inner);
|
||||||
|
|
||||||
|
// Wait for HTML content from parent
|
||||||
|
window.addEventListener("message", async (event) => {
|
||||||
|
if (event.source === window.parent) {
|
||||||
|
if (
|
||||||
|
event.data &&
|
||||||
|
event.data.method === "ui/notifications/sandbox-resource-ready"
|
||||||
|
) {
|
||||||
|
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",
|
||||||
|
params: {},
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
25
examples/simple-host/tsconfig.json
Normal file
25
examples/simple-host/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
24
examples/simple-host/vite.config.ts
Normal file
24
examples/simple-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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:uis": "rm -rf dist && concurrently 'UI=ui-raw.html vite build' 'UI=ui-vanilla.html vite build' 'UI=ui-react.html vite build'",
|
"build": "concurrently 'INPUT=ui-raw.html vite build' 'INPUT=ui-vanilla.html vite build' 'INPUT=ui-react.html vite build'",
|
||||||
"server": "bun server.ts",
|
"server": "bun server.ts",
|
||||||
"start": "npm run build:uis && npm run server"
|
"start": "npm run build && npm run server"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/ext-apps": "../..",
|
"@modelcontextprotocol/ext-apps": "../..",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,23 @@
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||||
import { resolve } from "path";
|
|
||||||
|
|
||||||
// Get the entry from environment variable, default to ui-react
|
const INPUT = process.env.INPUT;
|
||||||
const uiPath = process.env.UI;
|
if (!INPUT) {
|
||||||
|
throw new Error("INPUT environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDevelopment = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), viteSingleFile()],
|
plugins: [react(), viteSingleFile()],
|
||||||
build: {
|
build: {
|
||||||
|
sourcemap: isDevelopment ? "inline" : undefined,
|
||||||
|
cssMinify: !isDevelopment,
|
||||||
|
minify: !isDevelopment,
|
||||||
|
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: uiPath,
|
input: INPUT,
|
||||||
},
|
},
|
||||||
outDir: `dist`,
|
outDir: `dist`,
|
||||||
emptyOutDir: false,
|
emptyOutDir: false,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"homepage": "https://gist.github.com/a9603ba2d6757d6038ce066eded4c354",
|
"homepage": "https://gist.github.com/a9603ba2d6757d6038ce066eded4c354",
|
||||||
"name": "@modelcontextprotocol/ext-apps",
|
"name": "@modelcontextprotocol/ext-apps",
|
||||||
"version": "1.0.0",
|
"version": "0.0.1",
|
||||||
"description": "Hey",
|
"description": "Hey",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/src/app.js",
|
"main": "./dist/src/app.js",
|
||||||
|
|
@ -23,11 +23,7 @@
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start:example-server0": "bun examples/example-server.ts",
|
"start": "cd examples/simple-host && npm i && npm start",
|
||||||
"start:example-server": "cd ../openai-apps-sdk-examples && concurrently \"npm run serve\" \"cd pizzaz_server_node && PORT=3001 npm start\"",
|
|
||||||
"start:sandbox-proxy": "python3 sandbox_proxy.py",
|
|
||||||
"start": "npm run build && npm run start:example-server",
|
|
||||||
"start:dev": "NODE_ENV=development npm start",
|
|
||||||
"build": "bun build.bun.ts",
|
"build": "bun build.bun.ts",
|
||||||
"prepare": "npm run build",
|
"prepare": "npm run build",
|
||||||
"prettier:base-cmd": "prettier -u --ignore-path ./.gitignore --ignore-path ./.prettierignore",
|
"prettier:base-cmd": "prettier -u --ignore-path ./.gitignore --ignore-path ./.prettierignore",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import {
|
||||||
ListResourcesResultSchema,
|
ListResourcesResultSchema,
|
||||||
ListResourceTemplatesRequestSchema,
|
ListResourceTemplatesRequestSchema,
|
||||||
ListResourceTemplatesResultSchema,
|
ListResourceTemplatesResultSchema,
|
||||||
|
LoggingMessageNotification,
|
||||||
|
LoggingMessageNotificationSchema,
|
||||||
Notification,
|
Notification,
|
||||||
PingRequest,
|
PingRequest,
|
||||||
PingRequestSchema,
|
PingRequestSchema,
|
||||||
|
|
@ -40,6 +42,12 @@ import {
|
||||||
McpUiInitializeRequest,
|
McpUiInitializeRequest,
|
||||||
McpUiInitializeRequestSchema,
|
McpUiInitializeRequestSchema,
|
||||||
McpUiInitializeResult,
|
McpUiInitializeResult,
|
||||||
|
McpUiMessageRequest,
|
||||||
|
McpUiMessageRequestSchema,
|
||||||
|
McpUiMessageResult,
|
||||||
|
McpUiOpenLinkRequest,
|
||||||
|
McpUiOpenLinkRequestSchema,
|
||||||
|
McpUiOpenLinkResult,
|
||||||
McpUiResourceTeardownRequest,
|
McpUiResourceTeardownRequest,
|
||||||
McpUiResourceTeardownResultSchema,
|
McpUiResourceTeardownResultSchema,
|
||||||
McpUiSandboxProxyReadyNotification,
|
McpUiSandboxProxyReadyNotification,
|
||||||
|
|
@ -101,6 +109,42 @@ export class AppBridge extends Protocol<Request, Notification, Result> {
|
||||||
callback(n.params),
|
callback(n.params),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
set onmessage(
|
||||||
|
callback: (
|
||||||
|
params: McpUiMessageRequest["params"],
|
||||||
|
extra: RequestExtra,
|
||||||
|
) => Promise<McpUiMessageResult>,
|
||||||
|
) {
|
||||||
|
this.setRequestHandler(
|
||||||
|
McpUiMessageRequestSchema,
|
||||||
|
async (request, extra) => {
|
||||||
|
return callback(request.params, extra);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
set onopenlink(
|
||||||
|
callback: (
|
||||||
|
params: McpUiOpenLinkRequest["params"],
|
||||||
|
extra: RequestExtra,
|
||||||
|
) => Promise<McpUiOpenLinkResult>,
|
||||||
|
) {
|
||||||
|
this.setRequestHandler(
|
||||||
|
McpUiOpenLinkRequestSchema,
|
||||||
|
async (request, extra) => {
|
||||||
|
return callback(request.params, extra);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
set onloggingmessage(
|
||||||
|
callback: (params: LoggingMessageNotification["params"]) => void,
|
||||||
|
) {
|
||||||
|
this.setNotificationHandler(
|
||||||
|
LoggingMessageNotificationSchema,
|
||||||
|
async (notification) => {
|
||||||
|
callback(notification.params);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
assertCapabilityForMethod(method: Request["method"]): void {
|
assertCapabilityForMethod(method: Request["method"]): void {
|
||||||
// TODO
|
// TODO
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export const McpUiOpenLinkRequestSchema = RequestSchema.extend({
|
||||||
export type McpUiOpenLinkRequest = z.infer<typeof McpUiOpenLinkRequestSchema>;
|
export type McpUiOpenLinkRequest = z.infer<typeof McpUiOpenLinkRequestSchema>;
|
||||||
|
|
||||||
export const McpUiOpenLinkResultSchema = z.object({
|
export const McpUiOpenLinkResultSchema = z.object({
|
||||||
isError: z.boolean(),
|
isError: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
export type McpUiOpenLinkResult = z.infer<typeof McpUiOpenLinkResultSchema>;
|
export type McpUiOpenLinkResult = z.infer<typeof McpUiOpenLinkResultSchema>;
|
||||||
|
|
||||||
|
|
@ -36,7 +36,7 @@ export type McpUiMessageRequest = z.infer<typeof McpUiMessageRequestSchema>;
|
||||||
export const McpUiMessageResultSchema = z.object({
|
export const McpUiMessageResultSchema = z.object({
|
||||||
// Note: we don't return the result from follow up messages as they might leak info from the chat.
|
// Note: we don't return the result from follow up messages as they might leak info from the chat.
|
||||||
// We do tell the caller if it errored, though.
|
// We do tell the caller if it errored, though.
|
||||||
isError: z.boolean(),
|
isError: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
export type McpUiMessageResult = z.infer<typeof McpUiMessageResultSchema>;
|
export type McpUiMessageResult = z.infer<typeof McpUiMessageResultSchema>;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue