diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..d7ad47b7 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,29 @@ +name: Deploy Documentation + +on: + workflow_dispatch: + # Future: Uncomment to auto-deploy on release + # release: + # types: [published] + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - run: npm install + - run: npm run build + - run: npm run docs + - uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs + publish_branch: gh-pages + force_orphan: true diff --git a/.gitignore b/.gitignore index 32da72cf..3a7c2891 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ node_modules/ package-lock.json yarn.lock .vscode/ +docs/api/ +tmp/ diff --git a/README.md b/README.md index 67160dbf..bcc3252a 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,37 @@ # @modelcontextprotocol/ext-apps -This repo contains the SDK and [specification](./specification/draft/apps.mdx) for MCP Apps Extension ([SEP-1865](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865)). +[![API Documentation](https://img.shields.io/badge/docs-API%20Reference-blue)](https://modelcontextprotocol.github.io/ext-apps/) -MCP Apps are proposed standard inspired by [MCP-UI](https://mcpui.dev/) and [OpenAI's Apps SDK](https://developers.openai.com/apps-sdk/) to allow MCP Servers to display interactive UI elements in conversational MCP clients / chatbots. +This repo contains the SDK and [specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) for MCP Apps Extension ([SEP-1865](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865)). -This repo provides: +MCP Apps are a proposed standard inspired by [MCP-UI](https://mcpui.dev/) and [OpenAI's Apps SDK](https://developers.openai.com/apps-sdk/) to allow MCP Servers to display interactive UI elements in conversational MCP clients / chatbots. -- [specification/draft/apps.mdx](./specification/draft/apps.mdx): The Draft Extension Specification. It's still... in flux! Feedback welcome! (also see discussions in [SEP-1865](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865)). +## Overview -- [types.ts](./src/types.ts): Types of JSON-RPC messages used to communicate between Apps & their host - - Note that MCP Apps also use some standard MCP messages (e.g. `tools/call` for the App to trigger actions on its originating Server - these calls are proxied through the Host), but these types are the additional messages defined by the extension -- [examples/simple-example](./examples/simple-server): Example Server + Apps - - [server.ts](./examples/simple-server/server.ts): MCP server with three tools that declare UI resources of Apps to be show in the chat when called - - [ui-react.tsx](./examples/simple-server/src/ui-react.tsx): React App returned by the `create-ui-react` tool shows how to use the `useApp` hook to register MCP callbacks - - [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) +This SDK serves two audiences: -- [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 +### App Developers -- [message-transport](./src/message-transport.ts): `PostMessageTransport` class that uses `postMessage` to exchange JSON-RPC messages between windows / iframes +Build interactive UIs that run inside MCP-enabled chat clients. -- [app.ts](./src/app.ts): `App` class used by an App to talk to its host +- **SDK for Apps**: `@modelcontextprotocol/ext-apps` — [API Docs](https://modelcontextprotocol.github.io/ext-apps/modules/_modelcontextprotocol_ext_apps.html) +- **React hooks**: `@modelcontextprotocol/ext-apps/react` — [API Docs](https://modelcontextprotocol.github.io/ext-apps/modules/_modelcontextprotocol_ext_apps_react.html) -- [app-bridge.ts](./src/app-bridge.ts): `AppBridge` class used by the host to talk to a single App +### Host Developers -- _Soon_: more examples! +Embed and communicate with MCP Apps in your chat application. -What this repo does NOT provide: +- **SDK for Hosts**: `@modelcontextprotocol/ext-apps/app-bridge` — [API Docs](https://modelcontextprotocol.github.io/ext-apps/modules/_modelcontextprotocol_ext_apps_app_bridge.html) -- 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. +There's no _supported_ host implementation in this repo (beyond the [examples/simple-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-host) example). -## Using the SDK +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. -### Run examples +## Installation -Run the examples in this repo end-to-end: +This repo is in flux and isn't published to npm yet. When it is, it will use the `@modelcontextprotocol/ext-apps` package. -``` -npm i -npm start -``` - -open http://localhost:8080/ - -> [!NOTE] -> Please bear with us while we add more examples! - -### Using the SDK in your project - -This repo is in flux and isn't published to npm yet: when it is, it will use the `@modelcontextprotocol/ext-apps` package. - -In the meantime you can depend on the SDK library in a Node.js project by installing it w/ its git URL: +In the meantime you can depend on the SDK library in a Node.js project by installing it with its git URL: ```bash npm install -S git+https://github.com/modelcontextprotocol/ext-apps.git @@ -63,15 +41,33 @@ Your `package.json` will then look like: ```json { - ... "dependencies": { - ... "@modelcontextprotocol/ext-apps": "git+https://github.com/modelcontextprotocol/ext-apps.git" } } ``` -> [!NOTE] +> [!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`. + +## Examples + +- [examples/simple-server](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-server) — Example MCP server with tools that return UI Apps +- [examples/simple-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/simple-host) — Bare-bones example of hosting MCP Apps + +To run the examples end-to-end: + +``` +npm i +npm start +``` + +Then open http://localhost:8080/ + +## Resources + +- [API Documentation](https://modelcontextprotocol.github.io/ext-apps/) +- [Draft Specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) +- [SEP-1865 Discussion](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865) diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 00000000..609d10e8 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,12 @@ + + + + + + Redirecting... + + +

Redirecting to API Documentation...

