diff --git a/docs/src/guide/client/using-a-proxy.md b/docs/src/guide/client/using-a-proxy.md index f7fd135e..c077770e 100644 --- a/docs/src/guide/client/using-a-proxy.md +++ b/docs/src/guide/client/using-a-proxy.md @@ -36,9 +36,12 @@ You can find a complete example for a site with restrictive CSP that uses the ho ```mermaid sequenceDiagram participant Host as Host Page + participant Server as Proxy Server participant Proxy as Proxy iframe participant Inner as Inner iframe (UI widget) - Host->>Proxy: Load proxy (with "?url" or "?contentType=rawhtml") + Host->>Server: Request proxy (with "?csp=&contentType=rawhtml") + Server->>Server: Parse CSP from query param + Server-->>Proxy: Serve HTML with CSP HTTP headers alt External URL Proxy->>Inner: Create with src = decoded url else rawHtml @@ -79,6 +82,56 @@ A valid proxy script must: 3. **Sandbox the Iframe**: For external URLs, the nested iframe should be sandboxed with `allow-scripts allow-same-origin`. For raw HTML mode, the inner iframe does **not** use a sandbox attribute—this is intentional because `document.write()` requires same-origin access to the iframe's document. Security for raw HTML is enforced by the outer iframe's sandbox (controlled by the host) and the double-iframe isolation architecture. 4. **Forward `postMessage` Events**: To allow communication between the host application and the embedded external URL, the proxy needs to forward `message` events between `window.parent` and the iframe's `contentWindow`. For security, it's critical to use a specific `targetOrigin` instead of `*` in `postMessage` calls whenever possible. The `targetOrigin` for messages to the iframe should be the external URL's origin; Messages to the parent will default to `*`. 5. **Permissive Proxy CSP**: Serve the proxy page with a permissive CSP that does not block nested iframe content (e.g., allowing scripts, styles, images) since the host CSP is intentionally not applied on the proxy origin. +6. **(Recommended) CSP via HTTP Headers**: For enhanced security, the proxy server can read a `csp` query parameter and set Content-Security-Policy HTTP headers. See [CSP Query Parameter](#csp-query-parameter) below. + +### CSP Query Parameter + +When CSP metadata is provided, `mcp-ui` appends it to the proxy URL as a `?csp=` query parameter. This allows proxy servers to set CSP via HTTP headers, which is more secure than meta tags or postMessage-based CSP injection (which can be bypassed by malicious content). + +**Example URL:** +``` +https://my-proxy.com/?contentType=rawhtml&csp={"connectDomains":["https://api.example.com"],"resourceDomains":["https://cdn.example.com"]} +``` + +**Server-side implementation (Express example):** +```typescript +import type { McpUiResourceCsp } from '@modelcontextprotocol/ext-apps/app-bridge'; + +app.get('/proxy', (req, res) => { + let cspConfig: McpUiResourceCsp | undefined; + if (typeof req.query.csp === 'string') { + try { + cspConfig = JSON.parse(req.query.csp); + } catch (e) { /* ignore invalid JSON */ } + } + + const cspHeader = buildCspHeader(cspConfig); + res.setHeader('Content-Security-Policy', cspHeader); + res.sendFile('proxy.html'); +}); + +function buildCspHeader(csp?: McpUiResourceCsp): string { + const resourceDomains = csp?.resourceDomains?.join(' ') ?? ''; + const connectDomains = csp?.connectDomains?.join(' ') ?? ''; + const frameDomains = csp?.frameDomains?.join(' '); + + return [ + "default-src 'self' 'unsafe-inline'", + `script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resourceDomains}`.trim(), + `style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(), + `img-src 'self' data: blob: ${resourceDomains}`.trim(), + `font-src 'self' data: blob: ${resourceDomains}`.trim(), + `connect-src 'self' ${connectDomains}`.trim(), + `worker-src 'self' blob: ${resourceDomains}`.trim(), + frameDomains ? `frame-src ${frameDomains}` : "frame-src 'none'", + "object-src 'none'", + ].join('; '); +} +``` + +::: tip +The CSP is also sent via `postMessage` after the sandbox loads as a fallback for proxies that don't support the query parameter approach. However, HTTP header-based CSP is strongly recommended as it's tamper-proof. +::: ### Example Self-Hosted Proxy diff --git a/docs/src/guide/mcp-apps.md b/docs/src/guide/mcp-apps.md index 17700e9b..2b0814dd 100644 --- a/docs/src/guide/mcp-apps.md +++ b/docs/src/guide/mcp-apps.md @@ -84,7 +84,8 @@ const widgetUI = createUIResource({ ```typescript import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { createUIResource, RESOURCE_URI_META_KEY } from '@mcp-ui/server'; +import { createUIResource } from '@mcp-ui/server'; +import { registerAppTool, registerAppResource } from '@modelcontextprotocol/ext-apps/server'; import { z } from 'zod'; const server = new McpServer({ name: 'my-server', version: '1.0.0' }); @@ -96,7 +97,8 @@ const widgetUI = createUIResource({ }); // Register the resource so the host can fetch it -server.registerResource( +registerAppResource( + server, 'widget_ui', // Resource name widgetUI.resource.uri, // Resource URI {}, // Resource metadata @@ -106,7 +108,8 @@ server.registerResource( ); // Register the tool with _meta linking to the UI resource -server.registerTool( +registerAppTool( + server, 'my_widget', { description: 'An interactive widget', @@ -115,7 +118,9 @@ server.registerTool( }, // This tells MCP Apps hosts where to find the UI _meta: { - [RESOURCE_URI_META_KEY]: widgetUI.resource.uri + ui: { + resourceUri: widgetUI.resource.uri + } } }, async ({ query }) => { @@ -126,14 +131,15 @@ server.registerTool( ); ``` -The key requirement for MCP Apps hosts is that the tool's `_meta` contains the `ui/resourceUri` key pointing to the UI resource URI. This tells the host where to fetch the widget HTML. +The key requirement for MCP Apps hosts is that the tool's `_meta.ui.resourceUri` points to the UI resource URI. This tells the host where to fetch the widget HTML. ### 3. Add the MCP-UI Embedded Resource to Tool Responses -To support **MCP-UI hosts** (which expect embedded resources in tool responses), also return a `createUIResource` result **without** the MCP Apps adapter: +To support **MCP-UI hosts** (which expect embedded resources in tool responses), also return a `createUIResource` result: ```typescript -server.registerTool( +registerAppTool( + server, 'my_widget', { description: 'An interactive widget', @@ -142,7 +148,9 @@ server.registerTool( }, // For MCP Apps hosts - points to the registered resource _meta: { - [RESOURCE_URI_META_KEY]: widgetUI.resource.uri + ui: { + resourceUri: widgetUI.resource.uri + } } }, async ({ query }) => { @@ -326,7 +334,8 @@ import express from 'express'; import cors from 'cors'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { createUIResource, RESOURCE_URI_META_KEY } from '@mcp-ui/server'; +import { createUIResource } from '@mcp-ui/server'; +import { registerAppTool, registerAppResource } from '@modelcontextprotocol/ext-apps/server'; import { z } from 'zod'; const app = express(); @@ -383,7 +392,8 @@ const graphUI = createUIResource({ }); // Register the UI resource -server.registerResource( +registerAppResource( + server, 'graph_ui', graphUI.resource.uri, {}, @@ -393,7 +403,8 @@ server.registerResource( ); // Register the tool with _meta linking to the UI resource -server.registerTool( +registerAppTool( + server, 'show_graph', { description: 'Display an interactive graph', @@ -402,7 +413,9 @@ server.registerTool( }, // For MCP Apps hosts - points to the registered resource _meta: { - [RESOURCE_URI_META_KEY]: graphUI.resource.uri + ui: { + resourceUri: graphUI.resource.uri + } } }, async ({ title }) => { @@ -440,6 +453,141 @@ The adapter logs debug information to the browser console. Look for messages pre [MCP Apps Adapter] Intercepted MCP-UI message: prompt ``` +## Host-Side Rendering (Client SDK) + +The `@mcp-ui/client` package provides React components for rendering MCP Apps tool UIs in your host application. + +### AppRenderer Component + +`AppRenderer` is the high-level component that handles the complete lifecycle of rendering an MCP tool's UI: + +```tsx +import { AppRenderer, type AppRendererHandle } from '@mcp-ui/client'; + +function ToolUI({ client, toolName, toolInput, toolResult }) { + const appRef = useRef(null); + + return ( + window.open(url)} + onMessage={async (params) => { + console.log('Message from tool UI:', params); + return { isError: false }; + }} + onError={(error) => console.error('Tool UI error:', error)} + /> + ); +} +``` + +**Key Props:** +- `client` - Optional MCP client for automatic resource fetching and MCP request forwarding +- `toolName` - Name of the tool to render UI for +- `sandbox` - Sandbox configuration with the sandbox proxy URL +- `html` - Optional pre-fetched HTML (skips resource fetching) +- `toolResourceUri` - Optional pre-fetched resource URI +- `toolInput` / `toolResult` - Tool arguments and results to pass to the UI +- `hostContext` - Theme, locale, viewport info for the guest UI +- `onOpenLink` / `onMessage` / `onLoggingMessage` - Handlers for guest UI requests + +**Ref Methods:** +- `sendToolListChanged()` - Notify guest when tools change +- `sendResourceListChanged()` - Notify guest when resources change +- `sendPromptListChanged()` - Notify guest when prompts change +- `teardownResource()` - Clean up before unmounting + +### Using Without an MCP Client + +You can use `AppRenderer` without a full MCP client by providing custom handlers: + +```tsx + { + // Proxy to your MCP client in a different context + return myMcpProxy.readResource({ uri }); + }} + onCallTool={async (params) => { + return myMcpProxy.callTool(params); + }} +/> +``` + +Or provide pre-fetched HTML directly: + +```tsx + +``` + +### AppFrame Component + +`AppFrame` is the lower-level component for when you already have the HTML content and an `AppBridge` instance: + +```tsx +import { AppFrame, AppBridge } from '@mcp-ui/client'; + +function LowLevelToolUI({ html, client }) { + const bridge = useMemo(() => new AppBridge(client, hostInfo, capabilities), [client]); + + return ( + console.log('Size changed:', size)} + /> + ); +} +``` + +### Sandbox Proxy + +Both components require a sandbox proxy HTML file to be served. This provides security isolation for the guest UI. The sandbox proxy URL should point to a page that loads the MCP Apps sandbox proxy script. + +## Declaring UI Extension Support + +When creating your MCP client, declare UI extension support using the provided type and capabilities: + +```typescript +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { + type ClientCapabilitiesWithExtensions, + UI_EXTENSION_CAPABILITIES, +} from '@mcp-ui/client'; + +const capabilities: ClientCapabilitiesWithExtensions = { + // Standard capabilities + roots: { listChanged: true }, + // UI extension support (SEP-1724 pattern) + extensions: UI_EXTENSION_CAPABILITIES, +}; + +const client = new Client( + { name: 'my-app', version: '1.0.0' }, + { capabilities } +); +``` + +This tells MCP servers that your client can render UI resources with MIME type `text/html;profile=mcp-app`. + +> **Note:** This uses the `extensions` field pattern from [SEP-1724](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1724), which is not yet part of the official MCP protocol. + ## Related Resources - [MCP Apps SEP Specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) diff --git a/examples/external-url-demo/tsconfig.app.json b/examples/external-url-demo/tsconfig.app.json index 0a68247e..3828564b 100644 --- a/examples/external-url-demo/tsconfig.app.json +++ b/examples/external-url-demo/tsconfig.app.json @@ -21,5 +21,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["node_modules", "dist"] } diff --git a/examples/mcp-apps-demo/package.json b/examples/mcp-apps-demo/package.json index 34150682..58b7db32 100644 --- a/examples/mcp-apps-demo/package.json +++ b/examples/mcp-apps-demo/package.json @@ -9,7 +9,7 @@ "dependencies": { "@mcp-ui/server": "workspace:*", "@modelcontextprotocol/ext-apps": "^0.2.2", - "@modelcontextprotocol/sdk": "^1.22.0", + "@modelcontextprotocol/sdk": "^1.25.1", "cors": "^2.8.5", "express": "^4.18.2", "zod": "^3.22.4" diff --git a/examples/mcp-apps-demo/src/index.ts b/examples/mcp-apps-demo/src/index.ts index 1400d8d0..0a769505 100644 --- a/examples/mcp-apps-demo/src/index.ts +++ b/examples/mcp-apps-demo/src/index.ts @@ -3,7 +3,9 @@ import cors from 'cors'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; -import { createUIResource, RESOURCE_URI_META_KEY } from '@mcp-ui/server'; +import { createUIResource } from '@mcp-ui/server'; +import { registerAppTool, registerAppResource } from '@modelcontextprotocol/ext-apps/server'; + import { randomUUID } from 'crypto'; import { z } from 'zod'; @@ -365,7 +367,8 @@ app.post('/mcp', async (req, res) => { }); // Register the UI resource so the host can fetch it - server.registerResource( + registerAppResource( + server, 'weather_dashboard_ui', weatherDashboardUI.resource.uri, {}, @@ -375,7 +378,8 @@ app.post('/mcp', async (req, res) => { ); // Register the tool with _meta linking to the UI resource - server.registerTool( + registerAppTool( + server, 'weather_dashboard', { description: 'Interactive weather dashboard widget', @@ -384,7 +388,9 @@ app.post('/mcp', async (req, res) => { }, // This tells MCP Apps hosts where to find the UI _meta: { - [RESOURCE_URI_META_KEY]: weatherDashboardUI.resource.uri, + ui: { + resourceUri: weatherDashboardUI.resource.uri + } }, }, async ({ location }) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bbd108f..e0ebbc3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,10 +188,10 @@ importers: version: link:../../sdks/typescript/server '@modelcontextprotocol/ext-apps': specifier: ^0.2.2 - version: 0.2.2(@modelcontextprotocol/sdk@1.23.0(zod@3.25.67))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(zod@3.25.67) + version: 0.2.2(@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.67))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(zod@3.25.67) '@modelcontextprotocol/sdk': - specifier: ^1.22.0 - version: 1.23.0(zod@3.25.67) + specifier: ^1.25.1 + version: 1.25.1(hono@4.11.1)(zod@3.25.67) cors: specifier: ^2.8.5 version: 2.8.5 @@ -428,9 +428,12 @@ importers: sdks/typescript/client: dependencies: + '@modelcontextprotocol/ext-apps': + specifier: ^0.3.1 + version: 0.3.1(@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.67))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.25.67) '@modelcontextprotocol/sdk': - specifier: ^1.22.0 - version: 1.22.0 + specifier: ^1.24.0 + version: 1.25.1(hono@4.11.1)(zod@3.25.67) '@quilted/threads': specifier: ^3.1.3 version: 3.1.3(@preact/signals-core@1.10.0) @@ -443,6 +446,9 @@ importers: '@remote-dom/react': specifier: ^1.2.2 version: 1.2.2(@preact/signals-core@1.10.0)(react@18.3.1) + zod: + specifier: ^3.23.8 + version: 3.25.67 devDependencies: '@testing-library/jest-dom': specifier: ^6.0.0 @@ -493,8 +499,8 @@ importers: sdks/typescript/server: dependencies: '@modelcontextprotocol/ext-apps': - specifier: ^0.2.2 - version: 0.2.2(@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.67))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(zod@3.25.67) + specifier: ^0.3.1 + version: 0.3.1(@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.67))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(zod@3.25.67) '@modelcontextprotocol/sdk': specifier: ^1.25.1 version: 1.25.1(hono@4.11.1)(zod@3.25.67) @@ -2095,21 +2101,24 @@ packages: react-dom: optional: true - '@modelcontextprotocol/sdk@1.22.0': - resolution: {integrity: sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==} - engines: {node: '>=18'} + '@modelcontextprotocol/ext-apps@0.3.1': + resolution: {integrity: sha512-Iivz2KwWK8xlRbiWwFB/C4NXqE8VJBoRCbBkJCN98ST2UbQvA6kfyebcLsypiqylJS467XOOaBcI9DeQ3t+zqA==} peerDependencies: - '@cfworker/json-schema': ^4.1.1 + '@modelcontextprotocol/sdk': ^1.24.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + zod: ^3.25.0 || ^4.0.0 peerDependenciesMeta: - '@cfworker/json-schema': + react: + optional: true + react-dom: optional: true - '@modelcontextprotocol/sdk@1.23.0': - resolution: {integrity: sha512-MCGd4K9aZKvuSqdoBkdMvZNcYXCkZRYVs/Gh92mdV5IHbctX9H9uIvd4X93+9g8tBbXv08sxc/QHXTzf8y65bA==} + '@modelcontextprotocol/sdk@1.22.0': + resolution: {integrity: sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 peerDependenciesMeta: '@cfworker/json-schema': optional: true @@ -2736,6 +2745,11 @@ packages: cpu: [arm64] os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.55.1': + resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.44.0': resolution: {integrity: sha512-3XJ0NQtMAXTWFW8FqZKcw3gOQwBtVWP/u8TpHP3CRPXD7Pd6s8lLdH3sHWh8vqKCyyiI8xW5ltJScQmBU9j7WA==} cpu: [ia32] @@ -10458,9 +10472,10 @@ snapshots: '@mcp-ui/server@5.2.0(zod@3.25.67)': dependencies: - '@modelcontextprotocol/sdk': 1.23.0(zod@3.25.67) + '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.1)(zod@3.25.67) transitivePeerDependencies: - '@cfworker/json-schema' + - hono - supports-color - zod @@ -10580,9 +10595,9 @@ snapshots: '@mjackson/node-fetch-server@0.6.1': {} - '@modelcontextprotocol/ext-apps@0.2.2(@modelcontextprotocol/sdk@1.23.0(zod@3.25.67))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(zod@3.25.67)': + '@modelcontextprotocol/ext-apps@0.2.2(@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.67))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(zod@3.25.67)': dependencies: - '@modelcontextprotocol/sdk': 1.23.0(zod@3.25.67) + '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.1)(zod@3.25.67) prettier: 3.7.4 zod: 3.25.67 optionalDependencies: @@ -10605,10 +10620,9 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - '@modelcontextprotocol/ext-apps@0.2.2(@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.67))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(zod@3.25.67)': + '@modelcontextprotocol/ext-apps@0.3.1(@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.67))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.25.67)': dependencies: '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.1)(zod@3.25.67) - prettier: 3.7.4 zod: 3.25.67 optionalDependencies: '@oven/bun-darwin-aarch64': 1.3.3 @@ -10626,29 +10640,37 @@ snapshots: '@rollup/rollup-darwin-x64': 4.53.3 '@rollup/rollup-linux-arm64-gnu': 4.53.3 '@rollup/rollup-linux-x64-gnu': 4.53.3 + '@rollup/rollup-win32-arm64-msvc': 4.55.1 '@rollup/rollup-win32-x64-msvc': 4.53.3 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - '@modelcontextprotocol/sdk@1.22.0': + '@modelcontextprotocol/ext-apps@0.3.1(@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.67))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(zod@3.25.67)': dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - content-type: 1.0.5 - cors: 2.8.5 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.1.0 - express-rate-limit: 7.5.1(express@5.1.0) - pkce-challenge: 5.0.0 - raw-body: 3.0.2 + '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.1)(zod@3.25.67) zod: 3.25.67 - zod-to-json-schema: 3.25.0(zod@3.25.67) - transitivePeerDependencies: - - supports-color + optionalDependencies: + '@oven/bun-darwin-aarch64': 1.3.3 + '@oven/bun-darwin-x64': 1.3.3 + '@oven/bun-darwin-x64-baseline': 1.3.3 + '@oven/bun-linux-aarch64': 1.3.3 + '@oven/bun-linux-aarch64-musl': 1.3.3 + '@oven/bun-linux-x64': 1.3.3 + '@oven/bun-linux-x64-baseline': 1.3.3 + '@oven/bun-linux-x64-musl': 1.3.3 + '@oven/bun-linux-x64-musl-baseline': 1.3.3 + '@oven/bun-windows-x64': 1.3.3 + '@oven/bun-windows-x64-baseline': 1.3.3 + '@rollup/rollup-darwin-arm64': 4.53.3 + '@rollup/rollup-darwin-x64': 4.53.3 + '@rollup/rollup-linux-arm64-gnu': 4.53.3 + '@rollup/rollup-linux-x64-gnu': 4.53.3 + '@rollup/rollup-win32-arm64-msvc': 4.55.1 + '@rollup/rollup-win32-x64-msvc': 4.53.3 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) - '@modelcontextprotocol/sdk@1.23.0(zod@3.25.67)': + '@modelcontextprotocol/sdk@1.22.0': dependencies: ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) @@ -11238,6 +11260,9 @@ snapshots: '@rollup/rollup-win32-arm64-msvc@4.44.0': optional: true + '@rollup/rollup-win32-arm64-msvc@4.55.1': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.44.0': optional: true @@ -12837,7 +12862,7 @@ snapshots: agents@0.0.80(@cloudflare/workers-types@4.20250620.0)(react@19.1.0): dependencies: - '@modelcontextprotocol/sdk': 1.23.0(zod@3.25.67) + '@modelcontextprotocol/sdk': 1.25.1(hono@4.11.1)(zod@3.25.67) ai: 4.3.16(react@19.1.0)(zod@3.25.67) cron-schedule: 5.0.4 nanoid: 5.1.5 @@ -12848,6 +12873,7 @@ snapshots: transitivePeerDependencies: - '@cfworker/json-schema' - '@cloudflare/workers-types' + - hono - supports-color aggregate-error@3.1.0: diff --git a/sdks/typescript/client/package.json b/sdks/typescript/client/package.json index bfb86c26..5ff367d7 100644 --- a/sdks/typescript/client/package.json +++ b/sdks/typescript/client/package.json @@ -19,11 +19,13 @@ "dist" ], "dependencies": { - "@modelcontextprotocol/sdk": "^1.22.0", + "@modelcontextprotocol/ext-apps": "^0.3.1", + "@modelcontextprotocol/sdk": "^1.24.0", "@quilted/threads": "^3.1.3", "@r2wc/react-to-web-component": "^2.0.4", "@remote-dom/core": "^1.8.0", - "@remote-dom/react": "^1.2.2" + "@remote-dom/react": "^1.2.2", + "zod": "^3.23.8" }, "devDependencies": { "@testing-library/jest-dom": "^6.0.0", diff --git a/sdks/typescript/client/src/__tests__/capabilities.test.ts b/sdks/typescript/client/src/__tests__/capabilities.test.ts new file mode 100644 index 00000000..d7e1be06 --- /dev/null +++ b/sdks/typescript/client/src/__tests__/capabilities.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { + type ClientCapabilitiesWithExtensions, + UI_EXTENSION_NAME, + UI_EXTENSION_CONFIG, + UI_EXTENSION_CAPABILITIES, +} from '../capabilities'; +import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/app-bridge'; + +describe('UI Extension Capabilities', () => { + it('should have correct extension name', () => { + expect(UI_EXTENSION_NAME).toBe('io.modelcontextprotocol/ui'); + }); + + it('should include RESOURCE_MIME_TYPE in mimeTypes', () => { + expect(UI_EXTENSION_CONFIG.mimeTypes).toContain(RESOURCE_MIME_TYPE); + expect(UI_EXTENSION_CONFIG.mimeTypes).toEqual(['text/html;profile=mcp-app']); + }); + + it('should structure capabilities with extension name as key', () => { + expect(UI_EXTENSION_CAPABILITIES[UI_EXTENSION_NAME]).toEqual( + UI_EXTENSION_CONFIG + ); + }); + + it('should work with ClientCapabilitiesWithExtensions type', () => { + const capabilities: ClientCapabilitiesWithExtensions = { + roots: { listChanged: true }, + extensions: UI_EXTENSION_CAPABILITIES, + }; + + expect(capabilities.roots).toEqual({ listChanged: true }); + expect(capabilities.extensions?.[UI_EXTENSION_NAME]).toEqual(UI_EXTENSION_CONFIG); + }); + + it('should allow combining with other MCP capabilities', () => { + const capabilities: ClientCapabilitiesWithExtensions = { + roots: { listChanged: true }, + sampling: {}, + extensions: { + ...UI_EXTENSION_CAPABILITIES, + 'custom/extension': { customKey: 'customValue' }, + }, + }; + + expect(capabilities.roots).toEqual({ listChanged: true }); + expect(capabilities.sampling).toEqual({}); + expect(capabilities.extensions?.[UI_EXTENSION_NAME]).toEqual(UI_EXTENSION_CONFIG); + expect(capabilities.extensions?.['custom/extension']).toEqual({ customKey: 'customValue' }); + }); + + it('should allow extensions field to be optional', () => { + const capabilities: ClientCapabilitiesWithExtensions = { + roots: { listChanged: true }, + }; + + expect(capabilities.extensions).toBeUndefined(); + }); +}); diff --git a/sdks/typescript/client/src/capabilities.ts b/sdks/typescript/client/src/capabilities.ts new file mode 100644 index 00000000..4b95dd12 --- /dev/null +++ b/sdks/typescript/client/src/capabilities.ts @@ -0,0 +1,67 @@ +import type { ClientCapabilities } from '@modelcontextprotocol/sdk/types.js'; +import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/app-bridge'; + +/** + * Extended ClientCapabilities type that includes the `extensions` field. + * + * This type is a forward-compatible extension of the MCP SDK's ClientCapabilities, + * adding support for the `extensions` field pattern proposed in SEP-1724. + * + * @see https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1724 + */ +export interface ClientCapabilitiesWithExtensions extends ClientCapabilities { + extensions?: { + [extensionName: string]: unknown; + }; +} + +/** + * Extension identifier for MCP UI support. + * + * Follows the pattern from SEP-1724: `{vendor-prefix}/{extension-name}` + * + * @see https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1724 + */ +export const UI_EXTENSION_NAME = 'io.modelcontextprotocol/ui' as const; + +/** + * UI extension capability configuration. + * + * Declares support for rendering UI resources with specific MIME types. + */ +export const UI_EXTENSION_CONFIG = { + mimeTypes: [RESOURCE_MIME_TYPE], +} as const; + +/** + * UI extension capabilities object to use in the `extensions` field. + * + * Use this when creating an MCP Client to declare support for rendering + * UI resources. + * + * @example + * ```typescript + * import { Client } from '@modelcontextprotocol/sdk/client/index.js'; + * import { + * type ClientCapabilitiesWithExtensions, + * UI_EXTENSION_CAPABILITIES, + * } from '@mcp-ui/client'; + * + * const capabilities: ClientCapabilitiesWithExtensions = { + * // Standard MCP capabilities + * roots: { listChanged: true }, + * // UI extension support (SEP-1724 pattern) + * extensions: UI_EXTENSION_CAPABILITIES, + * }; + * + * const client = new Client( + * { name: 'my-app', version: '1.0.0' }, + * { capabilities } + * ); + * ``` + * + * @see https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1724 + */ +export const UI_EXTENSION_CAPABILITIES = { + [UI_EXTENSION_NAME]: UI_EXTENSION_CONFIG, +} as const; diff --git a/sdks/typescript/client/src/components/AppFrame.tsx b/sdks/typescript/client/src/components/AppFrame.tsx new file mode 100644 index 00000000..7266194a --- /dev/null +++ b/sdks/typescript/client/src/components/AppFrame.tsx @@ -0,0 +1,306 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { CallToolResult, Implementation } from '@modelcontextprotocol/sdk/types.js'; + +import { + AppBridge, + PostMessageTransport, + type McpUiSizeChangedNotification, + type McpUiResourceCsp, + type McpUiAppCapabilities, +} from '@modelcontextprotocol/ext-apps/app-bridge'; + +import { setupSandboxProxyIframe } from '../utils/app-host-utils'; + +/** + * Build sandbox URL with CSP query parameter for HTTP header-based CSP enforcement. + * + * When the proxy server supports it, CSP passed via query parameter allows the server + * to set CSP via HTTP headers (tamper-proof) rather than relying on meta tags or + * postMessage-based CSP injection (which can be bypassed by malicious content). + * + * @see https://github.com/modelcontextprotocol/ext-apps/pull/234 + */ +function buildSandboxUrl(baseUrl: URL, csp?: McpUiResourceCsp): URL { + const url = new URL(baseUrl.href); + if (csp && Object.keys(csp).length > 0) { + url.searchParams.set('csp', JSON.stringify(csp)); + } + return url; +} + +/** + * Information about the guest app, available after initialization. + */ +export interface AppInfo { + /** Guest app's name and version */ + appVersion?: Implementation; + /** Guest app's declared capabilities */ + appCapabilities?: McpUiAppCapabilities; +} + +/** + * Sandbox configuration for the iframe. + */ +export interface SandboxConfig { + /** URL to the sandbox proxy HTML */ + url: URL; + /** Override iframe sandbox attribute (default: "allow-scripts allow-same-origin allow-forms") */ + permissions?: string; + /** + * CSP metadata for the sandbox. + * + * This CSP is passed to the sandbox proxy in two ways: + * 1. Via URL query parameter (`?csp=`) - allows servers that support it to set + * CSP via HTTP headers (tamper-proof, recommended) + * 2. Via postMessage after sandbox loads - fallback for proxies that don't parse query params + * + * For maximum security, use a proxy server that reads the `csp` query parameter and sets + * Content-Security-Policy HTTP headers accordingly. + * + * @see https://github.com/modelcontextprotocol/ext-apps/pull/234 + */ + csp?: McpUiResourceCsp; +} + +/** + * Props for the AppFrame component. + */ +export interface AppFrameProps { + /** Pre-fetched HTML content to render in the sandbox */ + html: string; + + /** Sandbox configuration */ + sandbox: SandboxConfig; + + /** Pre-configured AppBridge for MCP communication (required) */ + appBridge: AppBridge; + + /** Callback when guest reports size change */ + onSizeChanged?: (params: McpUiSizeChangedNotification['params']) => void; + + /** Callback when app initialization completes, with app info */ + onInitialized?: (appInfo: AppInfo) => void; + + /** Tool input arguments to send when app initializes */ + toolInput?: Record; + + /** Tool result to send when app initializes */ + toolResult?: CallToolResult; + + /** Callback when an error occurs */ + onError?: (error: Error) => void; +} + +/** + * Low-level component that renders pre-fetched HTML in a sandboxed iframe. + * + * This component requires a pre-configured AppBridge for MCP communication. + * For automatic AppBridge creation and resource fetching, use the higher-level + * AppRenderer component instead. + * + * @example With pre-configured AppBridge + * ```tsx + * const appBridge = new AppBridge(client, hostInfo, capabilities); + * // ... configure appBridge handlers ... + * + * console.log('Size:', width, height)} + * /> + * ``` + */ +export const AppFrame = (props: AppFrameProps) => { + const { + html, + sandbox, + appBridge, + onSizeChanged, + onInitialized, + toolInput, + toolResult, + onError, + } = props; + + const [iframeReady, setIframeReady] = useState(false); + const [bridgeConnected, setBridgeConnected] = useState(false); + const [error, setError] = useState(null); + const containerRef = useRef(null); + const iframeRef = useRef(null); + // Track the current sandbox URL to detect when it changes + const currentSandboxUrlRef = useRef(null); + // Track the current appBridge to detect when it changes (for isolation) + const currentAppBridgeRef = useRef(null); + + // Refs for callbacks to avoid effect re-runs + const onSizeChangedRef = useRef(onSizeChanged); + const onInitializedRef = useRef(onInitialized); + const onErrorRef = useRef(onError); + + useEffect(() => { + onSizeChangedRef.current = onSizeChanged; + onInitializedRef.current = onInitialized; + onErrorRef.current = onError; + }); + + // Effect 1: Set up sandbox iframe and connect AppBridge + useEffect(() => { + // Build sandbox URL with CSP query parameter for HTTP header-based CSP enforcement. + // Servers that support this will parse the CSP from the query param and set it via + // HTTP headers (tamper-proof). The CSP is also sent via postMessage as fallback. + const sandboxUrl = buildSandboxUrl(sandbox.url, sandbox.csp); + const sandboxUrlString = sandboxUrl.href; + + // If we already have an iframe set up for this sandbox URL AND the same appBridge, skip setup + // This preserves the iframe state across React re-renders (including StrictMode) + // but ensures isolation when switching to a different app/resource (different appBridge) + if ( + currentSandboxUrlRef.current === sandboxUrlString && + currentAppBridgeRef.current === appBridge && + iframeRef.current + ) { + return; + } + + // Reset state when setting up a new iframe/bridge to ensure isolation + // between different apps/resources + setIframeReady(false); + setBridgeConnected(false); + setError(null); + + let mounted = true; + + const setup = async () => { + try { + // If switching to a different sandbox URL or appBridge, clean up the old iframe first + if (iframeRef.current && containerRef.current?.contains(iframeRef.current)) { + containerRef.current.removeChild(iframeRef.current); + iframeRef.current = null; + currentSandboxUrlRef.current = null; + currentAppBridgeRef.current = null; + } + + const { iframe, onReady } = await setupSandboxProxyIframe(sandboxUrl); + + if (!mounted) return; + + iframeRef.current = iframe; + currentSandboxUrlRef.current = sandboxUrlString; + currentAppBridgeRef.current = appBridge; + if (containerRef.current) { + containerRef.current.appendChild(iframe); + } + + await onReady; + + if (!mounted) return; + + // Register size change handler + appBridge.onsizechange = async (params) => { + onSizeChangedRef.current?.(params); + // Also update iframe size + if (iframeRef.current) { + if (params.width !== undefined) { + iframeRef.current.style.width = `${params.width}px`; + } + if (params.height !== undefined) { + iframeRef.current.style.height = `${params.height}px`; + } + } + }; + + // Hook into initialization + appBridge.oninitialized = () => { + if (!mounted) return; + console.log('[AppFrame] App initialized'); + setIframeReady(true); + onInitializedRef.current?.({ + appVersion: appBridge.getAppVersion(), + appCapabilities: appBridge.getAppCapabilities(), + }); + }; + + + // Connect the bridge + await appBridge.connect( + new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!), + ); + + if (!mounted) return; + + setBridgeConnected(true); + } catch (err) { + console.error('[AppFrame] Error:', err); + if (!mounted) return; + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + onErrorRef.current?.(error); + } + }; + + setup(); + + return () => { + mounted = false; + }; + }, [sandbox.url, sandbox.csp, appBridge]); + + // Effect 2: Send HTML to sandbox when bridge is connected + useEffect(() => { + // Ensure we only send HTML to the correct appBridge that's currently connected + // This prevents race conditions when switching between apps + if (!bridgeConnected || !html || currentAppBridgeRef.current !== appBridge) return; + + const sendHtml = async () => { + try { + console.log('[AppFrame] Sending HTML to sandbox'); + await appBridge.sendSandboxResourceReady({ + html, + csp: sandbox.csp, + }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + onErrorRef.current?.(error); + } + }; + + sendHtml(); + }, [bridgeConnected, html, appBridge, sandbox.csp]); + + // Effect 3: Send tool input when ready + useEffect(() => { + // Ensure we only send to the correct appBridge that's currently connected + if (bridgeConnected && iframeReady && toolInput && currentAppBridgeRef.current === appBridge) { + console.log('[AppFrame] Sending tool input:', toolInput); + appBridge.sendToolInput({ arguments: toolInput }); + } + }, [appBridge, bridgeConnected, iframeReady, toolInput]); + + // Effect 4: Send tool result when ready + useEffect(() => { + // Ensure we only send to the correct appBridge that's currently connected + if (bridgeConnected && iframeReady && toolResult && currentAppBridgeRef.current === appBridge) { + console.log('[AppFrame] Sending tool result:', toolResult); + appBridge.sendToolResult(toolResult); + } + }, [appBridge, bridgeConnected, iframeReady, toolResult]); + + return ( +
+ {error &&
Error: {error.message}
} +
+ ); +}; diff --git a/sdks/typescript/client/src/components/AppRenderer.tsx b/sdks/typescript/client/src/components/AppRenderer.tsx new file mode 100644 index 00000000..e1eb6cca --- /dev/null +++ b/sdks/typescript/client/src/components/AppRenderer.tsx @@ -0,0 +1,515 @@ +import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react'; + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { + type CallToolRequest, + type CallToolResult, + type ListPromptsRequest, + type ListPromptsResult, + type ListResourcesRequest, + type ListResourcesResult, + type ListResourceTemplatesRequest, + type ListResourceTemplatesResult, + type LoggingMessageNotification, + type ReadResourceRequest, + type ReadResourceResult, + McpError, + ErrorCode, +} from '@modelcontextprotocol/sdk/types.js'; + +import { + AppBridge, + RESOURCE_MIME_TYPE, + type McpUiMessageRequest, + type McpUiMessageResult, + type McpUiOpenLinkRequest, + type McpUiOpenLinkResult, + type McpUiSizeChangedNotification, + type McpUiToolInputPartialNotification, + type McpUiHostContext, +} from '@modelcontextprotocol/ext-apps/app-bridge'; + +import { AppFrame, type SandboxConfig } from './AppFrame'; +import { getToolUiResourceUri, readToolUiResourceHtml } from '../utils/app-host-utils'; + +/** + * Extra metadata passed to request handlers (from AppBridge). + */ +export type RequestHandlerExtra = Parameters[1]>[1]; + +/** + * Handle to access AppRenderer methods for sending notifications to the Guest UI. + * Obtained via ref on AppRenderer. + */ +export interface AppRendererHandle { + /** Notify the Guest UI that the server's tool list has changed */ + sendToolListChanged: () => void; + /** Notify the Guest UI that the server's resource list has changed */ + sendResourceListChanged: () => void; + /** Notify the Guest UI that the server's prompt list has changed */ + sendPromptListChanged: () => void; + /** Notify the Guest UI that the resource is being torn down / cleaned up */ + teardownResource: () => void; +} + +/** + * Props for the AppRenderer component. + */ +export interface AppRendererProps { + /** MCP client connected to the server providing the tool. Omit to disable automatic MCP forwarding and use custom handlers instead. */ + client?: Client; + + /** Name of the MCP tool to render UI for */ + toolName: string; + + /** Sandbox configuration */ + sandbox: SandboxConfig; + + /** Optional pre-fetched resource URI. If not provided, will be fetched via getToolUiResourceUri() */ + toolResourceUri?: string; + + /** Optional pre-fetched HTML. If provided, skips all resource fetching */ + html?: string; + + /** Optional input arguments to pass to the tool UI once it's ready */ + toolInput?: Record; + + /** Optional result from tool execution to pass to the tool UI once it's ready */ + toolResult?: CallToolResult; + + /** Partial/streaming tool input to send to the guest UI */ + toolInputPartial?: McpUiToolInputPartialNotification['params']; + + /** Set to true to notify the guest UI that the tool execution was cancelled */ + toolCancelled?: boolean; + + /** Host context (theme, viewport, locale, etc.) to pass to the guest UI */ + hostContext?: McpUiHostContext; + + /** Handler for open-link requests from the guest UI */ + onOpenLink?: ( + params: McpUiOpenLinkRequest['params'], + extra: RequestHandlerExtra, + ) => Promise; + + /** Handler for message requests from the guest UI */ + onMessage?: ( + params: McpUiMessageRequest['params'], + extra: RequestHandlerExtra, + ) => Promise; + + /** Handler for logging messages from the guest UI */ + onLoggingMessage?: (params: LoggingMessageNotification['params']) => void; + + /** Handler for size change notifications from the guest UI */ + onSizeChanged?: (params: McpUiSizeChangedNotification['params']) => void; + + /** Callback invoked when an error occurs during setup or message handling */ + onError?: (error: Error) => void; + + // --- MCP Request Handlers (override automatic forwarding) --- + + /** + * Handler for tools/call requests from the guest UI. + * If provided, overrides the automatic forwarding to the MCP client. + */ + onCallTool?: ( + params: CallToolRequest['params'], + extra: RequestHandlerExtra, + ) => Promise; + + /** + * Handler for resources/list requests from the guest UI. + * If provided, overrides the automatic forwarding to the MCP client. + */ + onListResources?: ( + params: ListResourcesRequest['params'], + extra: RequestHandlerExtra, + ) => Promise; + + /** + * Handler for resources/templates/list requests from the guest UI. + * If provided, overrides the automatic forwarding to the MCP client. + */ + onListResourceTemplates?: ( + params: ListResourceTemplatesRequest['params'], + extra: RequestHandlerExtra, + ) => Promise; + + /** + * Handler for resources/read requests from the guest UI. + * If provided, overrides the automatic forwarding to the MCP client. + */ + onReadResource?: ( + params: ReadResourceRequest['params'], + extra: RequestHandlerExtra, + ) => Promise; + + /** + * Handler for prompts/list requests from the guest UI. + * If provided, overrides the automatic forwarding to the MCP client. + */ + onListPrompts?: ( + params: ListPromptsRequest['params'], + extra: RequestHandlerExtra, + ) => Promise; +} + +/** + * 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 AppBridge for MCP communication + * 2. Fetches the tool's UI resource (HTML) if not provided + * 3. Delegates rendering to AppFrame + * 4. Handles UI actions (intents, link opening, prompts, notifications) + * + * For lower-level control or when you already have the HTML content, + * use the AppFrame component directly. + * + * @example Basic usage + * ```tsx + * window.open(url)} + * onError={(error) => console.error('UI Error:', error)} + * /> + * ``` + * + * @example With pre-fetched HTML (skips resource fetching) + * ```tsx + * + * ``` + * + * @example Using ref to access AppBridge methods + * ```tsx + * const appRef = useRef(null); + * + * // Notify guest UI when tools change + * useEffect(() => { + * appRef.current?.sendToolListChanged(); + * }, [toolsVersion]); + * + * + * ``` + * + * @example With custom MCP request handlers (no client) + * ```tsx + * { + * // Proxy to your MCP client (e.g., in a different context) + * return myMcpProxy.readResource({ uri }); + * }} + * onCallTool={async (params) => { + * // Custom tool call handling with caching/filtering + * return myCustomToolCall(params); + * }} + * onListResources={async () => { + * // Aggregate resources from multiple servers + * return { resources: [...server1Resources, ...server2Resources] }; + * }} + * /> + * ``` + */ +export const AppRenderer = forwardRef((props, ref) => { + const { + client, + toolName, + sandbox, + toolResourceUri, + html: htmlProp, + toolInput, + toolResult, + toolInputPartial, + toolCancelled, + hostContext, + onMessage, + onOpenLink, + onLoggingMessage, + onSizeChanged, + onError, + onCallTool, + onListResources, + onListResourceTemplates, + onReadResource, + onListPrompts, + } = props; + + // State + const [appBridge, setAppBridge] = useState(null); + const [html, setHtml] = useState(htmlProp ?? null); + const [error, setError] = useState(null); + + // Refs for callbacks + const onMessageRef = useRef(onMessage); + const onOpenLinkRef = useRef(onOpenLink); + const onLoggingMessageRef = useRef(onLoggingMessage); + const onSizeChangedRef = useRef(onSizeChanged); + const onErrorRef = useRef(onError); + const onCallToolRef = useRef(onCallTool); + const onListResourcesRef = useRef(onListResources); + const onListResourceTemplatesRef = useRef(onListResourceTemplates); + const onReadResourceRef = useRef(onReadResource); + const onListPromptsRef = useRef(onListPrompts); + + useEffect(() => { + onMessageRef.current = onMessage; + onOpenLinkRef.current = onOpenLink; + onLoggingMessageRef.current = onLoggingMessage; + onSizeChangedRef.current = onSizeChanged; + onErrorRef.current = onError; + onCallToolRef.current = onCallTool; + onListResourcesRef.current = onListResources; + onListResourceTemplatesRef.current = onListResourceTemplates; + onReadResourceRef.current = onReadResource; + onListPromptsRef.current = onListPrompts; + }); + + // Expose send methods via ref for Host → Guest notifications + useImperativeHandle( + ref, + () => ({ + sendToolListChanged: () => appBridge?.sendToolListChanged(), + sendResourceListChanged: () => appBridge?.sendResourceListChanged(), + sendPromptListChanged: () => appBridge?.sendPromptListChanged(), + teardownResource: () => appBridge?.teardownResource({}), + }), + [appBridge], + ); + + // Effect 1: Create and configure AppBridge + useEffect(() => { + let mounted = true; + + const createBridge = () => { + try { + const serverCapabilities = client?.getServerCapabilities(); + const bridge = new AppBridge( + client ?? null, + { + name: 'MCP-UI Host', + version: '1.0.0', + }, + { + openLinks: {}, + serverTools: serverCapabilities?.tools, + serverResources: serverCapabilities?.resources, + }, + ); + + // Register message handler + bridge.onmessage = async (params, extra) => { + if (onMessageRef.current) { + return onMessageRef.current(params, extra); + } else { + throw new McpError(ErrorCode.MethodNotFound, 'Method not found'); + } + }; + + // Register open-link handler + bridge.onopenlink = async (params, extra) => { + if (onOpenLinkRef.current) { + return onOpenLinkRef.current(params, extra); + } else { + throw new McpError(ErrorCode.MethodNotFound, 'Method not found'); + } + }; + + // Register logging handler + bridge.onloggingmessage = (params) => { + if (onLoggingMessageRef.current) { + onLoggingMessageRef.current(params); + } + }; + + // Register custom MCP request handlers (these override automatic forwarding) + if (onCallToolRef.current) { + bridge.oncalltool = (params, extra) => onCallToolRef.current!(params, extra); + } + if (onListResourcesRef.current) { + bridge.onlistresources = (params, extra) => onListResourcesRef.current!(params, extra); + } + if (onListResourceTemplatesRef.current) { + bridge.onlistresourcetemplates = (params, extra) => + onListResourceTemplatesRef.current!(params, extra); + } + if (onReadResourceRef.current) { + bridge.onreadresource = (params, extra) => onReadResourceRef.current!(params, extra); + } + if (onListPromptsRef.current) { + bridge.onlistprompts = (params, extra) => onListPromptsRef.current!(params, extra); + } + + if (!mounted) return; + setAppBridge(bridge); + } catch (err) { + console.error('[AppRenderer] Error creating bridge:', err); + if (!mounted) return; + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + onErrorRef.current?.(error); + } + }; + + createBridge(); + + return () => { + mounted = false; + }; + }, [client]); + + // Effect 2: Fetch HTML if not provided + useEffect(() => { + if (htmlProp) { + setHtml(htmlProp); + return; + } + + // Determine if we can fetch HTML + const canFetchWithClient = !!client; + const canFetchWithCallback = !!toolResourceUri && !!onReadResourceRef.current; + + if (!canFetchWithClient && !canFetchWithCallback) { + setError( + new Error( + "Either 'html' prop, 'client', or ('toolResourceUri' + 'onReadResource') must be provided to fetch UI resource", + ), + ); + return; + } + + let mounted = true; + + const fetchHtml = async () => { + try { + // Get resource URI + let uri: string; + if (toolResourceUri) { + uri = toolResourceUri; + console.log(`[AppRenderer] Using provided resource URI: ${uri}`); + } else if (client) { + console.log(`[AppRenderer] 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 in tool._meta)`, + ); + } + uri = info.uri; + console.log(`[AppRenderer] Got resource URI: ${uri}`); + } else { + throw new Error('Cannot determine resource URI without client or toolResourceUri'); + } + + if (!mounted) return; + + // Read HTML content - use client if available, otherwise use onReadResource callback + console.log(`[AppRenderer] Reading resource HTML from: ${uri}`); + let htmlContent: string; + + if (client) { + htmlContent = await readToolUiResourceHtml(client, { uri }); + } else if (onReadResourceRef.current) { + // Use the onReadResource callback to fetch the HTML + const result = await onReadResourceRef.current({ uri }, {} as RequestHandlerExtra); + if (!result.contents || result.contents.length !== 1) { + throw new Error('Unsupported UI resource content length: ' + result.contents?.length); + } + const content = result.contents[0]; + const isHtml = (t?: string) => t === RESOURCE_MIME_TYPE; + + if ('text' in content && typeof content.text === 'string' && isHtml(content.mimeType)) { + htmlContent = content.text; + } else if ( + 'blob' in content && + typeof content.blob === 'string' && + isHtml(content.mimeType) + ) { + htmlContent = atob(content.blob); + } else { + throw new Error('Unsupported UI resource content format: ' + JSON.stringify(content)); + } + } else { + throw new Error('No way to read resource HTML'); + } + + if (!mounted) return; + + setHtml(htmlContent); + } catch (err) { + if (!mounted) return; + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + onErrorRef.current?.(error); + } + }; + + fetchHtml(); + + return () => { + mounted = false; + }; + }, [client, toolName, toolResourceUri, htmlProp]); + + // Effect 3: Sync host context when it changes + useEffect(() => { + if (appBridge && hostContext) { + appBridge.setHostContext(hostContext); + } + }, [appBridge, hostContext]); + + // Effect 4: Send partial tool input when it changes + useEffect(() => { + if (appBridge && toolInputPartial) { + appBridge.sendToolInputPartial(toolInputPartial); + } + }, [appBridge, toolInputPartial]); + + // Effect 5: Send tool cancelled notification when flag is set + useEffect(() => { + if (appBridge && toolCancelled) { + appBridge.sendToolCancelled({}); + } + }, [appBridge, toolCancelled]); + + // Handle size change callback + const handleSizeChanged = onSizeChangedRef.current; + + // Render error state + if (error) { + return
Error: {error.message}
; + } + + // Render loading state + if (!appBridge || !html) { + return null; + } + + // Render AppFrame with the fetched HTML and configured bridge + return ( + + ); +}); + +AppRenderer.displayName = 'AppRenderer'; diff --git a/sdks/typescript/client/src/components/__tests__/AppFrame.test.tsx b/sdks/typescript/client/src/components/__tests__/AppFrame.test.tsx new file mode 100644 index 00000000..accbf53f --- /dev/null +++ b/sdks/typescript/client/src/components/__tests__/AppFrame.test.tsx @@ -0,0 +1,401 @@ +import { render, screen, waitFor, act } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import '@testing-library/jest-dom'; + +import { AppFrame, type AppFrameProps } from '../AppFrame'; +import * as appHostUtils from '../../utils/app-host-utils'; +import type { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge'; + +// Mock the ext-apps module +vi.mock('@modelcontextprotocol/ext-apps/app-bridge', () => { + // Create a mock constructor for PostMessageTransport + const MockPostMessageTransport = vi.fn().mockImplementation(function(this: unknown) { + return this; + }); + + return { + AppBridge: vi.fn(), + PostMessageTransport: MockPostMessageTransport, + }; +}); + +// Track registered handlers +let registeredOninitialized: (() => void) | null = null; +let registeredOnsizechange: ((params: { width?: number; height?: number }) => void) | null = null; + +// Mock AppBridge factory +const createMockAppBridge = () => { + const bridge = { + connect: vi.fn().mockResolvedValue(undefined), + sendSandboxResourceReady: vi.fn().mockResolvedValue(undefined), + sendToolInput: vi.fn(), + sendToolResult: vi.fn(), + getAppVersion: vi.fn().mockReturnValue({ name: 'TestApp', version: '1.0.0' }), + getAppCapabilities: vi.fn().mockReturnValue({ tools: {} }), + _oninitialized: null as (() => void) | null, + _onsizechange: null as ((params: { width?: number; height?: number }) => void) | null, + }; + + Object.defineProperty(bridge, 'oninitialized', { + set: (fn) => { + bridge._oninitialized = fn; + registeredOninitialized = fn; + }, + get: () => bridge._oninitialized, + }); + Object.defineProperty(bridge, 'onsizechange', { + set: (fn) => { + bridge._onsizechange = fn; + registeredOnsizechange = fn; + }, + get: () => bridge._onsizechange, + }); + + return bridge; +}; + +// Mock the app-host-utils module +vi.mock('../../utils/app-host-utils', () => ({ + setupSandboxProxyIframe: vi.fn(), +})); + +describe('', () => { + let mockIframe: Partial; + let mockContentWindow: { postMessage: ReturnType }; + let onReadyResolve: () => void; + let mockAppBridge: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + registeredOninitialized = null; + registeredOnsizechange = null; + mockAppBridge = createMockAppBridge(); + + // Create mock contentWindow + mockContentWindow = { + postMessage: vi.fn(), + }; + + // Create a real iframe element and mock contentWindow via defineProperty + const realIframe = document.createElement('iframe'); + Object.defineProperty(realIframe, 'contentWindow', { + get: () => mockContentWindow as unknown as Window, + configurable: true, + }); + mockIframe = realIframe; + + // Setup mock for setupSandboxProxyIframe + const onReadyPromise = new Promise((resolve) => { + onReadyResolve = resolve; + }); + + vi.mocked(appHostUtils.setupSandboxProxyIframe).mockResolvedValue({ + iframe: mockIframe as HTMLIFrameElement, + onReady: onReadyPromise, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const defaultProps: Omit = { + html: 'Test', + sandbox: { url: new URL('http://localhost:8081/sandbox.html') }, + }; + + const getPropsWithBridge = (overrides: Partial = {}): AppFrameProps => ({ + ...defaultProps, + appBridge: mockAppBridge as unknown as AppBridge, + ...overrides, + }); + + it('should render without crashing', () => { + render(); + expect(document.querySelector('div')).toBeInTheDocument(); + }); + + it('should call setupSandboxProxyIframe with sandbox URL', async () => { + render(); + + await waitFor(() => { + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenCalledWith(defaultProps.sandbox.url); + }); + }); + + it('should connect AppBridge when provided', async () => { + render(); + + await act(() => { + onReadyResolve(); + }); + + await waitFor(() => { + expect(mockAppBridge.connect).toHaveBeenCalled(); + }); + }); + + it('should send HTML via AppBridge.sendSandboxResourceReady', async () => { + render(); + + await act(() => { + onReadyResolve(); + }); + + // Trigger initialization + await act(() => { + registeredOninitialized?.(); + }); + + await waitFor(() => { + expect(mockAppBridge.sendSandboxResourceReady).toHaveBeenCalledWith({ + html: defaultProps.html, + csp: undefined, + }); + }); + }); + + it('should call onInitialized with app info when app initializes', async () => { + const onInitialized = vi.fn(); + + render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + await waitFor(() => { + expect(onInitialized).toHaveBeenCalledWith({ + appVersion: { name: 'TestApp', version: '1.0.0' }, + appCapabilities: { tools: {} }, + }); + }); + }); + + it('should send tool input after initialization', async () => { + const toolInput = { foo: 'bar' }; + + render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + await waitFor(() => { + expect(mockAppBridge.sendToolInput).toHaveBeenCalledWith({ + arguments: toolInput, + }); + }); + }); + + it('should send tool result after initialization', async () => { + const toolResult = { content: [{ type: 'text' as const, text: 'result' }] }; + + render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + await waitFor(() => { + expect(mockAppBridge.sendToolResult).toHaveBeenCalledWith(toolResult); + }); + }); + + it('should call onSizeChanged when size changes', async () => { + const onSizeChanged = vi.fn(); + + render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOnsizechange?.({ width: 800, height: 600 }); + }); + + expect(onSizeChanged).toHaveBeenCalledWith({ width: 800, height: 600 }); + }); + + it('should forward CSP to sandbox', async () => { + const csp = { + connectDomains: ['api.example.com'], + resourceDomains: ['cdn.example.com'], + }; + + render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + await waitFor(() => { + expect(mockAppBridge.sendSandboxResourceReady).toHaveBeenCalledWith({ + html: defaultProps.html, + csp, + }); + }); + }); + + it('should call onError when setup fails', async () => { + const onError = vi.fn(); + const error = new Error('Setup failed'); + + vi.mocked(appHostUtils.setupSandboxProxyIframe).mockRejectedValue(error); + + render(); + + await waitFor(() => { + expect(onError).toHaveBeenCalledWith(error); + }); + }); + + it('should display error message when error occurs', async () => { + const error = new Error('Test error'); + vi.mocked(appHostUtils.setupSandboxProxyIframe).mockRejectedValue(error); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Error: Test error/)).toBeInTheDocument(); + }); + }); + + describe('lifecycle', () => { + it('should preserve iframe across re-renders', async () => { + const { rerender } = render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + // setupSandboxProxyIframe should be called once on initial mount + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenCalledTimes(1); + + // Re-render with same props (simulating React StrictMode remount or parent re-render) + rerender(); + + // Should NOT call setupSandboxProxyIframe again - iframe is preserved + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenCalledTimes(1); + }); + + it('should recreate iframe when sandbox URL changes', async () => { + const { rerender } = render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenCalledTimes(1); + + // Create new mock for second iframe + const secondOnReadyPromise = new Promise((resolve) => { + onReadyResolve = resolve; + }); + vi.mocked(appHostUtils.setupSandboxProxyIframe).mockResolvedValue({ + iframe: mockIframe as HTMLIFrameElement, + onReady: secondOnReadyPromise, + }); + + // Re-render with DIFFERENT sandbox URL + const newSandboxUrl = new URL('http://localhost:9999/different-sandbox.html'); + rerender(); + + // Should call setupSandboxProxyIframe again with new URL + await waitFor(() => { + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenCalledTimes(2); + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenLastCalledWith(newSandboxUrl); + }); + }); + + it('should update HTML content without recreating iframe', async () => { + const { rerender } = render(); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + // Initial HTML sent + await waitFor(() => { + expect(mockAppBridge.sendSandboxResourceReady).toHaveBeenCalledTimes(1); + expect(mockAppBridge.sendSandboxResourceReady).toHaveBeenCalledWith({ + html: defaultProps.html, + csp: undefined, + }); + }); + + // Re-render with new HTML + const newHtml = 'Updated Content'; + rerender(); + + // Should send new HTML without recreating iframe + await waitFor(() => { + expect(mockAppBridge.sendSandboxResourceReady).toHaveBeenCalledTimes(2); + expect(mockAppBridge.sendSandboxResourceReady).toHaveBeenLastCalledWith({ + html: newHtml, + csp: undefined, + }); + }); + + // Iframe should NOT be recreated + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenCalledTimes(1); + }); + + it('should update toolInput without recreating iframe', async () => { + const { rerender } = render( + , + ); + + await act(() => { + onReadyResolve(); + }); + + await act(() => { + registeredOninitialized?.(); + }); + + await waitFor(() => { + expect(mockAppBridge.sendToolInput).toHaveBeenCalledWith({ arguments: { initial: true } }); + }); + + // Re-render with new toolInput + rerender(); + + await waitFor(() => { + expect(mockAppBridge.sendToolInput).toHaveBeenCalledWith({ arguments: { updated: true } }); + }); + + // Iframe should NOT be recreated + expect(appHostUtils.setupSandboxProxyIframe).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx b/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx new file mode 100644 index 00000000..5187ee66 --- /dev/null +++ b/sdks/typescript/client/src/components/__tests__/AppRenderer.test.tsx @@ -0,0 +1,543 @@ +import React from 'react'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import '@testing-library/jest-dom'; +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; + +import { AppRenderer, type AppRendererProps, type AppRendererHandle } from '../AppRenderer'; +import type { AppFrameProps } from '../AppFrame'; +import * as appHostUtils from '../../utils/app-host-utils'; + +// Mock AppFrame to capture props +const mockAppFrame = vi.fn(); +vi.mock('../AppFrame', () => ({ + AppFrame: (props: AppFrameProps) => { + mockAppFrame(props); + return ( +
+ {props.toolInput && {JSON.stringify(props.toolInput)}} + {props.toolResult && {JSON.stringify(props.toolResult)}} +
+ ); + }, +})); + +// Mock app-host-utils +vi.mock('../../utils/app-host-utils', () => ({ + getToolUiResourceUri: vi.fn(), + readToolUiResourceHtml: vi.fn(), +})); + +// Store mock bridge instance for test access +let mockBridgeInstance: Partial | null = null; + +// Mock AppBridge constructor +vi.mock('@modelcontextprotocol/ext-apps/app-bridge', () => { + return { + AppBridge: vi.fn().mockImplementation(function () { + mockBridgeInstance = { + onmessage: undefined, + onopenlink: undefined, + onloggingmessage: undefined, + oncalltool: undefined, + onlistresources: undefined, + onlistresourcetemplates: undefined, + onreadresource: undefined, + onlistprompts: undefined, + setHostContext: vi.fn(), + sendToolInputPartial: vi.fn(), + sendToolCancelled: vi.fn(), + sendToolListChanged: vi.fn(), + sendResourceListChanged: vi.fn(), + sendPromptListChanged: vi.fn(), + teardownResource: vi.fn(), + }; + return mockBridgeInstance; + }), + RESOURCE_MIME_TYPE: 'text/html', + }; +}); + +// Mock MCP Client +const mockClient = { + getServerCapabilities: vi.fn().mockReturnValue({ + tools: {}, + resources: {}, + }), +}; + +describe('', () => { + const defaultProps: AppRendererProps = { + client: mockClient as unknown as Client, + toolName: 'test-tool', + sandbox: { url: new URL('http://localhost:8081/sandbox.html') }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockBridgeInstance = null; + mockAppFrame.mockClear(); + + // Default mock implementations + vi.mocked(appHostUtils.getToolUiResourceUri).mockResolvedValue({ + uri: 'ui://test-tool', + }); + vi.mocked(appHostUtils.readToolUiResourceHtml).mockResolvedValue( + 'Test Tool UI', + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('basic rendering', () => { + it('should render AppFrame after fetching HTML', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + }); + + it('should fetch resource URI for the tool', async () => { + render(); + + await waitFor(() => { + expect(appHostUtils.getToolUiResourceUri).toHaveBeenCalledWith(mockClient, 'test-tool'); + }); + }); + + it('should read HTML from resource URI', async () => { + render(); + + await waitFor(() => { + expect(appHostUtils.readToolUiResourceHtml).toHaveBeenCalledWith(mockClient, { + uri: 'ui://test-tool', + }); + }); + }); + + it('should pass fetched HTML to AppFrame', async () => { + render(); + + await waitFor(() => { + const appFrame = screen.getByTestId('app-frame'); + expect(appFrame).toHaveAttribute('data-html', 'Test Tool UI'); + }); + }); + + it('should use provided toolResourceUri instead of fetching', async () => { + const props: AppRendererProps = { + ...defaultProps, + toolResourceUri: 'ui://custom-uri', + }; + + render(); + + await waitFor(() => { + expect(appHostUtils.getToolUiResourceUri).not.toHaveBeenCalled(); + expect(appHostUtils.readToolUiResourceHtml).toHaveBeenCalledWith(mockClient, { + uri: 'ui://custom-uri', + }); + }); + }); + + it('should use provided HTML directly without fetching', async () => { + const props: AppRendererProps = { + ...defaultProps, + html: 'Pre-fetched HTML', + }; + + render(); + + await waitFor(() => { + expect(appHostUtils.getToolUiResourceUri).not.toHaveBeenCalled(); + expect(appHostUtils.readToolUiResourceHtml).not.toHaveBeenCalled(); + expect(screen.getByTestId('app-frame')).toHaveAttribute( + 'data-html', + 'Pre-fetched HTML', + ); + }); + }); + + it('should pass sandbox config to AppFrame', async () => { + render(); + + await waitFor(() => { + const appFrame = screen.getByTestId('app-frame'); + expect(appFrame).toHaveAttribute('data-sandbox-url', 'http://localhost:8081/sandbox.html'); + }); + }); + + it('should pass toolInput to AppFrame', async () => { + const toolInput = { query: 'test query' }; + const props: AppRendererProps = { + ...defaultProps, + toolInput, + }; + + render(); + + await waitFor(() => { + const toolInputEl = screen.getByTestId('tool-input'); + expect(toolInputEl).toHaveTextContent(JSON.stringify(toolInput)); + }); + }); + + it('should pass toolResult to AppFrame', async () => { + const toolResult = { content: [{ type: 'text' as const, text: 'result' }] }; + const props: AppRendererProps = { + ...defaultProps, + toolResult, + }; + + render(); + + await waitFor(() => { + const toolResultEl = screen.getByTestId('tool-result'); + expect(toolResultEl).toHaveTextContent(JSON.stringify(toolResult)); + }); + }); + + + it('should display error when tool has no UI resource', async () => { + vi.mocked(appHostUtils.getToolUiResourceUri).mockResolvedValue(null); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Error:/)).toBeInTheDocument(); + expect(screen.getByText(/has no UI resource/)).toBeInTheDocument(); + }); + }); + + it('should call onError when resource fetch fails', async () => { + const onError = vi.fn(); + const error = new Error('Fetch failed'); + vi.mocked(appHostUtils.readToolUiResourceHtml).mockRejectedValue(error); + + render(); + + await waitFor(() => { + expect(onError).toHaveBeenCalledWith(error); + }); + }); + + it('should return null while loading', () => { + // Make the promise never resolve + vi.mocked(appHostUtils.getToolUiResourceUri).mockReturnValue(new Promise(() => {})); + + const { container } = render(); + + // Should render nothing while loading + expect(container.firstChild).toBeNull(); + }); + }); + + describe('hostContext prop', () => { + it('should call setHostContext when hostContext is provided', async () => { + const hostContext = { theme: 'dark' as const }; + + render(); + + await waitFor(() => { + expect(mockBridgeInstance?.setHostContext).toHaveBeenCalledWith(hostContext); + }); + }); + + it('should update hostContext when prop changes', async () => { + const { rerender } = render(); + + await waitFor(() => { + expect(mockBridgeInstance?.setHostContext).toHaveBeenCalledWith({ theme: 'light' }); + }); + + rerender(); + + await waitFor(() => { + expect(mockBridgeInstance?.setHostContext).toHaveBeenCalledWith({ theme: 'dark' }); + }); + }); + }); + + describe('toolInputPartial prop', () => { + it('should call sendToolInputPartial when toolInputPartial is provided', async () => { + const toolInputPartial = { arguments: { delta: 'partial data' } }; + + render(); + + await waitFor(() => { + expect(mockBridgeInstance?.sendToolInputPartial).toHaveBeenCalledWith(toolInputPartial); + }); + }); + }); + + describe('toolCancelled prop', () => { + it('should call sendToolCancelled when toolCancelled is true', async () => { + render(); + + await waitFor(() => { + expect(mockBridgeInstance?.sendToolCancelled).toHaveBeenCalledWith({}); + }); + }); + + it('should not call sendToolCancelled when toolCancelled is false', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(mockBridgeInstance?.sendToolCancelled).not.toHaveBeenCalled(); + }); + }); + + describe('ref methods', () => { + it('should expose sendToolListChanged via ref', async () => { + const ref = React.createRef(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(ref.current).not.toBeNull(); + }); + + act(() => { + ref.current?.sendToolListChanged(); + }); + + expect(mockBridgeInstance?.sendToolListChanged).toHaveBeenCalled(); + }); + + it('should expose sendResourceListChanged via ref', async () => { + const ref = React.createRef(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(ref.current).not.toBeNull(); + }); + + act(() => { + ref.current?.sendResourceListChanged(); + }); + + expect(mockBridgeInstance?.sendResourceListChanged).toHaveBeenCalled(); + }); + + it('should expose sendPromptListChanged via ref', async () => { + const ref = React.createRef(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(ref.current).not.toBeNull(); + }); + + act(() => { + ref.current?.sendPromptListChanged(); + }); + + expect(mockBridgeInstance?.sendPromptListChanged).toHaveBeenCalled(); + }); + + it('should expose teardownResource via ref', async () => { + const ref = React.createRef(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(ref.current).not.toBeNull(); + }); + + act(() => { + ref.current?.teardownResource(); + }); + + expect(mockBridgeInstance?.teardownResource).toHaveBeenCalledWith({}); + }); + }); + + describe('MCP request handler props', () => { + it('should register onCallTool handler on AppBridge', async () => { + const onCallTool = vi.fn().mockResolvedValue({ content: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + // The handler should be registered + expect(mockBridgeInstance?.oncalltool).toBeDefined(); + }); + + it('should register onListResources handler on AppBridge', async () => { + const onListResources = vi.fn().mockResolvedValue({ resources: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(mockBridgeInstance?.onlistresources).toBeDefined(); + }); + + it('should register onListResourceTemplates handler on AppBridge', async () => { + const onListResourceTemplates = vi.fn().mockResolvedValue({ resourceTemplates: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(mockBridgeInstance?.onlistresourcetemplates).toBeDefined(); + }); + + it('should register onReadResource handler on AppBridge', async () => { + const onReadResource = vi.fn().mockResolvedValue({ contents: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(mockBridgeInstance?.onreadresource).toBeDefined(); + }); + + it('should register onListPrompts handler on AppBridge', async () => { + const onListPrompts = vi.fn().mockResolvedValue({ prompts: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(mockBridgeInstance?.onlistprompts).toBeDefined(); + }); + }); + + describe('callback props', () => { + it('should pass onSizeChanged to AppFrame', async () => { + const onSizeChanged = vi.fn(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(mockAppFrame).toHaveBeenCalledWith( + expect.objectContaining({ + onSizeChanged: expect.any(Function), + }), + ); + }); + + it('should pass onError to AppFrame', async () => { + const onError = vi.fn(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + }); + + expect(mockAppFrame).toHaveBeenCalledWith( + expect.objectContaining({ + onError, + }), + ); + }); + }); + + describe('no client', () => { + it('should work without client when html is provided', async () => { + const props: AppRendererProps = { + // client omitted - using html prop instead + toolName: 'test-tool', + sandbox: { url: new URL('http://localhost:8081/sandbox.html') }, + html: 'Static HTML', + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + expect(screen.getByTestId('app-frame')).toHaveAttribute( + 'data-html', + 'Static HTML', + ); + }); + }); + + it('should show error without client and no html', async () => { + const props: AppRendererProps = { + // client omitted, no html provided + toolName: 'test-tool', + sandbox: { url: new URL('http://localhost:8081/sandbox.html') }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByText(/Error:/)).toBeInTheDocument(); + }); + }); + + it('should work with onReadResource and toolResourceUri instead of client', async () => { + const mockReadResource = vi.fn().mockResolvedValue({ + contents: [ + { + uri: 'ui://test/tool', + mimeType: 'text/html', + text: 'Custom fetched HTML', + }, + ], + }); + + const props: AppRendererProps = { + // client omitted - using onReadResource + toolResourceUri instead + toolName: 'test-tool', + sandbox: { url: new URL('http://localhost:8081/sandbox.html') }, + toolResourceUri: 'ui://test/tool', + onReadResource: mockReadResource, + }; + + render(); + + await waitFor(() => { + expect(mockReadResource).toHaveBeenCalledWith( + { uri: 'ui://test/tool' }, + expect.anything(), + ); + expect(screen.getByTestId('app-frame')).toBeInTheDocument(); + expect(screen.getByTestId('app-frame')).toHaveAttribute( + 'data-html', + 'Custom fetched HTML', + ); + }); + }); + }); +}); diff --git a/sdks/typescript/client/src/index.ts b/sdks/typescript/client/src/index.ts index 49734e7d..0fcd4632 100644 --- a/sdks/typescript/client/src/index.ts +++ b/sdks/typescript/client/src/index.ts @@ -2,6 +2,35 @@ export { UIResourceRenderer } from './components/UIResourceRenderer'; export { getUIResourceMetadata, getResourceMetadata } from './utils/metadataUtils'; export { isUIResource } from './utils/isUIResource'; +// Client capabilities for UI extension support (SEP-1724) +export { + type ClientCapabilitiesWithExtensions, + UI_EXTENSION_NAME, + UI_EXTENSION_CONFIG, + UI_EXTENSION_CAPABILITIES, +} from './capabilities'; + +// MCP Apps renderers +export { + AppRenderer, + type AppRendererProps, + type AppRendererHandle, + type RequestHandlerExtra, +} from './components/AppRenderer'; +export { + AppFrame, + type AppFrameProps, + type SandboxConfig, + type AppInfo, +} from './components/AppFrame'; + +// Re-export AppBridge, transport, and common types for advanced use cases +export { + AppBridge, + PostMessageTransport, + type McpUiHostContext, +} from '@modelcontextprotocol/ext-apps/app-bridge'; + // The types needed to create a custom component library export type { ComponentLibrary, diff --git a/sdks/typescript/client/src/utils/app-host-utils.ts b/sdks/typescript/client/src/utils/app-host-utils.ts new file mode 100644 index 00000000..82643b9a --- /dev/null +++ b/sdks/typescript/client/src/utils/app-host-utils.ts @@ -0,0 +1,130 @@ +import { + SANDBOX_PROXY_READY_METHOD, + getToolUiResourceUri as _getToolUiResourceUri, + RESOURCE_MIME_TYPE, +} from '@modelcontextprotocol/ext-apps/app-bridge'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { Tool } from '@modelcontextprotocol/sdk/types.js'; +const DEFAULT_SANDBOX_TIMEOUT_MS = 10000; + +export async function setupSandboxProxyIframe(sandboxProxyUrl: URL): Promise<{ + iframe: HTMLIFrameElement; + onReady: Promise; +}> { + const iframe = document.createElement('iframe'); + iframe.style.width = '100%'; + iframe.style.height = '600px'; + iframe.style.border = 'none'; + iframe.style.backgroundColor = 'transparent'; + iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms'); + + const onReady = new Promise((resolve, reject) => { + let settled = false; + + const cleanup = () => { + window.removeEventListener('message', messageListener); + iframe.removeEventListener('error', errorListener); + }; + + const timeoutId = setTimeout(() => { + if (!settled) { + settled = true; + cleanup(); + reject(new Error('Timed out waiting for sandbox proxy iframe to be ready')); + } + }, DEFAULT_SANDBOX_TIMEOUT_MS); + + const messageListener = (event: MessageEvent) => { + if (event.source === iframe.contentWindow) { + if ( + event.data && + event.data.method === SANDBOX_PROXY_READY_METHOD + ) { + if (!settled) { + settled = true; + clearTimeout(timeoutId); + cleanup(); + resolve(); + } + } + } + }; + + const errorListener = () => { + if (!settled) { + settled = true; + clearTimeout(timeoutId); + cleanup(); + reject(new Error('Failed to load sandbox proxy iframe')); + } + }; + + window.addEventListener('message', messageListener); + iframe.addEventListener('error', errorListener); + }); + + iframe.src = sandboxProxyUrl.href; + + return { iframe, onReady }; +} + +export type ToolUiResourceInfo = { + uri: string; +}; + +export async function getToolUiResourceUri( + client: Client, + toolName: string, +): Promise { + let tool: Tool | undefined; + let cursor: string | undefined = undefined; + do { + const toolsResult = await client.listTools({ cursor }); + tool = toolsResult.tools.find((t) => t.name === toolName); + cursor = toolsResult.nextCursor; + } while (!tool && cursor); + if (!tool) { + throw new Error(`tool ${toolName} not found`); + } + if (!tool._meta) { + return null; + } + + const uri = _getToolUiResourceUri(tool); + if (!uri) { + return null; + } + if (!uri.startsWith('ui://')) { + throw new Error(`tool ${toolName} has unsupported output template URI: ${uri}`); + } + return { uri }; +} + +export async function readToolUiResourceHtml( + client: Client, + opts: { + uri: string; + }, +): Promise { + const resource = await client.readResource({ uri: opts.uri }); + + if (!resource) { + throw new Error('UI resource not found: ' + opts.uri); + } + if (resource.contents.length !== 1) { + throw new Error('Unsupported UI resource content length: ' + resource.contents.length); + } + const content = resource.contents[0]; + let html: string; + const isHtml = (t?: string) => t === RESOURCE_MIME_TYPE; + + if ('text' in content && typeof content.text === 'string' && isHtml(content.mimeType)) { + html = content.text; + } else if ('blob' in content && typeof content.blob === 'string' && isHtml(content.mimeType)) { + html = atob(content.blob); + } else { + throw new Error('Unsupported UI resource content format: ' + JSON.stringify(content)); + } + + return html; +} diff --git a/sdks/typescript/server/package.json b/sdks/typescript/server/package.json index 76b1b62e..b6f9f9e3 100644 --- a/sdks/typescript/server/package.json +++ b/sdks/typescript/server/package.json @@ -73,7 +73,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^0.2.2", + "@modelcontextprotocol/ext-apps": "^0.3.1", "@modelcontextprotocol/sdk": "^1.25.1" } } diff --git a/sdks/typescript/server/src/adapters/mcp-apps/adapter-runtime.ts b/sdks/typescript/server/src/adapters/mcp-apps/adapter-runtime.ts index 8d4e6ef3..d6341d0e 100644 --- a/sdks/typescript/server/src/adapters/mcp-apps/adapter-runtime.ts +++ b/sdks/typescript/server/src/adapters/mcp-apps/adapter-runtime.ts @@ -195,8 +195,9 @@ class McpAppsAdapter { | 'pip' | 'fullscreen'; if (this.hostContext.locale) this.currentRenderData.locale = this.hostContext.locale; - if (this.hostContext.viewport?.maxHeight) - this.currentRenderData.maxHeight = this.hostContext.viewport.maxHeight; + const dims = this.hostContext.containerDimensions; + if (dims && 'maxHeight' in dims && dims.maxHeight !== undefined) + this.currentRenderData.maxHeight = dims.maxHeight; } // Send initial render data to MCP-UI app @@ -367,16 +368,18 @@ class McpAppsAdapter { break; // MCP Apps SEP: Host context changed (theme, viewport, etc.) - case METHODS.HOST_CONTEXT_CHANGED: + case METHODS.HOST_CONTEXT_CHANGED: { // Update stored render data with context if (data.params?.theme) this.currentRenderData.theme = data.params.theme; if (data.params?.displayMode) this.currentRenderData.displayMode = data.params.displayMode; if (data.params?.locale) this.currentRenderData.locale = data.params.locale; - if (data.params?.viewport?.maxHeight) - this.currentRenderData.maxHeight = data.params.viewport.maxHeight; + const contextDims = data.params?.containerDimensions; + if (contextDims && 'maxHeight' in contextDims && contextDims.maxHeight !== undefined) + this.currentRenderData.maxHeight = contextDims.maxHeight; this.sendRenderData(); break; + } // MCP Apps SEP: Size change notification from host case METHODS.SIZE_CHANGED: diff --git a/vitest.config.ts b/vitest.config.ts index 3054a43a..5715144e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ setupFiles: './vitest.setup.ts', globalSetup: './vitest.global-setup.ts', // include: ['sdks/typescript/*/src/**/__tests__/**/*.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**', '**/.pnpm-store/**'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'],