+ + + diff --git a/package.json b/package.json index 36873758..b24b6a34 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "start": "NODE_ENV=development npm run build && concurrently 'npm run start:example-host' 'npm run start:example-mcp-server'", "build": "bun build.bun.ts", "prepare": "npm run build && husky", + "docs": "typedoc", + "docs:watch": "typedoc --watch", "prettier:base-cmd": "prettier -u --ignore-path ./.gitignore --ignore-path ./.prettierignore", "prettier": "yarn prettier:base-cmd \"$(pwd)/**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --check", "prettier:fix": "yarn prettier:base-cmd \"$(pwd)/**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --write --list-different" @@ -45,10 +47,11 @@ "express": "^5.1.0", "husky": "^9.1.7", "prettier": "^3.6.2", + "typedoc": "^0.28.14", "typescript": "^5.9.3" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.22.0", + "@modelcontextprotocol/sdk": "^1.23.0", "bun": "^1.3.2", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 90756a7a..8e682f3c 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -1,5 +1,5 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { ZodLiteral, ZodObject } from "zod"; +import { ZodLiteral, ZodObject } from "zod/v4"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { @@ -59,18 +59,114 @@ import { export * from "./types"; export { PostMessageTransport } from "./message-transport"; -type HostOptions = ProtocolOptions; +/** + * Options for configuring AppBridge behavior. + * + * @see ProtocolOptions from @modelcontextprotocol/sdk for available options + */ +export type HostOptions = ProtocolOptions; +/** + * Protocol versions supported by this AppBridge implementation. + * + * The SDK automatically handles version negotiation during initialization. + * Hosts don't need to manage protocol versions manually. + */ export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION]; +/** + * Extra metadata passed to request handlers. + * + * This type represents the additional context provided by the Protocol class + * when handling requests, including abort signals and session information. + * It is extracted from the MCP SDK's request handler signature. + * + * @internal + */ type RequestHandlerExtra = Parameters< Parameters[1] >[1]; +/** + * Host-side bridge for communicating with a single Guest UI (App). + * + * AppBridge extends the MCP SDK's Protocol class and acts as a proxy between + * the host application and a Guest UI running in an iframe. It automatically + * forwards MCP server capabilities (tools, resources, prompts) to the Guest UI + * and handles the initialization handshake. + * + * ## Architecture + * + * **Guest UI ↔ AppBridge ↔ Host ↔ MCP Server** + * + * The bridge proxies requests from the Guest UI to the MCP server and forwards + * responses back. It also sends host-initiated notifications like tool input + * and results to the Guest UI. + * + * ## Lifecycle + * + * 1. **Create**: Instantiate AppBridge with MCP client and capabilities + * 2. **Connect**: Call `connect()` with transport to establish communication + * 3. **Wait for init**: Guest UI sends initialize request, bridge responds + * 4. **Send data**: Call `sendToolInput()`, `sendToolResult()`, etc. + * 5. **Teardown**: Call `sendResourceTeardown()` before unmounting iframe + * + * @example Basic usage + * ```typescript + * import { AppBridge, PostMessageTransport } from '@modelcontextprotocol/ext-apps/app-bridge'; + * import { Client } from '@modelcontextprotocol/sdk/client/index.js'; + * + * // Create MCP client for the server + * const client = new Client({ + * name: "MyHost", + * version: "1.0.0", + * }); + * await client.connect(serverTransport); + * + * // Create bridge for the Guest UI + * const bridge = new AppBridge( + * client, + * { name: "MyHost", version: "1.0.0" }, + * { openLinks: {}, serverTools: {}, logging: {} } + * ); + * + * // Set up iframe and connect + * const iframe = document.getElementById('app') as HTMLIFrameElement; + * const transport = new PostMessageTransport( + * iframe.contentWindow!, + * iframe.contentWindow!, + * ); + * + * bridge.oninitialized = () => { + * console.log("Guest UI initialized"); + * // Now safe to send tool input + * bridge.sendToolInput({ arguments: { location: "NYC" } }); + * }; + * + * await bridge.connect(transport); + * ``` + */ export class AppBridge extends Protocol { private _appCapabilities?: McpUiAppCapabilities; private _appInfo?: Implementation; + /** + * Create a new AppBridge instance. + * + * @param _client - MCP client connected to the server (for proxying requests) + * @param _hostInfo - Host application identification (name and version) + * @param _capabilities - Features and capabilities the host supports + * @param options - Configuration options (inherited from Protocol) + * + * @example + * ```typescript + * const bridge = new AppBridge( + * mcpClient, + * { name: "MyHost", version: "1.0.0" }, + * { openLinks: {}, serverTools: {}, logging: {} } + * ); + * ``` + */ constructor( private _client: Client, private _hostInfo: Implementation, @@ -89,15 +185,100 @@ export class AppBridge extends Protocol { }); } + /** + * Get the Guest UI's capabilities discovered during initialization. + * + * Returns the capabilities that the Guest UI advertised during its + * initialization request. Returns `undefined` if called before + * initialization completes. + * + * @returns Guest UI capabilities, or `undefined` if not yet initialized + * + * @example Check Guest UI capabilities after initialization + * ```typescript + * bridge.oninitialized = () => { + * const caps = bridge.getAppCapabilities(); + * if (caps?.tools) { + * console.log("Guest UI provides tools"); + * } + * }; + * ``` + * + * @see {@link McpUiAppCapabilities} for the capabilities structure + */ getAppCapabilities(): McpUiAppCapabilities | undefined { return this._appCapabilities; } + + /** + * Get the Guest UI's implementation info discovered during initialization. + * + * Returns the Guest UI's name and version as provided in its initialization + * request. Returns `undefined` if called before initialization completes. + * + * @returns Guest UI implementation info, or `undefined` if not yet initialized + * + * @example Log Guest UI information after initialization + * ```typescript + * bridge.oninitialized = () => { + * const appInfo = bridge.getAppVersion(); + * if (appInfo) { + * console.log(`Guest UI: ${appInfo.name} v${appInfo.version}`); + * } + * }; + * ``` + */ getAppVersion(): Implementation | undefined { return this._appInfo; } + /** + * Optional handler for ping requests from the Guest UI. + * + * The Guest UI can send standard MCP `ping` requests to verify the connection + * is alive. The AppBridge automatically responds with an empty object, but this + * handler allows the host to observe or log ping activity. + * + * Unlike the other handlers which use setters, this is a direct property + * assignment. It is optional; if not set, pings are still handled automatically. + * + * @param params - Empty params object from the ping request + * @param extra - Request metadata (abort signal, session info) + * + * @example + * ```typescript + * bridge.onping = (params, extra) => { + * console.log("Received ping from Guest UI"); + * }; + * ``` + */ onping?: (params: PingRequest["params"], extra: RequestHandlerExtra) => void; + /** + * Register a handler for size change notifications from the Guest UI. + * + * The Guest UI sends `ui/notifications/size-change` when its rendered content + * size changes, typically via ResizeObserver. Set this callback to dynamically + * adjust the iframe container dimensions based on the Guest UI's content. + * + * Note: This is for Guest UI → Host communication. To notify the Guest UI of + * host viewport changes, use {@link app.App.sendSizeChange}. + * + * @example + * ```typescript + * bridge.onsizechange = ({ width, height }) => { + * if (width != null) { + * iframe.style.width = `${width}px`; + * } + * if (height != null) { + * iframe.style.height = `${height}px`; + * } + * }; + * ``` + * + * @see {@link McpUiSizeChangeNotification} for the notification type + * @see {@link app.App.sendSizeChange} for Host → Guest UI size notifications + */ set onsizechange( callback: (params: McpUiSizeChangeNotification["params"]) => void, ) { @@ -105,6 +286,37 @@ export class AppBridge extends Protocol { callback(n.params), ); } + + /** + * Register a handler for sandbox proxy ready notifications. + * + * This is an internal callback used by web-based hosts implementing the + * double-iframe sandbox architecture. The sandbox proxy sends + * `ui/notifications/sandbox-proxy-ready` after it loads and is ready to receive + * HTML content. + * + * When this fires, the host should call {@link sendSandboxResourceReady} with + * the HTML content to load into the inner sandboxed iframe. + * + * @example + * ```typescript + * bridge.onsandboxready = async () => { + * const resource = await mcpClient.request( + * { method: "resources/read", params: { uri: "ui://my-app" } }, + * ReadResourceResultSchema + * ); + * + * bridge.sendSandboxResourceReady({ + * html: resource.contents[0].text, + * sandbox: "allow-scripts" + * }); + * }; + * ``` + * + * @internal + * @see {@link McpUiSandboxProxyReadyNotification} for the notification type + * @see {@link sendSandboxResourceReady} for sending content to the sandbox + */ set onsandboxready( callback: (params: McpUiSandboxProxyReadyNotification["params"]) => void, ) { @@ -112,6 +324,24 @@ export class AppBridge extends Protocol { callback(n.params), ); } + + /** + * Called when the Guest UI completes initialization. + * + * Set this callback to be notified when the Guest UI has finished its + * initialization handshake and is ready to receive tool input and other data. + * + * @example + * ```typescript + * bridge.oninitialized = () => { + * console.log("Guest UI ready"); + * bridge.sendToolInput({ arguments: toolArgs }); + * }; + * ``` + * + * @see {@link McpUiInitializedNotification} for the notification type + * @see {@link sendToolInput} for sending tool arguments to the Guest UI + */ set oninitialized( callback: (params: McpUiInitializedNotification["params"]) => void, ) { @@ -119,6 +349,41 @@ export class AppBridge extends Protocol { callback(n.params), ); } + + /** + * Register a handler for message requests from the Guest UI. + * + * The Guest UI sends `ui/message` requests when it wants to add a message to + * the host's chat interface. This enables interactive apps to communicate with + * the user through the conversation thread. + * + * The handler should process the message (add it to the chat) and return a + * result indicating success or failure. For security, the host should NOT + * return conversation content or follow-up results to prevent information + * leakage. + * + * @param callback - Handler that receives message params and returns a result + * - params.role - Message role (currently only "user" is supported) + * - params.content - Message content blocks (text, image, etc.) + * - extra - Request metadata (abort signal, session info) + * - Returns: Promise with optional isError flag + * + * @example + * ```typescript + * bridge.onmessage = async ({ role, content }, extra) => { + * try { + * await chatManager.addMessage({ role, content, source: "app" }); + * return {}; // Success + * } catch (error) { + * console.error("Failed to add message:", error); + * return { isError: true }; + * } + * }; + * ``` + * + * @see {@link McpUiMessageRequest} for the request type + * @see {@link McpUiMessageResult} for the result type + */ set onmessage( callback: ( params: McpUiMessageRequest["params"], @@ -132,6 +397,50 @@ export class AppBridge extends Protocol { }, ); } + + /** + * Register a handler for external link requests from the Guest UI. + * + * The Guest UI sends `ui/open-link` requests when it wants to open an external + * URL in the host's default browser. The handler should validate the URL and + * open it according to the host's security policy and user preferences. + * + * The host MAY: + * - Show a confirmation dialog before opening + * - Block URLs based on a security policy or allowlist + * - Log the request for audit purposes + * - Reject the request entirely + * + * @param callback - Handler that receives URL params and returns a result + * - params.url - URL to open in the host's browser + * - extra - Request metadata (abort signal, session info) + * - Returns: Promise with optional isError flag + * + * @example + * ```typescript + * bridge.onopenlink = async ({ url }, extra) => { + * if (!isAllowedDomain(url)) { + * console.warn("Blocked external link:", url); + * return { isError: true }; + * } + * + * const confirmed = await showDialog({ + * message: `Open external link?\n${url}`, + * buttons: ["Open", "Cancel"] + * }); + * + * if (confirmed) { + * window.open(url, "_blank", "noopener,noreferrer"); + * return {}; + * } + * + * return { isError: true }; + * }; + * ``` + * + * @see {@link McpUiOpenLinkRequest} for the request type + * @see {@link McpUiOpenLinkResult} for the result type + */ set onopenlink( callback: ( params: McpUiOpenLinkRequest["params"], @@ -145,6 +454,34 @@ export class AppBridge extends Protocol { }, ); } + + /** + * Register a handler for logging messages from the Guest UI. + * + * The Guest UI sends standard MCP `notifications/message` (logging) notifications + * to report debugging information, errors, warnings, and other telemetry to the + * host. The host can display these in a console, log them to a file, or send + * them to a monitoring service. + * + * This uses the standard MCP logging notification format, not a UI-specific + * message type. + * + * @param callback - Handler that receives logging params + * - params.level - Log level: "debug" | "info" | "notice" | "warning" | "error" | "critical" | "alert" | "emergency" + * - params.logger - Optional logger name/identifier + * - params.data - Log message and optional structured data + * + * @example + * ```typescript + * bridge.onloggingmessage = ({ level, logger, data }) => { + * const prefix = logger ? `[${logger}]` : "[Guest UI]"; + * console[level === "error" ? "error" : "log"]( + * `${prefix} ${level.toUpperCase()}:`, + * data + * ); + * }; + * ``` + */ set onloggingmessage( callback: (params: LoggingMessageNotification["params"]) => void, ) { @@ -156,20 +493,45 @@ export class AppBridge extends Protocol { ); } + /** + * Verify that the guest supports the capability required for the given request method. + * @internal + */ assertCapabilityForMethod(method: Request["method"]): void { // TODO } + + /** + * Verify that a request handler is registered and supported for the given method. + * @internal + */ assertRequestHandlerCapability(method: Request["method"]): void { // TODO } + + /** + * Verify that the host supports the capability required for the given notification method. + * @internal + */ assertNotificationCapability(method: Notification["method"]): void { // TODO } + /** + * Get the host capabilities passed to the constructor. + * + * @returns Host capabilities object + * + * @see {@link McpUiHostCapabilities} for the capabilities structure + */ getCapabilities(): McpUiHostCapabilities { return this._capabilities; } + /** + * Handle the ui/initialize request from the guest. + * @internal + */ private async _oninitialize( request: McpUiInitializeRequest, ): Promise { @@ -194,12 +556,59 @@ export class AppBridge extends Protocol { }; } + /** + * Send complete tool arguments to the Guest UI. + * + * The host MUST send this notification after the Guest UI completes initialization + * (after {@link oninitialized} callback fires) and complete tool arguments become available. + * This notification is sent exactly once and is required before {@link sendToolResult}. + * + * @param params - Complete tool call arguments + * + * @example + * ```typescript + * bridge.oninitialized = () => { + * bridge.sendToolInput({ + * arguments: { location: "New York", units: "metric" } + * }); + * }; + * ``` + * + * @see {@link McpUiToolInputNotification} for the notification type + * @see {@link oninitialized} for the initialization callback + * @see {@link sendToolResult} for sending results after execution + */ sendToolInput(params: McpUiToolInputNotification["params"]) { return this.notification({ method: "ui/notifications/tool-input", params, }); } + + /** + * Send tool execution result to the Guest UI. + * + * The host MUST send this notification when tool execution completes successfully, + * provided the UI is still displayed. If the UI was closed before execution + * completes, the host MAY skip this notification. This must be sent after + * {@link sendToolInput}. + * + * @param params - Standard MCP tool execution result + * + * @example + * ```typescript + * import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; + * + * const result = await mcpClient.request( + * { method: "tools/call", params: { name: "get_weather", arguments: args } }, + * CallToolResultSchema + * ); + * bridge.sendToolResult(result); + * ``` + * + * @see {@link McpUiToolResultNotification} for the notification type + * @see {@link sendToolInput} for sending tool arguments before results + */ sendToolResult(params: McpUiToolResultNotification["params"]) { return this.notification({ method: "ui/notifications/tool-result", @@ -207,6 +616,21 @@ export class AppBridge extends Protocol { }); } + /** + * Send HTML resource to the sandbox proxy for secure loading. + * + * This is an internal method used by web-based hosts implementing the + * double-iframe sandbox architecture. After the sandbox proxy signals readiness + * via `ui/notifications/sandbox-proxy-ready`, the host sends this notification + * with the HTML content to load. + * + * @param params - HTML content and sandbox configuration: + * - `html`: The HTML content to load into the sandboxed iframe + * - `sandbox`: Optional sandbox attribute value (e.g., "allow-scripts") + * + * @internal + * @see {@link onsandboxready} for handling the sandbox proxy ready notification + */ sendSandboxResourceReady( params: McpUiSandboxResourceReadyNotification["params"], ) { @@ -216,6 +640,30 @@ export class AppBridge extends Protocol { }); } + /** + * Request graceful shutdown of the Guest UI. + * + * The host MUST send this request before tearing down the UI resource (before + * unmounting the iframe). This gives the Guest UI an opportunity to save state, + * cancel pending operations, or show confirmation dialogs. + * + * The host SHOULD wait for the response before unmounting to prevent data loss. + * + * @param params - Empty params object + * @param options - Request options (timeout, etc.) + * @returns Promise resolving when Guest UI confirms readiness for teardown + * + * @example + * ```typescript + * try { + * await bridge.sendResourceTeardown({}); + * // Guest UI is ready, safe to unmount iframe + * iframe.remove(); + * } catch (error) { + * console.error("Teardown failed:", error); + * } + * ``` + */ sendResourceTeardown( params: McpUiResourceTeardownRequest["params"], options?: RequestOptions, @@ -253,6 +701,44 @@ export class AppBridge extends Protocol { await this._client.notification(notification); }); } + + /** + * Connect to the Guest UI via transport and set up message forwarding. + * + * This method establishes the transport connection and automatically sets up + * request/notification forwarding based on the MCP server's capabilities. + * It proxies the following server capabilities to the Guest UI: + * - Tools (tools/call, tools/list_changed) + * - Resources (resources/list, resources/read, resources/templates/list, resources/list_changed) + * - Prompts (prompts/list, prompts/list_changed) + * + * After calling connect, wait for the `oninitialized` callback before sending + * tool input and other data to the Guest UI. + * + * @param transport - Transport layer (typically PostMessageTransport) + * @returns Promise resolving when connection is established + * + * @throws {Error} If server capabilities are not available. This occurs when + * connect() is called before the MCP client has completed its initialization + * with the server. Ensure `await client.connect()` completes before calling + * `bridge.connect()`. + * + * @example + * ```typescript + * const bridge = new AppBridge(mcpClient, hostInfo, capabilities); + * const transport = new PostMessageTransport( + * iframe.contentWindow!, + * iframe.contentWindow!, + * ); + * + * bridge.oninitialized = () => { + * console.log("Guest UI ready"); + * bridge.sendToolInput({ arguments: toolArgs }); + * }; + * + * await bridge.connect(transport); + * ``` + */ async connect(transport: Transport) { // Forward core available MCP features const serverCapabilities = this._client.getServerCapabilities(); diff --git a/src/app.ts b/src/app.ts index a57bc590..137c31fe 100644 --- a/src/app.ts +++ b/src/app.ts @@ -44,9 +44,55 @@ import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; export { PostMessageTransport } from "./message-transport.js"; export * from "./types"; +/** + * Metadata key for associating a resource URI with a tool call. + * + * MCP servers include this key in tool call result metadata to indicate which + * UI resource should be displayed for the tool. When hosts receive a tool result + * containing this metadata, they resolve and render the corresponding App. + * + * **Note**: This constant is provided for reference. MCP servers set this metadata + * in their tool handlers; App developers typically don't need to use it directly. + * + * @example How MCP servers use this key (server-side, not in Apps) + * ```typescript + * // In an MCP server's tool handler: + * return { + * content: [{ type: "text", text: "Result" }], + * _meta: { + * [RESOURCE_URI_META_KEY]: "ui://weather/forecast" + * } + * }; + * ``` + * + * @example How hosts check for this metadata (host-side) + * ```typescript + * const result = await mcpClient.callTool({ name: "weather", arguments: {} }); + * const uiUri = result._meta?.[RESOURCE_URI_META_KEY]; + * if (uiUri) { + * // Load and display the UI resource + * } + * ``` + */ export const RESOURCE_URI_META_KEY = "ui/resourceUri"; +/** + * Options for configuring App behavior. + * + * Extends ProtocolOptions from the MCP SDK with App-specific configuration. + * + * @see ProtocolOptions from @modelcontextprotocol/sdk for inherited options + */ type AppOptions = ProtocolOptions & { + /** + * Automatically report size changes to the host using ResizeObserver. + * + * When enabled, the App monitors `document.body` and `document.documentElement` + * for size changes and automatically sends `ui/notifications/size-change` + * notifications to the host. + * + * @default true + */ autoResize?: boolean; }; @@ -54,10 +100,104 @@ type RequestHandlerExtra = Parameters< Parameters[1] >[1]; +/** + * Main class for MCP Apps to communicate with their host. + * + * The App class provides a framework-agnostic way to build interactive MCP Apps + * that run inside host applications. It extends the MCP SDK's Protocol class and + * handles the connection lifecycle, initialization handshake, and bidirectional + * communication with the host. + * + * ## Architecture + * + * Guest UIs (Apps) act as MCP clients connecting to the host via {@link PostMessageTransport}. + * The host proxies requests to the actual MCP server and forwards + * responses back to the App. + * + * ## Lifecycle + * + * 1. **Create**: Instantiate App with info and capabilities + * 2. **Connect**: Call `connect()` to establish transport and perform handshake + * 3. **Interactive**: Send requests, receive notifications, call tools + * 4. **Cleanup**: Host sends teardown request before unmounting + * + * ## Inherited Methods + * + * As a subclass of Protocol, App inherits key methods for handling communication: + * - `setRequestHandler()` - Register handlers for requests from host + * - `setNotificationHandler()` - Register handlers for notifications from host + * + * @see Protocol from @modelcontextprotocol/sdk for all inherited methods + * + * ## Notification Setters + * + * For common notifications, the App class provides convenient setter properties + * that simplify handler registration: + * - `ontoolinput` - Complete tool arguments from host + * - `ontoolinputpartial` - Streaming partial tool arguments + * - `ontoolresult` - Tool execution results + * - `onhostcontextchanged` - Host context changes (theme, viewport, etc.) + * + * These setters are convenience wrappers around `setNotificationHandler()`. + * Both patterns work; use whichever fits your coding style better. + * + * @example Basic usage with PostMessageTransport + * ```typescript + * import { + * App, + * PostMessageTransport, + * McpUiToolInputNotificationSchema + * } from '@modelcontextprotocol/ext-apps'; + * + * const app = new App( + * { name: "WeatherApp", version: "1.0.0" }, + * {} // capabilities + * ); + * + * // Register notification handler using setter (simpler) + * app.ontoolinput = (params) => { + * console.log("Tool arguments:", params.arguments); + * }; + * + * // OR using inherited setNotificationHandler (more explicit) + * app.setNotificationHandler( + * McpUiToolInputNotificationSchema, + * (notification) => { + * console.log("Tool arguments:", notification.params.arguments); + * } + * ); + * + * await app.connect(new PostMessageTransport(window.parent)); + * ``` + * + * @example Sending a message to the host's chat + * ```typescript + * await app.sendMessage({ + * role: "user", + * content: [{ type: "text", text: "Weather updated!" }] + * }); + * ``` + */ export class App extends Protocol { private _hostCapabilities?: McpUiHostCapabilities; private _hostInfo?: Implementation; + /** + * Create a new MCP App instance. + * + * @param _appInfo - App identification (name and version) + * @param _capabilities - Features and capabilities this app provides + * @param options - Configuration options including autoResize behavior + * + * @example + * ```typescript + * const app = new App( + * { name: "MyApp", version: "1.0.0" }, + * { tools: { listChanged: true } }, // capabilities + * { autoResize: true } // options + * ); + * ``` + */ constructor( private _appInfo: Implementation, private _capabilities: McpUiAppCapabilities = {}, @@ -71,14 +211,98 @@ export class App extends Protocol { }); } + /** + * Get the host's capabilities discovered during initialization. + * + * Returns the capabilities that the host advertised during the + * {@link connect} handshake. Returns `undefined` if called before + * connection is established. + * + * @returns Host capabilities, or `undefined` if not yet connected + * + * @example Check host capabilities after connection + * ```typescript + * await app.connect(transport); + * const caps = app.getHostCapabilities(); + * if (caps === undefined) { + * console.error("Not connected"); + * return; + * } + * if (caps.serverTools) { + * console.log("Host supports server tool calls"); + * } + * ``` + * + * @see {@link connect} for the initialization handshake + * @see {@link McpUiHostCapabilities} for the capabilities structure + */ getHostCapabilities(): McpUiHostCapabilities | undefined { return this._hostCapabilities; } + /** + * Get the host's implementation info discovered during initialization. + * + * Returns the host's name and version as advertised during the + * {@link connect} handshake. Returns `undefined` if called before + * connection is established. + * + * @returns Host implementation info, or `undefined` if not yet connected + * + * @example Log host information after connection + * ```typescript + * await app.connect(transport); + * const host = app.getHostVersion(); + * if (host === undefined) { + * console.error("Not connected"); + * return; + * } + * console.log(`Connected to ${host.name} v${host.version}`); + * ``` + * + * @see {@link connect} for the initialization handshake + */ getHostVersion(): Implementation | undefined { return this._hostInfo; } + /** + * Convenience handler for receiving complete tool input from the host. + * + * Set this property to register a handler that will be called when the host + * sends a tool's complete arguments. This is sent after a tool call begins + * and before the tool result is available. + * + * This setter is a convenience wrapper around `setNotificationHandler()` that + * automatically handles the notification schema and extracts the params for you. + * + * Register handlers before calling {@link connect} to avoid missing notifications. + * + * @param callback - Function called with the tool input params + * + * @example Using the setter (simpler) + * ```typescript + * // Register before connecting to ensure no notifications are missed + * app.ontoolinput = (params) => { + * console.log("Tool:", params.arguments); + * // Update your UI with the tool arguments + * }; + * await app.connect(transport); + * ``` + * + * @example Using setNotificationHandler (more explicit) + * ```typescript + * app.setNotificationHandler( + * McpUiToolInputNotificationSchema, + * (notification) => { + * console.log("Tool:", notification.params.arguments); + * } + * ); + * ``` + * + * @see {@link setNotificationHandler} for the underlying method + * @see {@link McpUiToolInputNotification} for the notification structure + */ set ontoolinput( callback: (params: McpUiToolInputNotification["params"]) => void, ) { @@ -86,6 +310,33 @@ export class App extends Protocol { callback(n.params), ); } + + /** + * Convenience handler for receiving streaming partial tool input from the host. + * + * Set this property to register a handler that will be called as the host + * streams partial tool arguments during tool call initialization. This enables + * progressive rendering of tool arguments before they're complete. + * + * This setter is a convenience wrapper around `setNotificationHandler()` that + * automatically handles the notification schema and extracts the params for you. + * + * Register handlers before calling {@link connect} to avoid missing notifications. + * + * @param callback - Function called with each partial tool input update + * + * @example Progressive rendering of tool arguments + * ```typescript + * app.ontoolinputpartial = (params) => { + * console.log("Partial args:", params.arguments); + * // Update your UI progressively as arguments stream in + * }; + * ``` + * + * @see {@link setNotificationHandler} for the underlying method + * @see {@link McpUiToolInputPartialNotification} for the notification structure + * @see {@link ontoolinput} for the complete tool input handler + */ set ontoolinputpartial( callback: (params: McpUiToolInputPartialNotification["params"]) => void, ) { @@ -93,6 +344,37 @@ export class App extends Protocol { callback(n.params), ); } + + /** + * Convenience handler for receiving tool execution results from the host. + * + * Set this property to register a handler that will be called when the host + * sends the result of a tool execution. This is sent after the tool completes + * on the MCP server, allowing your app to display the results or update its state. + * + * This setter is a convenience wrapper around `setNotificationHandler()` that + * automatically handles the notification schema and extracts the params for you. + * + * Register handlers before calling {@link connect} to avoid missing notifications. + * + * @param callback - Function called with the tool result + * + * @example Display tool execution results + * ```typescript + * app.ontoolresult = (params) => { + * if (params.content) { + * console.log("Tool output:", params.content); + * } + * if (params.isError) { + * console.error("Tool execution failed"); + * } + * }; + * ``` + * + * @see {@link setNotificationHandler} for the underlying method + * @see {@link McpUiToolResultNotification} for the notification structure + * @see {@link ontoolinput} for the initial tool input handler + */ set ontoolresult( callback: (params: McpUiToolResultNotification["params"]) => void, ) { @@ -100,6 +382,37 @@ export class App extends Protocol { callback(n.params), ); } + + /** + * Convenience handler for host context changes (theme, viewport, locale, etc.). + * + * Set this property to register a handler that will be called when the host's + * context changes, such as theme switching (light/dark), viewport size changes, + * locale changes, or other environmental updates. Apps should respond by + * updating their UI accordingly. + * + * This setter is a convenience wrapper around `setNotificationHandler()` that + * automatically handles the notification schema and extracts the params for you. + * + * Register handlers before calling {@link connect} to avoid missing notifications. + * + * @param callback - Function called with the updated host context + * + * @example Respond to theme changes + * ```typescript + * app.onhostcontextchanged = (params) => { + * if (params.theme === "dark") { + * document.body.classList.add("dark-theme"); + * } else { + * document.body.classList.remove("dark-theme"); + * } + * }; + * ``` + * + * @see {@link setNotificationHandler} for the underlying method + * @see {@link McpUiHostContextChangedNotification} for the notification structure + * @see {@link McpUiHostContext} for the full context structure + */ set onhostcontextchanged( callback: (params: McpUiHostContextChangedNotification["params"]) => void, ) { @@ -108,6 +421,38 @@ export class App extends Protocol { (n) => callback(n.params), ); } + + /** + * Convenience handler for tool call requests from the host. + * + * Set this property to register a handler that will be called when the host + * requests this app to execute a tool. This enables apps to provide their own + * tools that can be called by the host or LLM. + * + * The app must declare tool capabilities in the constructor to use this handler. + * + * This setter is a convenience wrapper around `setRequestHandler()` that + * automatically handles the request schema and extracts the params for you. + * + * Register handlers before calling {@link connect} to avoid missing requests. + * + * @param callback - Async function that executes the tool and returns the result. + * The callback will only be invoked if the app declared tool capabilities + * in the constructor. + * + * @example Handle tool calls from the host + * ```typescript + * app.oncalltool = async (params, extra) => { + * if (params.name === "greet") { + * const name = params.arguments?.name ?? "World"; + * return { content: [{ type: "text", text: `Hello, ${name}!` }] }; + * } + * throw new Error(`Unknown tool: ${params.name}`); + * }; + * ``` + * + * @see {@link setRequestHandler} for the underlying method + */ set oncalltool( callback: ( params: CallToolRequest["params"], @@ -118,6 +463,37 @@ export class App extends Protocol { callback(request.params, extra), ); } + + /** + * Convenience handler for listing available tools. + * + * Set this property to register a handler that will be called when the host + * requests a list of tools this app provides. This enables dynamic tool + * discovery by the host or LLM. + * + * The app must declare tool capabilities in the constructor to use this handler. + * + * This setter is a convenience wrapper around `setRequestHandler()` that + * automatically handles the request schema and extracts the params for you. + * + * Register handlers before calling {@link connect} to avoid missing requests. + * + * @param callback - Async function that returns the list of available tools. + * The callback will only be invoked if the app declared tool capabilities + * in the constructor. + * + * @example Return available tools + * ```typescript + * app.onlisttools = async (params, extra) => { + * return { + * tools: ["calculate", "convert", "format"] + * }; + * }; + * ``` + * + * @see {@link setRequestHandler} for the underlying method + * @see {@link oncalltool} for handling tool execution + */ set onlisttools( callback: ( params: ListToolsRequest["params"], @@ -129,9 +505,18 @@ export class App extends Protocol { ); } + /** + * Verify that the host supports the capability required for the given request method. + * @internal + */ assertCapabilityForMethod(method: Request["method"]): void { // TODO } + + /** + * Verify that the app declared the capability required for the given request method. + * @internal + */ assertRequestHandlerCapability(method: Request["method"]): void { switch (method) { case "tools/call": @@ -148,10 +533,50 @@ export class App extends Protocol { throw new Error(`No handler for method ${method} registered`); } } + + /** + * Verify that the app supports the capability required for the given notification method. + * @internal + */ assertNotificationCapability(method: Notification["method"]): void { // TODO } + /** + * Call a tool on the originating MCP server (proxied through the host). + * + * Apps can call tools to fetch fresh data or trigger server-side actions. + * The host proxies the request to the actual MCP server and returns the result. + * + * @param params - Tool name and arguments + * @param options - Request options (timeout, etc.) + * @returns Tool execution result + * + * @throws {Error} If the tool does not exist on the server + * @throws {Error} If the request times out or the connection is lost + * @throws {Error} If the host rejects the request + * + * Note: Tool-level execution errors are returned in the result with `isError: true` + * rather than throwing exceptions. Always check `result.isError` to distinguish + * between transport failures (thrown) and tool execution failures (returned). + * + * @example Fetch updated weather data + * ```typescript + * try { + * const result = await app.callServerTool({ + * name: "get_weather", + * arguments: { location: "Tokyo" } + * }); + * if (result.isError) { + * console.error("Tool returned error:", result.content); + * } else { + * console.log(result.content); + * } + * } catch (error) { + * console.error("Tool call failed:", error); + * } + * ``` + */ async callServerTool( params: CallToolRequest["params"], options?: RequestOptions, @@ -163,6 +588,33 @@ export class App extends Protocol { ); } + /** + * Send a message to the host's chat interface. + * + * Enables the app to add messages to the conversation thread. Useful for + * user-initiated messages or app-to-conversation communication. + * + * @param params - Message role and content + * @param options - Request options (timeout, etc.) + * @returns Result indicating success or error (no message content returned) + * + * @throws {Error} If the host rejects the message + * + * @example Send a text message from user interaction + * ```typescript + * try { + * await app.sendMessage({ + * role: "user", + * content: [{ type: "text", text: "Show me details for item #42" }] + * }); + * } catch (error) { + * console.error("Failed to send message:", error); + * // Handle error appropriately for your app + * } + * ``` + * + * @see {@link McpUiMessageRequest} for request structure + */ sendMessage(params: McpUiMessageRequest["params"], options?: RequestOptions) { return this.request( { @@ -174,6 +626,25 @@ export class App extends Protocol { ); } + /** + * Send log messages to the host for debugging and telemetry. + * + * Logs are not added to the conversation but may be recorded by the host + * for debugging purposes. + * + * @param params - Log level and message + * + * @example Log app state for debugging + * ```typescript + * app.sendLog({ + * level: "info", + * data: "Weather data refreshed", + * logger: "WeatherApp" + * }); + * ``` + * + * @returns Promise that resolves when the log notification is sent + */ sendLog(params: LoggingMessageNotification["params"]) { return this.notification({ method: "notifications/message", @@ -181,6 +652,31 @@ export class App extends Protocol { }); } + /** + * Request the host to open an external URL in the default browser. + * + * The host may deny this request based on user preferences or security policy. + * Apps should handle rejection gracefully. + * + * @param params - URL to open + * @param options - Request options (timeout, etc.) + * @returns Result indicating success or error + * + * @throws {Error} If the host denies the request (e.g., blocked domain, user cancelled) + * @throws {Error} If the request times out or the connection is lost + * + * @example Open documentation link + * ```typescript + * try { + * await app.sendOpenLink({ url: "https://docs.example.com" }); + * } catch (error) { + * console.error("Failed to open link:", error); + * // Optionally show fallback: display URL for manual copy + * } + * ``` + * + * @see {@link McpUiOpenLinkRequest} for request structure + */ sendOpenLink( params: McpUiOpenLinkRequest["params"], options?: RequestOptions, @@ -195,6 +691,26 @@ export class App extends Protocol { ); } + /** + * Notify the host of UI size changes. + * + * Apps can manually report size changes to help the host adjust the container. + * If `autoResize` is enabled (default), this is called automatically. + * + * @param params - New width and height in pixels + * + * @example Manually notify host of size change + * ```typescript + * app.sendSizeChange({ + * width: 400, + * height: 600 + * }); + * ``` + * + * @returns Promise that resolves when the notification is sent + * + * @see {@link McpUiSizeChangeNotification} for notification structure + */ sendSizeChange(params: McpUiSizeChangeNotification["params"]) { return this.notification({ method: "ui/notifications/size-change", @@ -202,6 +718,31 @@ export class App extends Protocol { }); } + /** + * Set up automatic size change notifications using ResizeObserver. + * + * Observes both `document.documentElement` and `document.body` for size changes + * and automatically sends `ui/notifications/size-change` notifications to the host. + * The notifications are debounced using requestAnimationFrame to avoid duplicates. + * + * Note: This method is automatically called by `connect()` if the `autoResize` + * option is true (default). You typically don't need to call this manually unless + * you disabled autoResize and want to enable it later. + * + * @returns Cleanup function to disconnect the observer + * + * @example Manual setup for custom scenarios + * ```typescript + * const app = new App(appInfo, capabilities, { autoResize: false }); + * await app.connect(transport); + * + * // Later, enable auto-resize manually + * const cleanup = app.setupSizeChangeNotifications(); + * + * // Clean up when done + * cleanup(); + * ``` + */ setupSizeChangeNotifications() { let scheduled = false; const sendBodySizeChange = () => { @@ -230,6 +771,43 @@ export class App extends Protocol { return () => resizeObserver.disconnect(); } + /** + * Establish connection with the host and perform initialization handshake. + * + * This method performs the following steps: + * 1. Connects the transport layer + * 2. Sends `ui/initialize` request with app info and capabilities + * 3. Receives host capabilities and context in response + * 4. Sends `ui/notifications/initialized` notification + * 5. Sets up auto-resize using {@link setupSizeChangeNotifications} if enabled (default) + * + * If initialization fails, the connection is automatically closed and an error + * is thrown. + * + * @param transport - Transport layer (typically PostMessageTransport) + * @param options - Request options for the initialize request + * + * @throws {Error} If initialization fails or connection is lost + * + * @example Connect with PostMessageTransport + * ```typescript + * const app = new App( + * { name: "MyApp", version: "1.0.0" }, + * {} + * ); + * + * try { + * await app.connect(new PostMessageTransport(window.parent)); + * console.log("Connected successfully!"); + * } catch (error) { + * console.error("Failed to connect:", error); + * } + * ``` + * + * @see {@link McpUiInitializeRequest} for the initialization request structure + * @see {@link McpUiInitializedNotification} for the initialized notification + * @see {@link PostMessageTransport} for the typical transport implementation + */ override async connect( transport: Transport, options?: RequestOptions, diff --git a/src/message-transport.ts b/src/message-transport.ts index 4210d4da..b997d7c6 100644 --- a/src/message-transport.ts +++ b/src/message-transport.ts @@ -8,12 +8,69 @@ import { TransportSendOptions, } from "@modelcontextprotocol/sdk/shared/transport.js"; +/** + * JSON-RPC transport using window.postMessage for iframe↔parent communication. + * + * This transport enables bidirectional communication between MCP Apps running in + * iframes and their host applications using the browser's postMessage API. It + * implements the MCP SDK's Transport interface. + * + * ## Security + * + * The `eventSource` parameter provides origin validation by filtering messages + * from specific sources. Guest UIs typically don't need to specify this (they only + * communicate with their parent), but hosts should validate the iframe source for + * security. + * + * ## Usage + * + * **Guest UI (default)**: + * ```typescript + * const transport = new PostMessageTransport(window.parent); + * await app.connect(transport); + * ``` + * + * **Host (with source validation)**: + * ```typescript + * const iframe = document.getElementById('app-iframe') as HTMLIFrameElement; + * const transport = new PostMessageTransport( + * iframe.contentWindow!, + * iframe.contentWindow // Validate messages from this iframe only + * ); + * await bridge.connect(transport); + * ``` + * + * @see {@link app.App.connect} for Guest UI usage + * @see {@link app-bridge.AppBridge.connect} for Host usage + */ export class PostMessageTransport implements Transport { private messageListener: ( this: Window, ev: WindowEventMap["message"], ) => any | undefined; + /** + * Create a new PostMessageTransport. + * + * @param eventTarget - Target window to send messages to (default: window.parent) + * @param eventSource - Optional source validation. If specified, only messages from + * this source will be accepted. Guest UIs typically don't need this (they only + * receive from parent), but hosts should validate the iframe source. + * + * @example Guest UI connecting to parent + * ```typescript + * const transport = new PostMessageTransport(window.parent); + * ``` + * + * @example Host connecting to iframe with validation + * ```typescript + * const iframe = document.getElementById('app') as HTMLIFrameElement; + * const transport = new PostMessageTransport( + * iframe.contentWindow!, + * iframe.contentWindow // Only accept messages from this iframe + * ); + * ``` + */ constructor( private eventTarget: Window = window.parent, private eventSource?: MessageEventSource, @@ -37,20 +94,84 @@ export class PostMessageTransport implements Transport { } }; } + + /** + * Begin listening for messages from the event source. + * + * Registers a message event listener on the window. Must be called before + * messages can be received. + */ async start() { window.addEventListener("message", this.messageListener); } + + /** + * Send a JSON-RPC message to the target window. + * + * Messages are sent using postMessage with "*" origin, meaning they are visible + * to all frames. The receiver should validate the message source for security. + * + * @param message - JSON-RPC message to send + * @param options - Optional send options (currently unused) + */ async send(message: JSONRPCMessage, options?: TransportSendOptions) { console.info("[host] Sending message", message); this.eventTarget.postMessage(message, "*"); } + + /** + * Stop listening for messages and cleanup. + * + * Removes the message event listener and calls the {@link onclose} callback if set. + */ async close() { window.removeEventListener("message", this.messageListener); this.onclose?.(); } + + /** + * Called when the transport is closed. + * + * Set this handler to be notified when {@link close} is called. + */ onclose?: () => void; + + /** + * Called when a message parsing error occurs. + * + * This handler is invoked when a received message fails JSON-RPC schema + * validation. The error parameter contains details about the validation failure. + * + * @param error - Error describing the validation failure + */ onerror?: (error: Error) => void; + + /** + * Called when a valid JSON-RPC message is received. + * + * This handler is invoked after message validation succeeds. The {@link start} + * method must be called before messages will be received. + * + * @param message - The validated JSON-RPC message + * @param extra - Optional metadata about the message (unused in this transport) + */ onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; + + /** + * Optional session identifier for this transport connection. + * + * Set by the MCP SDK to track the connection session. Not required for + * PostMessageTransport functionality. + */ sessionId?: string; + + /** + * Callback to set the negotiated protocol version. + * + * The MCP SDK calls this during initialization to communicate the protocol + * version negotiated with the peer. + * + * @param version - The negotiated protocol version string + */ setProtocolVersion?: (version: string) => void; } diff --git a/src/react/index.tsx b/src/react/index.tsx index 47a326f2..d5ce3760 100644 --- a/src/react/index.tsx +++ b/src/react/index.tsx @@ -1,2 +1,34 @@ +/** + * React utilities for building MCP Apps. + * + * This module provides React hooks and utilities for easily building + * interactive MCP Apps using React. This is optional - the core SDK + * ({@link App}, {@link PostMessageTransport}) is framework-agnostic and can be + * used with any UI framework or vanilla JavaScript. + * + * ## Main Exports + * + * - {@link useApp} - React hook to create and connect an MCP App + * - {@link useAutoResize} - React hook for manual auto-resize control (rarely needed) + * + * @module @modelcontextprotocol/ext-apps/react + * + * @example Basic React App + * ```tsx + * import { useApp } from '@modelcontextprotocol/ext-apps/react'; + * + * function MyApp() { + * const { app, isConnected, error } = useApp({ + * appInfo: { name: "MyApp", version: "1.0.0" }, + * capabilities: {} + * }); + * + * if (error) return
Error: {error.message}
; + * if (!isConnected) return
Connecting...
; + * + * return
Connected!
; + * } + * ``` + */ export * from "./useApp"; export * from "./useAutoResize"; diff --git a/src/react/useApp.tsx b/src/react/useApp.tsx index 1e6cfc3a..ccfce3eb 100644 --- a/src/react/useApp.tsx +++ b/src/react/useApp.tsx @@ -4,22 +4,105 @@ import { Client } from "@modelcontextprotocol/sdk/client"; import { App, McpUiAppCapabilities, PostMessageTransport } from "../app"; export * from "../app"; +/** + * Options for configuring the useApp hook. + * + * Note: This interface does NOT expose App options like `autoResize`. + * The hook creates the App with default options (autoResize: true). If you need + * custom App options, create the App manually instead of using this hook. + * + * @see {@link useApp} for the hook that uses these options + * @see {@link useAutoResize} for manual auto-resize control with custom App options + */ export interface UseAppOptions { + /** App identification (name and version) */ appInfo: Implementation; + /** Features and capabilities this app provides */ capabilities: McpUiAppCapabilities; /** - * Called after client is created but before connection. - * Use this to register handlers via app.ontoolinput, app.toolresult, etc. + * Called after App is created but before connection. + * + * Use this to register request/notification handlers that need to be in place + * before the initialization handshake completes. + * + * @param app - The newly created App instance + * + * @example Register a notification handler + * ```typescript + * import { McpUiToolInputNotificationSchema } from '@modelcontextprotocol/ext-apps/react'; + * + * onAppCreated: (app) => { + * app.setNotificationHandler( + * McpUiToolInputNotificationSchema, + * (notification) => { + * console.log("Tool input:", notification.params.arguments); + * } + * ); + * } + * ``` */ onAppCreated?: (app: App) => void; } +/** + * State returned by the useApp hook. + */ export interface AppState { + /** The connected App instance, null during initialization */ app: App | null; + /** Whether initialization completed successfully */ isConnected: boolean; + /** Connection error if initialization failed, null otherwise */ error: Error | null; } +/** + * React hook to create and connect an MCP App. + * + * This hook manages the complete lifecycle of an {@link App}: creation, connection, + * and cleanup. It automatically creates a {@link PostMessageTransport} to window.parent + * and handles initialization. + * + * **Important**: The hook intentionally does NOT re-run when options change + * to avoid reconnection loops. Options are only used during the initial mount. + * + * **Note**: This is part of the optional React integration. The core SDK + * (App, PostMessageTransport) is framework-agnostic and can be + * used with any UI framework or vanilla JavaScript. + * + * @param options - Configuration for the app + * @returns Current connection state and app instance. If connection fails during + * initialization, the `error` field will contain the error (typically connection + * timeouts, initialization handshake failures, or transport errors). + * + * @example Basic usage + * ```typescript + * import { useApp, McpUiToolInputNotificationSchema } from '@modelcontextprotocol/ext-apps/react'; + * + * function MyApp() { + * const { app, isConnected, error } = useApp({ + * appInfo: { name: "MyApp", version: "1.0.0" }, + * capabilities: {}, + * onAppCreated: (app) => { + * // Register handlers before connection + * app.setNotificationHandler( + * McpUiToolInputNotificationSchema, + * (notification) => { + * console.log("Tool input:", notification.params.arguments); + * } + * ); + * }, + * }); + * + * if (error) return
Error: {error.message}
; + * if (!isConnected) return
Connecting...
; + * return
Connected!
; + * } + * ``` + * + * @see {@link App.connect} for the underlying connection method + * @see {@link useAutoResize} for manual auto-resize control when using custom App options + */ export function useApp({ appInfo, capabilities, diff --git a/src/react/useAutoResize.ts b/src/react/useAutoResize.ts index ce39c75a..44f32e82 100644 --- a/src/react/useAutoResize.ts +++ b/src/react/useAutoResize.ts @@ -2,9 +2,52 @@ import { useEffect, RefObject } from "react"; import { App } from "../app"; /** - * Custom hook that automatically reports size changes to the parent window. + * React hook that automatically reports UI size changes to the host. * - * @param client - MCP UI client for sending size notifications + * Uses ResizeObserver to watch `document.body` and `document.documentElement` for + * size changes and sends `ui/notifications/size-change` notifications. + * + * **Note**: This hook is rarely needed since the {@link useApp} hook automatically enables + * auto-resize by default. This hook is provided for advanced cases where you + * create the {@link App} manually with `autoResize: false` and want to add auto-resize + * behavior later. + * + * @param app - The connected App instance, or null during initialization + * @param elementRef - Currently unused. The hook always observes `document.body` + * and `document.documentElement` regardless of this value. Passing a ref will + * cause unnecessary effect re-runs; omit this parameter. + * + * @example Manual App creation with custom auto-resize control + * ```tsx + * function MyComponent() { + * // For custom App options, create App manually instead of using useApp + * const [app, setApp] = useState(null); + * const [error, setError] = useState(null); + * + * useEffect(() => { + * const myApp = new App( + * { name: "MyApp", version: "1.0.0" }, + * {}, // capabilities + * { autoResize: false } // Disable default auto-resize + * ); + * + * const transport = new PostMessageTransport(window.parent); + * myApp.connect(transport) + * .then(() => setApp(myApp)) + * .catch((err) => setError(err)); + * }, []); + * + * // Add manual auto-resize control + * useAutoResize(app); + * + * if (error) return
Connection failed: {error.message}
; + * return
My content
; + * } + * ``` + * + * @see {@link App.setupSizeChangeNotifications} for the underlying implementation + * @see {@link useApp} which enables auto-resize by default + * @see {@link App} constructor for configuring `autoResize` option */ export function useAutoResize( app: App | null, diff --git a/src/types.ts b/src/types.ts index d58bfc5c..0ce8540d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,29 +1,133 @@ import { + CallToolResult, CallToolResultSchema, + ContentBlock, ContentBlockSchema, EmptyResultSchema, + Implementation, ImplementationSchema, + RequestId, RequestIdSchema, RequestSchema, + Tool, ToolSchema, } from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; +import { z } from "zod/v4"; +/** + * Type-level assertion that validates a Zod schema produces the expected interface. + * + * This helper is used for request and notification schemas that cannot use + * `z.ZodType` type annotations. Adding `: z.ZodType` to + * schemas widens the type from the specific `ZodObject` to the generic `ZodType`, + * which breaks MCP SDK's `setRequestHandler()` and `setNotificationHandler()` + * methods that require the specific `ZodObject` type. + * + * By using this type-level assertion instead, we get compile-time validation that + * the schema matches the interface without affecting the runtime schema type. + * + * @internal + */ +type VerifySchemaMatches = + z.infer extends TInterface + ? TInterface extends z.infer + ? true + : never + : never; + +/** + * Current protocol version supported by this SDK. + * + * The SDK automatically handles version negotiation during initialization. + * Apps and hosts don't need to manage protocol versions manually. + */ export const LATEST_PROTOCOL_VERSION = "2025-11-21"; +/** + * Request to open an external URL in the host's default browser. + * + * Sent from the Guest UI to the Host when requesting to open an external link. + * The host may deny the request based on user preferences or security policy. + * + * @see {@link app.App.sendOpenLink} for the method that sends this request + */ +export interface McpUiOpenLinkRequest { + method: "ui/open-link"; + params: { + /** URL to open in the host's browser */ + url: string; + }; +} + +/** + * Runtime validation schema for {@link McpUiOpenLinkRequest}. + * @internal + */ export const McpUiOpenLinkRequestSchema = RequestSchema.extend({ method: z.literal("ui/open-link"), params: z.object({ url: z.string().url(), }), }); -export type McpUiOpenLinkRequest = z.infer; -export const McpUiOpenLinkResultSchema = z.object({ - isError: z.boolean().optional(), -}); -export type McpUiOpenLinkResult = z.infer; +/** @internal - Compile-time verification that schema matches interface */ +type _VerifyOpenLinkRequest = VerifySchemaMatches< + typeof McpUiOpenLinkRequestSchema, + McpUiOpenLinkRequest +>; + +/** + * Result from a {@link McpUiOpenLinkRequest}. + * + * The host returns this result after attempting to open the requested URL. + * + * @see {@link McpUiOpenLinkRequest} + */ +export interface McpUiOpenLinkResult { + /** + * True if the host failed to open the URL (e.g., due to security policy, + * user cancellation, or system error). False or undefined indicates success. + */ + isError?: boolean; + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The schema intentionally omits this to enforce strict validation. + */ + [key: string]: unknown; +} + +/** + * Runtime validation schema for {@link McpUiOpenLinkResult}. + * @internal + */ +export const McpUiOpenLinkResultSchema: z.ZodType = + z.object({ + isError: z.boolean().optional(), + }); +/** + * Request to send a message to the host's chat interface. + * + * Sent from the Guest UI to the Host when the app wants to add a message to the + * conversation thread. This enables interactive apps to communicate with the user + * through the host's chat interface. + * + * @see {@link app.App.sendMessage} for the method that sends this request + */ +export interface McpUiMessageRequest { + method: "ui/message"; + params: { + /** Message role, currently only "user" is supported */ + role: "user"; + /** Message content blocks (text, image, etc.) */ + content: ContentBlock[]; + }; +} + +/** + * Runtime validation schema for {@link McpUiMessageRequest}. + * @internal + */ export const McpUiMessageRequestSchema = RequestSchema.extend({ method: z.literal("ui/message"), params: z.object({ @@ -31,38 +135,146 @@ export const McpUiMessageRequestSchema = RequestSchema.extend({ content: z.array(ContentBlockSchema), }), }); -export type McpUiMessageRequest = z.infer; -export const McpUiMessageResultSchema = z.object({ - // 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. - isError: z.boolean().optional(), -}); -export type McpUiMessageResult = z.infer; +/** @internal - Compile-time verification that schema matches interface */ +type _VerifyMessageRequest = VerifySchemaMatches< + typeof McpUiMessageRequestSchema, + McpUiMessageRequest +>; + +/** + * Result from a {@link McpUiMessageRequest}. + * + * Note: The host does not return message content or follow-up results to prevent + * leaking information from the conversation. Only error status is provided. + * + * @see {@link McpUiMessageRequest} + */ +export interface McpUiMessageResult { + /** + * True if the host rejected or failed to deliver the message (e.g., due to + * rate limiting, content policy, or system error). False or undefined + * indicates the message was accepted. + */ + isError?: boolean; + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The schema intentionally omits this to enforce strict validation. + */ + [key: string]: unknown; +} + +/** + * Runtime validation schema for {@link McpUiMessageResult}. + * @internal + */ +export const McpUiMessageResultSchema: z.ZodType = z.object( + { + isError: z.boolean().optional(), + }, +); // McpUiIframeReadyNotification removed - replaced by standard MCP initialization // The SDK's oninitialized callback now handles the ready signal +/** + * Notification that the sandbox proxy iframe is ready to receive content. + * + * This is an internal message used by web-based hosts implementing the + * double-iframe sandbox architecture. The sandbox proxy sends this to the host + * after it loads and is ready to receive HTML content via + * {@link McpUiSandboxResourceReadyNotification}. + * + * @internal + * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#sandbox-proxy + */ +export interface McpUiSandboxProxyReadyNotification { + method: "ui/notifications/sandbox-proxy-ready"; + params: {}; +} + +/** + * Runtime validation schema for {@link McpUiSandboxProxyReadyNotification}. + * @internal + */ export const McpUiSandboxProxyReadyNotificationSchema = z.object({ method: z.literal("ui/notifications/sandbox-proxy-ready"), params: z.object({}), }); -export type McpUiSandboxProxyReadyNotification = z.infer< - typeof McpUiSandboxProxyReadyNotificationSchema + +/** @internal - Compile-time verification that schema matches interface */ +type _VerifySandboxProxyReadyNotification = VerifySchemaMatches< + typeof McpUiSandboxProxyReadyNotificationSchema, + McpUiSandboxProxyReadyNotification >; +/** + * Notification containing HTML resource for the sandbox proxy to load. + * + * This is an internal message used by web-based hosts implementing the + * double-iframe sandbox architecture. After the sandbox proxy signals readiness, + * the host sends this notification with the HTML content and optional sandbox + * attributes to load into the inner iframe. + * + * @internal + * @see https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#sandbox-proxy + */ +export interface McpUiSandboxResourceReadyNotification { + method: "ui/notifications/sandbox-resource-ready"; + params: { + /** HTML content to load into the inner iframe */ + html: string; + /** Optional override for the inner iframe's sandbox attribute */ + sandbox?: string; + }; +} + +/** + * Runtime validation schema for {@link McpUiSandboxResourceReadyNotification}. + * @internal + */ export const McpUiSandboxResourceReadyNotificationSchema = z.object({ method: z.literal("ui/notifications/sandbox-resource-ready"), params: z.object({ - html: z.string(), //ReadResourceResultSchema, + html: z.string(), sandbox: z.string().optional(), }), }); -export type McpUiSandboxResourceReadyNotification = z.infer< - typeof McpUiSandboxResourceReadyNotificationSchema + +/** @internal - Compile-time verification that schema matches interface */ +type _VerifySandboxResourceReadyNotification = VerifySchemaMatches< + typeof McpUiSandboxResourceReadyNotificationSchema, + McpUiSandboxResourceReadyNotification >; -// Fired by the iframe when its body changes, and by the host when the viewport size changes. +/** + * Notification of UI size changes (bidirectional: Guest ↔ Host). + * + * **Guest UI → Host**: Sent by the Guest UI when its rendered content size changes, + * typically using ResizeObserver. This helps the host adjust the iframe container. + * If {@link app.App} is configured with `autoResize: true` (default), this is sent + * automatically. + * + * **Host → Guest UI**: Sent by the Host when the viewport size changes (e.g., + * window resize, orientation change). This allows the Guest UI to adjust its layout. + * + * @see {@link app.App.sendSizeChange} for the method to send this from Guest UI + * @see {@link app.App.setupSizeChangeNotifications} for automatic size reporting + */ +export interface McpUiSizeChangeNotification { + method: "ui/notifications/size-change"; + params: { + /** New width in pixels */ + width?: number; + /** New height in pixels */ + height?: number; + }; +} + +/** + * Runtime validation schema for {@link McpUiSizeChangeNotification}. + * @internal + */ export const McpUiSizeChangeNotificationSchema = z.object({ method: z.literal("ui/notifications/size-change"), params: z.object({ @@ -70,45 +282,223 @@ export const McpUiSizeChangeNotificationSchema = z.object({ height: z.number().optional(), }), }); -export type McpUiSizeChangeNotification = z.infer< - typeof McpUiSizeChangeNotificationSchema + +/** @internal - Compile-time verification that schema matches interface */ +type _VerifySizeChangeNotification = VerifySchemaMatches< + typeof McpUiSizeChangeNotificationSchema, + McpUiSizeChangeNotification >; +/** + * Notification containing complete tool arguments (Host → Guest UI). + * + * The host MUST send this notification after the Guest UI's initialize request + * completes, when complete tool arguments become available. This notification is + * sent exactly once and is required before {@link McpUiToolResultNotification}. + * + * The arguments object contains the complete tool call parameters that triggered + * this App instance. + */ +export interface McpUiToolInputNotification { + method: "ui/notifications/tool-input"; + params: { + /** Complete tool call arguments as key-value pairs */ + arguments?: Record; + }; +} + +/** + * Runtime validation schema for {@link McpUiToolInputNotification}. + * @internal + */ export const McpUiToolInputNotificationSchema = z.object({ method: z.literal("ui/notifications/tool-input"), params: z.object({ - arguments: z.record(z.unknown()).optional(), + arguments: z.record(z.string(), z.unknown()).optional(), }), }); -export type McpUiToolInputNotification = z.infer< - typeof McpUiToolInputNotificationSchema + +/** @internal - Compile-time verification that schema matches interface */ +type _VerifyToolInputNotification = VerifySchemaMatches< + typeof McpUiToolInputNotificationSchema, + McpUiToolInputNotification >; +/** + * Notification containing partial/streaming tool arguments (Host → Guest UI). + * + * The host MAY send this notification zero or more times while the agent is + * streaming tool arguments, before {@link McpUiToolInputNotification} is sent + * with complete arguments. + * + * The arguments object represents best-effort recovery of incomplete JSON, with + * unclosed structures automatically closed to produce valid JSON. Guest UIs may + * ignore these notifications or use them to render progressive loading states. + * + * Guest UIs MUST NOT rely on partial arguments for critical operations and SHOULD + * gracefully handle missing or changing fields between notifications. + */ +export interface McpUiToolInputPartialNotification { + method: "ui/notifications/tool-input-partial"; + params: { + /** Partial tool call arguments (incomplete, may change) */ + arguments?: Record; + }; +} + +/** + * Runtime validation schema for {@link McpUiToolInputPartialNotification}. + * @internal + */ export const McpUiToolInputPartialNotificationSchema = z.object({ method: z.literal("ui/notifications/tool-input-partial"), params: z.object({ - arguments: z.record(z.unknown()).optional(), + arguments: z.record(z.string(), z.unknown()).optional(), }), }); -export type McpUiToolInputPartialNotification = z.infer< - typeof McpUiToolInputPartialNotificationSchema + +/** @internal - Compile-time verification that schema matches interface */ +type _VerifyToolInputPartialNotification = VerifySchemaMatches< + typeof McpUiToolInputPartialNotificationSchema, + McpUiToolInputPartialNotification >; -// Fired once both tool call returned *AND* host received ui/ui-lifecycle-iframe-ready. +/** + * Notification containing tool execution result (Host → Guest UI). + * + * The host MUST send this notification when tool execution completes successfully, + * provided the UI is still displayed. If the UI was closed before execution + * completes, the host MAY skip this notification. This notification is sent after + * {@link McpUiToolInputNotification}. + * + * The result follows the standard MCP CallToolResult format, containing content + * for the model and optionally structuredContent optimized for UI rendering. + */ +export interface McpUiToolResultNotification { + method: "ui/notifications/tool-result"; + /** Standard MCP tool execution result */ + params: CallToolResult; +} + +/** + * Runtime validation schema for {@link McpUiToolResultNotification}. + * @internal + */ export const McpUiToolResultNotificationSchema = z.object({ method: z.literal("ui/notifications/tool-result"), params: CallToolResultSchema, }); -export type McpUiToolResultNotification = z.infer< - typeof McpUiToolResultNotificationSchema + +/** @internal - Compile-time verification that schema matches interface */ +type _VerifyToolResultNotification = VerifySchemaMatches< + typeof McpUiToolResultNotificationSchema, + McpUiToolResultNotification >; -export const McpUiHostContextSchema = z.object({ +/** + * Rich context about the host environment provided to Guest UIs. + * + * Hosts provide this context in the {@link McpUiInitializeResult} response and send + * updates via {@link McpUiHostContextChangedNotification} when values change. + * All fields are optional and Guest UIs should handle missing fields gracefully. + * + * @example + * ```typescript + * // Received during initialization + * const result = await app.connect(transport); + * const context = result.hostContext; + * + * if (context.theme === "dark") { + * document.body.classList.add("dark-mode"); + * } + * ``` + */ +export interface McpUiHostContext { + /** Metadata of the tool call that instantiated this App */ + toolInfo?: { + /** JSON-RPC id of the tools/call request */ + id: RequestId; + /** Tool definition including name, inputSchema, etc. */ + tool: Tool; + }; + /** + * Current color theme preference. + * @example "dark" + */ + theme?: "light" | "dark" | "system"; + /** + * How the UI is currently displayed. + * @example "inline" + */ + displayMode?: "inline" | "fullscreen" | "pip" | "carousel"; + /** + * Display modes the host supports. + * Apps can use this to offer mode-switching UI if applicable. + */ + availableDisplayModes?: string[]; + /** Current and maximum dimensions available to the UI */ + viewport?: { + /** Current viewport width in pixels */ + width: number; + /** Current viewport height in pixels */ + height: number; + /** Maximum available height in pixels (if constrained) */ + maxHeight?: number; + /** Maximum available width in pixels (if constrained) */ + maxWidth?: number; + }; + /** + * User's language and region preference in BCP 47 format. + * @example "en-US", "fr-CA", "ja-JP" + */ + locale?: string; + /** + * User's timezone in IANA format. + * @example "America/New_York", "Europe/London", "Asia/Tokyo" + */ + timeZone?: string; + /** + * Host application identifier. + * @example "claude-desktop/1.0.0" + */ + userAgent?: string; + /** + * Platform type for responsive design decisions. + * @example "desktop" + */ + platform?: "web" | "desktop" | "mobile"; + /** Device input capabilities */ + deviceCapabilities?: { + /** Whether the device supports touch input */ + touch?: boolean; + /** Whether the device supports hover interactions */ + hover?: boolean; + }; + /** + * Mobile safe area boundaries in pixels. + * Used to avoid notches, rounded corners, and system UI on mobile devices. + */ + safeAreaInsets?: { + /** Top safe area inset in pixels */ + top: number; + /** Right safe area inset in pixels */ + right: number; + /** Bottom safe area inset in pixels */ + bottom: number; + /** Left safe area inset in pixels */ + left: number; + }; +} + +/** + * Runtime validation schema for {@link McpUiHostContext}. + * @internal + */ +export const McpUiHostContextSchema: z.ZodType = z.object({ toolInfo: z .object({ - // Metadata of the tool call that instantiated the App - id: RequestIdSchema, // JSON-RPC id of the tools/call request - tool: ToolSchema, // contains name, inputSchema, etc… + id: RequestIdSchema, + tool: ToolSchema, }) .optional(), theme: z.enum(["light", "dark", "system"]).optional(), @@ -122,8 +512,8 @@ export const McpUiHostContextSchema = z.object({ maxWidth: z.number().optional(), }) .optional(), - locale: z.string().optional(), // BCP 47, e.g., "en-US" - timeZone: z.string().optional(), // IANA, e.g., "America/New_York" + locale: z.string().optional(), + timeZone: z.string().optional(), userAgent: z.string().optional(), platform: z.enum(["web", "desktop", "mobile"]).optional(), deviceCapabilities: z @@ -141,64 +531,219 @@ export const McpUiHostContextSchema = z.object({ }) .optional(), }); -export type McpUiHostContext = z.infer; +/** + * Notification that host context has changed (Host → Guest UI). + * + * The host MAY send this notification when any context field changes, such as: + * - Theme toggled (light/dark) + * - Display mode changed (inline/fullscreen) + * - Device orientation changed + * - Window/panel resized + * + * This notification contains partial updates. Guest UIs SHOULD merge received + * fields with their current context state rather than replacing it entirely. + * + * @see {@link McpUiHostContext} for the full context structure + */ +export interface McpUiHostContextChangedNotification { + method: "ui/notifications/host-context-changed"; + /** Partial context update containing only changed fields */ + params: McpUiHostContext; +} + +/** + * Runtime validation schema for {@link McpUiHostContextChangedNotification}. + * @internal + */ export const McpUiHostContextChangedNotificationSchema = z.object({ method: z.literal("ui/notifications/host-context-changed"), params: McpUiHostContextSchema, }); -export type McpUiHostContextChangedNotification = z.infer< - typeof McpUiHostContextChangedNotificationSchema + +/** @internal - Compile-time verification that schema matches interface */ +type _VerifyHostContextChangedNotification = VerifySchemaMatches< + typeof McpUiHostContextChangedNotificationSchema, + McpUiHostContextChangedNotification >; +/** + * Request for graceful shutdown of the Guest UI (Host → Guest UI). + * + * The host MUST send this request before tearing down the UI resource, for any + * reason including user action, resource reallocation, or app closure. This gives + * the Guest UI an opportunity to save state, cancel pending operations, or show + * confirmation dialogs. + * + * The host SHOULD wait for the response before unmounting the iframe to prevent + * data loss. + * + * @see {@link app-bridge.AppBridge.sendResourceTeardown} for the host method that sends this + */ +export interface McpUiResourceTeardownRequest { + method: "ui/resource-teardown"; + params: {}; +} + +/** + * Runtime validation schema for {@link McpUiResourceTeardownRequest}. + * @internal + */ export const McpUiResourceTeardownRequestSchema = RequestSchema.extend({ method: z.literal("ui/resource-teardown"), params: z.object({}), }); -export type McpUiResourceTeardownRequest = z.infer< - typeof McpUiResourceTeardownRequestSchema ->; -export const McpUiResourceTeardownResultSchema = EmptyResultSchema; -export type McpUiResourceTeardownResult = z.infer< - typeof McpUiResourceTeardownResultSchema +/** @internal - Compile-time verification that schema matches interface */ +type _VerifyResourceTeardownRequest = VerifySchemaMatches< + typeof McpUiResourceTeardownRequestSchema, + McpUiResourceTeardownRequest >; -export const McpUiHostCapabilitiesSchema = z.object({ - experimental: z.object({}).optional(), +/** + * Result from graceful shutdown request. + * + * Empty result indicates the Guest UI has completed cleanup and is ready to be + * torn down. + * + * @see {@link McpUiResourceTeardownRequest} + */ +export interface McpUiResourceTeardownResult {} - openLinks: z.object({}).optional(), +/** + * Runtime validation schema for {@link McpUiResourceTeardownResult}. + * @internal + */ +export const McpUiResourceTeardownResultSchema: z.ZodType = + EmptyResultSchema; - serverTools: z - .object({ - listChanged: z.boolean().optional(), - }) - .optional(), +/** + * Capabilities supported by the host application. + * + * Hosts declare these capabilities during the initialization handshake. Guest UIs + * can check capabilities before attempting to use specific features. + * + * @example Check if host supports opening links + * ```typescript + * const result = await app.connect(transport); + * if (result.hostCapabilities.openLinks) { + * await app.sendOpenLink({ url: "https://example.com" }); + * } + * ``` + * + * @see {@link McpUiInitializeResult} for the initialization result that includes these capabilities + */ +export interface McpUiHostCapabilities { + /** Experimental features (structure TBD) */ + experimental?: {}; + /** Host supports opening external URLs via {@link app.App.sendOpenLink} */ + openLinks?: {}; + /** Host can proxy tool calls to the MCP server */ + serverTools?: { + /** Host supports tools/list_changed notifications */ + listChanged?: boolean; + }; + /** Host can proxy resource reads to the MCP server */ + serverResources?: { + /** Host supports resources/list_changed notifications */ + listChanged?: boolean; + }; + /** Host accepts log messages via {@link app.App.sendLog} */ + logging?: {}; +} - serverResources: z - .object({ - listChanged: z.boolean().optional(), - }) - .optional(), +/** + * Runtime validation schema for {@link McpUiHostCapabilities}. + * @internal + */ +export const McpUiHostCapabilitiesSchema: z.ZodType = + z.object({ + experimental: z.object({}).optional(), + openLinks: z.object({}).optional(), + serverTools: z + .object({ + listChanged: z.boolean().optional(), + }) + .optional(), + serverResources: z + .object({ + listChanged: z.boolean().optional(), + }) + .optional(), + logging: z.object({}).optional(), + }); - logging: z.object({}).optional(), +/** + * Capabilities provided by the Guest UI (App). + * + * Apps declare these capabilities during the initialization handshake to indicate + * what features they provide to the host. + * + * @example Declare tool capabilities + * ```typescript + * const app = new App( + * { name: "MyApp", version: "1.0.0" }, + * { tools: { listChanged: true } } + * ); + * ``` + * + * @see {@link McpUiInitializeRequest} for the initialization request that includes these capabilities + */ +export interface McpUiAppCapabilities { + /** Experimental features (structure TBD) */ + experimental?: {}; + /** + * App exposes MCP-style tools that the host can call. + * These are app-specific tools, not proxied from the server. + */ + tools?: { + /** App supports tools/list_changed notifications */ + listChanged?: boolean; + }; +} - // TODO: elicitation, sampling... -}); -export type McpUiHostCapabilities = z.infer; +/** + * Runtime validation schema for {@link McpUiAppCapabilities}. + * @internal + */ +export const McpUiAppCapabilitiesSchema: z.ZodType = + z.object({ + experimental: z.object({}).optional(), + tools: z + .object({ + listChanged: z.boolean().optional(), + }) + .optional(), + }); -export const McpUiAppCapabilitiesSchema = z.object({ - experimental: z.object({}).optional(), - - // WebMCP-style tools exposed by the app to the host - tools: z - .object({ - listChanged: z.boolean().optional(), - }) - .optional(), -}); -export type McpUiAppCapabilities = z.infer; +/** + * Initialization request sent from Guest UI to Host. + * + * This is the first message sent by the Guest UI after loading. The host responds + * with {@link McpUiInitializeResult} containing host capabilities and context. + * After receiving the response, the Guest UI MUST send + * {@link McpUiInitializedNotification}. + * + * This replaces the custom iframe-ready pattern used in pre-SEP MCP-UI. + * + * @see {@link app.App.connect} for the method that sends this request + */ +export interface McpUiInitializeRequest { + method: "ui/initialize"; + params: { + /** App identification (name and version) */ + appInfo: Implementation; + /** Features and capabilities this app provides */ + appCapabilities: McpUiAppCapabilities; + /** Protocol version this app supports */ + protocolVersion: string; + }; +} +/** + * Runtime validation schema for {@link McpUiInitializeRequest}. + * @internal + */ export const McpUiInitializeRequestSchema = RequestSchema.extend({ method: z.literal("ui/initialize"), params: z.object({ @@ -207,22 +752,74 @@ export const McpUiInitializeRequestSchema = RequestSchema.extend({ protocolVersion: z.string(), }), }); -export type McpUiInitializeRequest = z.infer< - typeof McpUiInitializeRequestSchema + +/** @internal - Compile-time verification that schema matches interface */ +type _VerifyInitializeRequest = VerifySchemaMatches< + typeof McpUiInitializeRequestSchema, + McpUiInitializeRequest >; -export const McpUiInitializeResultSchema = z.object({ - protocolVersion: z.string(), - hostInfo: ImplementationSchema, - hostCapabilities: McpUiHostCapabilitiesSchema, - hostContext: McpUiHostContextSchema, -}); -export type McpUiInitializeResult = z.infer; +/** + * Initialization result returned from Host to Guest UI. + * + * Contains the negotiated protocol version, host information, capabilities, + * and rich context about the host environment. + * + * @see {@link McpUiInitializeRequest} + */ +export interface McpUiInitializeResult { + /** Negotiated protocol version string (e.g., "2025-11-21") */ + protocolVersion: string; + /** Host application identification and version */ + hostInfo: Implementation; + /** Features and capabilities provided by the host */ + hostCapabilities: McpUiHostCapabilities; + /** Rich context about the host environment */ + hostContext: McpUiHostContext; + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The schema intentionally omits this to enforce strict validation. + */ + [key: string]: unknown; +} + +/** + * Runtime validation schema for {@link McpUiInitializeResult}. + * @internal + */ +export const McpUiInitializeResultSchema: z.ZodType = + z.object({ + protocolVersion: z.string(), + hostInfo: ImplementationSchema, + hostCapabilities: McpUiHostCapabilitiesSchema, + hostContext: McpUiHostContextSchema, + }); +/** + * Notification that Guest UI has completed initialization (Guest UI → Host). + * + * The Guest UI MUST send this notification after receiving + * {@link McpUiInitializeResult} and completing any setup. The host waits for this + * notification before sending tool input and other data to the Guest UI. + * + * @see {@link app.App.connect} for the method that sends this notification + */ +export interface McpUiInitializedNotification { + method: "ui/notifications/initialized"; + params?: {}; +} + +/** + * Runtime validation schema for {@link McpUiInitializedNotification}. + * @internal + */ export const McpUiInitializedNotificationSchema = z.object({ method: z.literal("ui/notifications/initialized"), params: z.object({}).optional(), }); -export type McpUiInitializedNotification = z.infer< - typeof McpUiInitializedNotificationSchema + +/** @internal - Compile-time verification that schema matches interface */ +type _VerifyInitializedNotification = VerifySchemaMatches< + typeof McpUiInitializedNotificationSchema, + McpUiInitializedNotification >; diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 00000000..cb7d7790 --- /dev/null +++ b/typedoc.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": [ + "src/app.ts", + "src/app-bridge.ts", + "src/types.ts", + "src/message-transport.ts", + "src/react/index.tsx" + ], + "out": "docs/api", + "gitRevision": "main", + "excludePrivate": true, + "excludeInternal": false, + "categorizeByGroup": true, + "navigationLinks": { + "GitHub": "https://github.com/modelcontextprotocol/ext-apps", + "Specification": "https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx" + }, + "readme": "README.md", + "includeVersion": true +}