From 37c3774b50b1f6a8015e9492f82d3fafc7600a81 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 30 Sep 2026 19:26:59 +0100 Subject: [PATCH 1/7] Reserve sandbox messages for tools a View exposes through WebMCP Adds ui/sandbox-list-tools, ui/sandbox-call-tool and ui/notifications/sandbox-tools-changed to the draft specification: the Sandbox proxy, which is the only party same-origin with the View, reads the tools the View registers with WebMCP (document.modelContext) and answers the Host itself. The standard tools/list and tools/call are untouched and keep reaching Views that answer them. The `tools` app capability and the View-handled tools/list, tools/call and notifications/tools/list_changed are marked deprecated in favor of WebMCP. Includes the matching spec types and regenerated schemas. Illustrates one of the options discussed in #797. --- specification/draft/apps.mdx | 644 +++++++++++++++++++++-------------- src/generated/schema.json | 70 ++++ src/generated/schema.test.ts | 30 ++ src/generated/schema.ts | 58 +++- src/spec.types.ts | 56 ++- src/types.ts | 12 + 6 files changed, 613 insertions(+), 257 deletions(-) diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index f2523f25e..c1e09f121 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -501,9 +501,10 @@ If the Host is a web page, it MUST wrap the View and communicate with it through - Block dangerous features (`object-src 'none'`) - Apply restrictive defaults if no CSP metadata is provided - If `permissions` is declared, the Sandbox MAY set the inner iframe's `allow` attribute accordingly -6. The Sandbox MUST forward messages sent by the Host to the View, and vice versa, for any method that doesn't start with `ui/notifications/sandbox-`. This includes lifecycle messages, e.g., `ui/initialize` request & `ui/notifications/initialized` notification both sent by the View. The Host MUST NOT send any request or notification to the View before it receives an `initialized` notification. +6. The Sandbox MUST forward messages sent by the Host to the View, and vice versa, for any method that doesn't start with `ui/notifications/sandbox-` or `ui/sandbox-`. This includes lifecycle messages, e.g., `ui/initialize` request & `ui/notifications/initialized` notification both sent by the View. The Host MUST NOT send any request or notification to the View before it receives an `initialized` notification. 7. The Sandbox SHOULD NOT create/send any requests to the Host or to the View (this would require synthesizing new request ids). 8. The Host MAY forward any message from the View (coming via the Sandbox) to the MCP Apps server, for any method that doesn't start with `ui/`. While the Host SHOULD ensure the View's MCP connection is spec-compliant, it MAY decide to block some messages or subject them to further user approval. +9. The Sandbox SHOULD serve the reserved `ui/sandbox-list-tools` and `ui/sandbox-call-tool` requests and send the `ui/notifications/sandbox-tools-changed` notification itself, from the View's WebMCP tools (see [Reserved Messages](#reserved-messages-sandbox-proxy) and [App-Registered Tools and WebMCP](#app-registered-tools-and-webmcp)). It MUST NOT forward these messages to the View. ### Standard MCP Messages @@ -511,19 +512,21 @@ UI iframes can use the following subset of standard MCP protocol messages. Note that `tools/call` and `tools/list` flow **bidirectionally**: - **App → Host → Server**: Apps call server tools (requires host `serverTools` capability) -- **Host → App**: Host calls app-registered tools (requires app `tools` capability) +- **Host → App** (**Deprecated**): Host calls app-registered tools (requires app `tools` capability) + +> **Deprecated:** The Host → App and App → Host directions below (app-registered tools handled by the View) are deprecated in favor of [WebMCP](https://webmachinelearning.github.io/webmcp/). Tools a View registers with WebMCP are reached through [reserved Sandbox messages](#reserved-messages-sandbox-proxy) instead. See [App-Registered Tools and WebMCP](#app-registered-tools-and-webmcp). **Tools:** - `tools/call` - Execute a tool (bidirectional) - **App → Host**: Call server tool via host proxy - - **Host → App**: Call app-registered tool + - **Host → App** (Deprecated): Call app-registered tool - `tools/list` - List available tools (bidirectional) - **App → Host**: List server tools - - **Host → App**: List app-registered tools + - **Host → App** (Deprecated): List app-registered tools - `notifications/tools/list_changed` - Notify when tool list changes (bidirectional) - **Server → Host → App**: Server tools changed - - **App → Host**: App-registered tools changed + - **App → Host** (Deprecated): App-registered tools changed **Resources:** @@ -550,7 +553,11 @@ When the View sends an `ui/initialize` request to the Host, it MUST include its interface McpUiAppCapabilities { /** Experimental features keyed by identifier. */ experimental?: Record; - /** App exposes MCP-style tools that the host can call. */ + /** + * App exposes MCP-style tools that the host can call. + * @deprecated Register tools with WebMCP (`document.modelContext.registerTool()`) + * instead. See "App-Registered Tools and WebMCP". + */ tools?: { /** App supports tools/list_changed notifications. */ listChanged?: boolean; @@ -1236,8 +1243,70 @@ Host behavior: - If multiple updates are received before the next user message, Host SHOULD only send the last update to the model - MAY display context updates to the user +#### App-Registered Tools and WebMCP + +> **Deprecated:** App-registered tools (the `tools` app capability, Host → App `tools/list` and `tools/call` handled by the View, and App → Host `notifications/tools/list_changed`) are deprecated in favor of [WebMCP](https://webmachinelearning.github.io/webmcp/); see [ext-apps#797](https://github.com/modelcontextprotocol/ext-apps/issues/797). The legacy messages are unchanged, so Views that still use them keep working, but they MAY be removed in a future revision of this specification. + +**Views:** + +- Views SHOULD expose tools to the Host with `document.modelContext.registerTool()` and SHOULD NOT declare the `tools` app capability. +- Views SHOULD feature-detect `document.modelContext`, which is provided by the Host (see below), not by the View. +- Views SHOULD return plain data from `execute()`; it is relayed to the Host as `structuredContent` (see the mapping below). + +```typescript +const controller = new AbortController(); +document.modelContext.registerTool( + { + name: "tictactoe_move", + description: "Make a move in the tic-tac-toe game", + inputSchema: { type: "object", properties: { position: { type: "number" } } }, + annotations: { readOnlyHint: false }, + async execute({ position }) { + return { board: makeMove(position) }; + }, + }, + { signal: controller.signal }, // abort the signal to unregister +); +``` + +> Non-normative: the SDK's deprecated `app.registerTool()` is a thin wrapper over `document.modelContext.registerTool()`. + +**Providing `document.modelContext`.** A View cannot polyfill WebMCP on its own: a registry that only the View can see is of no use to the Host. A Host that wants to surface View tools therefore provides `document.modelContext` to the View: + +- If the browser implements WebMCP, the native implementation is used (it wins over any polyfill). The Host MUST delegate the `tools` Permissions Policy feature to the nested frames (e.g. `allow="tools"` on the Sandbox iframe, which sets it on the inner View iframe), otherwise native registration is rejected. +- Otherwise the Host SHOULD inject a polyfill into the View HTML it passes to the Sandbox in `ui/notifications/sandbox-resource-ready`, before the View's own scripts. + +**Reserved Sandbox messages.** The Sandbox proxy is the only party that shares an origin with the View: the Host and Sandbox MUST have different origins (see [Sandbox proxy](#sandbox-proxy)), so the Host page cannot reach into the View's document. The Host therefore reads View tools through three [reserved messages](#reserved-messages-sandbox-proxy): the requests `ui/sandbox-list-tools` and `ui/sandbox-call-tool` (Host → Sandbox) and the notification `ui/notifications/sandbox-tools-changed` (Sandbox → Host). They are not `tools/list` and `tools/call`. + +- The Sandbox SHOULD answer them from the `document.modelContext` of the View's own frame, using WebMCP's consumer API (`getTools()`, `executeTool()` and the `toolchange` event), and send the notification when `toolchange` fires. It MUST NOT forward them to the View. +- The Sandbox MUST only expose tools registered in the View's own frame (WebMCP's native `getTools()` also returns tools of other frames). +- A Sandbox that cannot access the View's document (for instance, an inner iframe whose `sandbox` attribute omits `allow-same-origin`) cannot serve them. If it forwards them instead, the View, which does not implement them, answers with a method-not-found error. +- `tools/list` and `tools/call` are untouched: they still flow Host → Sandbox → View, where a View that declares the `tools` app capability answers them itself. A Host SHOULD NOT conclude that a View has no tools from the absence of that capability. +- A Host that embeds the View without a Sandbox proxy MAY access `document.modelContext` directly and apply the same mapping. + +**Mapping.** WebMCP tools and results map to MCP as follows: + +| WebMCP | MCP | +|--------|-----| +| `name`, `description` | `Tool.name`, `Tool.description` | +| `title` | `Tool.title`, only if non-empty | +| `inputSchema` | `Tool.inputSchema`, which MUST have `type: "object"`: a schema without it is completed, and an absent schema becomes `{ "type": "object", "properties": {} }` | +| `annotations.readOnlyHint` | `Tool.annotations.readOnlyHint` (no other annotation maps) | +| *(none)* | `Tool.outputSchema` is omitted, as WebMCP has none | +| `execute()` returns an object `v` | `CallToolResult` `{ content: [], structuredContent: v }` | +| `execute()` returns a non-object value `v` (including `null` and arrays) | `{ content: [], structuredContent: { "result": v } }` | +| `execute()` returns nothing | `{ content: [], structuredContent: { "result": null } }` | +| `execute()` throws or rejects | `{ content: [{ type: "text", text: }], isError: true }` (a native implementation may only expose a generic message) | +| `ui/sandbox-call-tool` names an unknown tool | JSON-RPC error `-32602` | + +Tool descriptors from the View that are malformed (for example, an invalid name or a non-string description) are omitted from the list. + +> Non-normative: in the SDK, `AppBridge.listWebMcpTools()` and `callWebMcpTool()` send the reserved requests, while the deprecated `listTools()` and `callTool()` keep sending `tools/list` and `tools/call`. + #### Requests (Host → App) +> **Deprecated:** The requests below, handled by the View itself, are deprecated. New Views SHOULD use WebMCP, whose tools the Host reaches through reserved Sandbox messages instead (see [App-Registered Tools and WebMCP](#app-registered-tools-and-webmcp)). + When Apps declare the `tools` capability, the Host can send standard MCP tool requests to the App: `tools/call` - Execute an App-registered tool @@ -1278,9 +1347,9 @@ When Apps declare the `tools` capability, the Host can send standard MCP tool re ``` **App Behavior:** -- Apps MUST implement `oncalltool` handler if they declare `tools` capability +- Apps MUST implement `oncalltool` handler if they declare `tools` capability (deprecated; prefer WebMCP) - Apps SHOULD validate tool names and arguments -- Apps MAY use `app.registerTool()` SDK helper for automatic validation +- Apps MAY use the deprecated `app.registerTool()` SDK helper for automatic validation - Apps SHOULD return `isError: true` for tool execution failures `tools/list` - List App-registered tools @@ -1321,7 +1390,7 @@ interface Tool { ``` **App Behavior:** -- Apps MUST implement `onlisttools` handler if they declare `tools` capability +- Apps MUST implement `onlisttools` handler if they declare `tools` capability (deprecated; prefer WebMCP) - Apps SHOULD return complete tool metadata including schemas - Apps MAY filter tools based on context or permissions @@ -1511,6 +1580,68 @@ These messages are reserved for web-based hosts that implement the recommended d These messages facilitate the communication between the outer sandbox proxy iframe and the host, enabling secure loading of untrusted HTML content. The `permissions` field maps to the inner iframe's `allow` attribute for Permission Policy features. +`ui/sandbox-list-tools` (Host → Sandbox Proxy) - List the tools the View exposes through WebMCP + +```typescript +// Request +{ + jsonrpc: "2.0", + id: 1, + method: "ui/sandbox-list-tools", + params: { + cursor?: string // Optional pagination cursor + } +} + +// Response: MCP ListToolsResult +{ + jsonrpc: "2.0", + id: 1, + result: { + tools: Array, // See the mapping in "App-Registered Tools and WebMCP" + nextCursor?: string + } +} +``` + +`ui/sandbox-call-tool` (Host → Sandbox Proxy) - Call a tool the View exposes through WebMCP + +```typescript +// Request +{ + jsonrpc: "2.0", + id: 2, + method: "ui/sandbox-call-tool", + params: { + name: string, // Name of the tool to execute + arguments?: object // Tool arguments + } +} + +// Response: MCP CallToolResult +{ + jsonrpc: "2.0", + id: 2, + result: { + content: Array, + structuredContent?: object, + isError?: boolean + } +} +``` + +`ui/notifications/sandbox-tools-changed` (Sandbox Proxy → Host) - The View's WebMCP tool list changed + +```typescript +{ + jsonrpc: "2.0", + method: "ui/notifications/sandbox-tools-changed", + params: {} +} +``` + +The Sandbox proxy answers and sends these three messages itself, from the View's WebMCP tools, and MUST NOT forward them to the View. They are distinct from the standard `tools/list`, `tools/call` and `notifications/tools/list_changed`, which are forwarded to and from the View as usual. See [App-Registered Tools and WebMCP](#app-registered-tools-and-webmcp) for how WebMCP tools and results map to these messages. + ### Lifecycle The typical lifecycle for rendering a UI resource: @@ -1564,7 +1695,7 @@ sequenceDiagram end ``` -Note: when the View is rendered inside a sandbox, the sandbox transparently passes messages between the View and the Host, except for messages named `ui/notifications/sandbox-*`. +Note: when the View is rendered inside a sandbox, the sandbox transparently passes messages between the View and the Host, except for messages named `ui/notifications/sandbox-*` or `ui/sandbox-*`. #### 3. Interactive Phase @@ -1738,6 +1869,8 @@ Note: Tools with `visibility: ["app"]` are hidden from the agent but remain call ### App-Provided Tools +> **Note:** Tools a View exposes now use [WebMCP](https://webmachinelearning.github.io/webmcp/) (`document.modelContext`). The legacy mechanism (`tools` app capability, `app.registerTool()`, View-handled `tools/list` and `tools/call`) is deprecated and described under [Legacy: View-Handled Tools](#legacy-view-handled-tools-deprecated). See [App-Registered Tools and WebMCP](#app-registered-tools-and-webmcp). + Apps can register their own tools that hosts and agents can call, making apps **introspectable and accessible** to the model. This complements the existing capability where apps call server tools (via host proxy). #### Motivation: Semantic Introspection @@ -1747,7 +1880,7 @@ Without tool registration, apps are opaque to the model: - The model therefore cannot query app state or discover what operations the app supports With tool registration, apps expose a semantic interface: -- The model discovers available operations via `tools/list` +- The model discovers available operations via the Host's tool listing (`ui/sandbox-list-tools`) - The model queries app state via tools (e.g., `get_board_state`) - The model executes actions via tools (e.g., `make_move`) - Apps return structured data rather than relying on the host to interpret rendered output @@ -1756,7 +1889,229 @@ This is *pull*-based and complements the *push*-based [`ui/update-model-context` #### App Tool Registration -Apps register tools using the SDK's `registerTool()` method: +Views register tools with [WebMCP](https://webmachinelearning.github.io/webmcp/); the Host reaches them through the Sandbox proxy (see [App-Registered Tools and WebMCP](#app-registered-tools-and-webmcp)): + +```typescript +const { signal } = new AbortController(); + +document.modelContext.registerTool( + { + name: "tictactoe_move", + description: "Make a move in the tic-tac-toe game", + inputSchema: { + type: "object", + properties: { + position: { type: "number", minimum: 0, maximum: 8 }, + player: { type: "string", enum: ["X", "O"] }, + }, + required: ["position", "player"], + }, + annotations: { readOnlyHint: false }, // This tool has side effects + async execute({ position, player }) { + const board = makeMove(position, player); + return { board, winner: checkWinner(board) }; // plain data, relayed as structuredContent + }, + }, + { signal }, +); +``` + +WebMCP tools have no `outputSchema` and Views return plain data from `execute()`. Views SHOULD validate the arguments they receive. + +#### Tool Lifecycle + +A tool is available while the View's document is loaded and the tool's registration signal has not been aborted. Aborting the signal unregisters the tool, and to change a tool's description or schema the View unregisters it and registers it again. Whenever the View's tool list changes, WebMCP fires `toolchange` and the Sandbox sends `ui/notifications/sandbox-tools-changed` to the Host. + +#### Complete Example: Introspectable Tic-Tac-Toe + +This example demonstrates how apps expose semantic interfaces through tools: + +```typescript +// Game state +let board: Array<"X" | "O" | null> = Array(9).fill(null); +let currentPlayer: "X" | "O" = "X"; +let moveHistory: number[] = []; + +const { signal } = new AbortController(); + +// Agent can query semantic state directly +document.modelContext.registerTool( + { + name: "get_board_state", + description: "Get current game state including board, current player, and winner", + annotations: { readOnlyHint: true }, + async execute() { + return { board, currentPlayer, winner: checkWinner(board), moveHistory }; + }, + }, + { signal }, +); + +// Agent can execute moves +document.modelContext.registerTool( + { + name: "make_move", + description: "Place a piece at the specified position", + inputSchema: { + type: "object", + properties: { position: { type: "number", minimum: 0, maximum: 8 } }, + required: ["position"], + }, + annotations: { readOnlyHint: false }, + async execute({ position }) { + if (board[position] !== null) { + throw new Error("Position already taken"); // becomes isError: true + } + + board[position] = currentPlayer; + moveHistory.push(position); + const winner = checkWinner(board); + currentPlayer = currentPlayer === "X" ? "O" : "X"; + + return { board, currentPlayer, winner, moveHistory }; + }, + }, + { signal }, +); + +// Agent can reset game +document.modelContext.registerTool( + { + name: "reset_game", + description: "Reset the game board to initial state", + annotations: { readOnlyHint: false }, + async execute() { + board = Array(9).fill(null); + currentPlayer = "X"; + moveHistory = []; + return { board, currentPlayer, moveHistory }; + }, + }, + { signal }, +); + +function checkWinner(board: Array<"X" | "O" | null>): "X" | "O" | "draw" | null { + const lines = [ + [0, 1, 2], [3, 4, 5], [6, 7, 8], // rows + [0, 3, 6], [1, 4, 7], [2, 5, 8], // columns + [0, 4, 8], [2, 4, 6] // diagonals + ]; + + for (const [a, b, c] of lines) { + if (board[a] && board[a] === board[b] && board[a] === board[c]) { + return board[a]; + } + } + + return board.every(cell => cell !== null) ? "draw" : null; +} +``` + +**Agent Interaction** (Host side, using the SDK; these calls send `ui/sandbox-list-tools` and `ui/sandbox-call-tool`): + +```typescript +// 1. Discover available operations +const { tools } = await bridge.listWebMcpTools(); +// → ["get_board_state", "make_move", "reset_game"] + +// 2. Query semantic state (not visual/DOM) +const state = await bridge.callWebMcpTool({ + name: "get_board_state", + arguments: {} +}); +// → structuredContent: { board: [null, null, null, ...], currentPlayer: 'X', winner: null, moveHistory: [] } + +// 3. Execute actions based on semantic understanding +if (state.structuredContent.board[4] === null) { + await bridge.callWebMcpTool({ + name: "make_move", + arguments: { position: 4 } + }); +} + +// 4. Query updated state +const newState = await bridge.callWebMcpTool({ + name: "get_board_state", + arguments: {} +}); +// → structuredContent: { board: [null, null, null, null, 'X', null, ...], currentPlayer: 'O', ... } +``` + +The agent interacts with the app through semantic operations rather than visual interpretation. + +#### Tool Flow Directions + +**Existing Flow (unchanged): App → Host → Server** + +Apps call server tools (proxied by host): + +```typescript +// App calls server tool +const result = await app.callServerTool("get_weather", { location: "NYC" }); +``` + +Requires host `serverTools` capability. + +**New Flow: Host/Agent → App** + +Host/Agent calls tools the View registered with WebMCP: + +```typescript +// Host calls app tool (sends ui/sandbox-call-tool to the Sandbox) +const result = await bridge.callWebMcpTool({ + name: "tictactoe_move", + arguments: { position: 4 } +}); +``` + +Does not require the app `tools` capability. Requires a Sandbox proxy that serves the [reserved tool messages](#reserved-messages-sandbox-proxy). + +**Key Distinction:** + +| Aspect | Server Tools | App Tools | +|--------|-------------|-----------| +| **Lifetime** | Persistent (server process) | Ephemeral (while app loaded) | +| **Source** | MCP Server | App JavaScript | +| **Trust** | Trusted | Sandboxed (untrusted) | +| **Discovery** | Server `tools/list` | `ui/sandbox-list-tools` (from the View's `document.modelContext`) | +| **When Available** | Always | Only while app is loaded | + +#### Use Cases + +**Introspection:** Agent queries app state semantically — the app's DOM is not exposed to the host or model + +**Voice mode:** Agent drives app interactions programmatically based on voice commands + +**Accessibility:** Structured state and operations more accessible than visual rendering + +**Complex workflows:** Agent discovers available operations and coordinates multi-step interactions + +**Stateful apps:** Apps expose operations (move, reset, query) rather than pushing state updates via messages + +#### Security Implications + +App tools run in **sandboxed iframes** (untrusted). See Security Implications section for detailed mitigations. + +Key considerations: +- App tools could provide misleading descriptions +- Tool namespacing needed to avoid conflicts with server tools +- Resource limits (max tools, execution timeouts) +- Audit trail for app tool invocations +- User confirmation for tools with side effects + +#### Relation to WebMCP + +This feature was inspired by [WebMCP](https://webmachinelearning.github.io/webmcp/), which lets web pages register JavaScript functions as tools via `document.modelContext.registerTool()`. Views now expose tools with WebMCP itself, and the parallel app-registered mechanism is deprecated; see [App-Registered Tools and WebMCP](#app-registered-tools-and-webmcp). + +See [ext-apps#35](https://github.com/modelcontextprotocol/ext-apps/issues/35) for discussion. + +#### Legacy: View-Handled Tools (Deprecated) + +> **Deprecated:** Everything in this subsection describes the legacy mechanism: the `tools` app capability, `app.registerTool()`, and `oncalltool`/`onlisttools` handlers that answer the standard `tools/list` and `tools/call` requests from the View. Use [WebMCP](#app-registered-tools-and-webmcp) instead. It is kept for Views built with older SDKs. + +##### SDK Registration + +Older SDKs let apps register tools with the SDK's `registerTool()` method, declaring the legacy `tools` capability: ```typescript import { App } from '@modelcontextprotocol/ext-apps'; @@ -1764,7 +2119,7 @@ import { z } from 'zod'; const app = new App( { name: "TicTacToe", version: "1.0.0" }, - { tools: { listChanged: true } } // Declare tool capability + { tools: { listChanged: true } } // Declare the legacy tool capability ); // Register a tool with schema validation @@ -1858,9 +2213,9 @@ app.onlisttools = async () => { }; ``` -#### Tool Lifecycle +##### Legacy Tool Lifecycle -Registered tools support dynamic lifecycle management: +Tools registered with `app.registerTool()` support dynamic lifecycle management: **Enable/Disable:** @@ -1874,7 +2229,7 @@ tool.disable(); tool.enable(); ``` -When a tool is disabled/enabled, the app automatically sends `notifications/tools/list_changed` (if the app declared `listChanged: true` capability). +When a tool is disabled/enabled, the app sends `notifications/tools/list_changed` (if the app declared the `listChanged: true` capability). **Update:** @@ -1895,13 +2250,13 @@ Updates also trigger `notifications/tools/list_changed`. tool.remove(); ``` -#### Long-Running App Tools +##### Long-Running App Tools -App tools are bounded by the app's render lifecycle. For work that may outlive a single render, app `tools/call` MAY be task-augmented per [core MCP Tasks](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks); hosts proxy the `tasks/*` methods to the app exactly as they proxy `tools/call`. Because the iframe can be torn down at any time, apps SHOULD delegate long-lived work to the server (via `callServerTool()`) and return the server's task handle, so the host can continue polling the server after teardown. +This applies to View-handled `tools/call` only; `ui/sandbox-call-tool` defines no task augmentation. App tools are bounded by the app's render lifecycle. For work that may outlive a single render, app `tools/call` MAY be task-augmented per [core MCP Tasks](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks); hosts proxy the `tasks/*` methods to the app exactly as they proxy `tools/call`. Because the iframe can be torn down at any time, apps SHOULD delegate long-lived work to the server (via `callServerTool()`) and return the server's task handle, so the host can continue polling the server after teardown. -#### Schema Validation +##### Schema Validation -The SDK validates input and output against any [Standard Schema](https://standardschema.dev/)–compatible library (Zod, ArkType, Valibot, …). Examples below use Zod: +The SDK's `app.registerTool()` validates input and output against any [Standard Schema](https://standardschema.dev/)–compatible library (Zod, ArkType, Valibot, …). Examples below use Zod: **Input Validation:** @@ -1949,233 +2304,6 @@ app.registerTool( If the callback returns data that doesn't match `outputSchema`, the tool returns an error. -#### Complete Example: Introspectable Tic-Tac-Toe - -This example demonstrates how apps expose semantic interfaces through tools: - -```typescript -import { App } from '@modelcontextprotocol/ext-apps'; -import { z } from 'zod'; - -// Game state -let board: Array<'X' | 'O' | null> = Array(9).fill(null); -let currentPlayer: 'X' | 'O' = 'X'; -let moveHistory: number[] = []; - -const app = new App( - { name: "TicTacToe", version: "1.0.0" }, - { tools: { listChanged: true } } -); - -// Agent can query semantic state directly -app.registerTool( - "get_board_state", - { - description: "Get current game state including board, current player, and winner", - outputSchema: z.object({ - board: z.array(z.enum(['X', 'O', null])).length(9), - currentPlayer: z.enum(['X', 'O']), - winner: z.enum(['X', 'O', 'draw', null]).nullable(), - moveHistory: z.array(z.number()) - }) - }, - async () => { - return { - content: [{ - type: "text", - text: `Board: ${board.map(c => c || '-').join('')}, Player: ${currentPlayer}` - }], - structuredContent: { - board, - currentPlayer, - winner: checkWinner(board), - moveHistory - } - }; - } -); - -// Agent can execute moves -app.registerTool( - "make_move", - { - description: "Place a piece at the specified position", - inputSchema: z.object({ - position: z.number().int().min(0).max(8) - }), - annotations: { readOnlyHint: false } - }, - async ({ position }) => { - if (board[position] !== null) { - return { - content: [{ type: "text", text: "Position already taken" }], - isError: true - }; - } - - board[position] = currentPlayer; - moveHistory.push(position); - const winner = checkWinner(board); - currentPlayer = currentPlayer === 'X' ? 'O' : 'X'; - - return { - content: [{ - type: "text", - text: `Player ${board[position]} moved to position ${position}` + - (winner ? `. ${winner} wins!` : '') - }], - structuredContent: { - board, - currentPlayer, - winner, - moveHistory - } - }; - } -); - -// Agent can reset game -app.registerTool( - "reset_game", - { - description: "Reset the game board to initial state", - annotations: { readOnlyHint: false } - }, - async () => { - board = Array(9).fill(null); - currentPlayer = 'X'; - moveHistory = []; - - return { - content: [{ type: "text", text: "Game reset" }], - structuredContent: { board, currentPlayer, moveHistory } - }; - } -); - -await app.connect(new PostMessageTransport(window.parent)); - -function checkWinner(board: Array<'X' | 'O' | null>): 'X' | 'O' | 'draw' | null { - const lines = [ - [0, 1, 2], [3, 4, 5], [6, 7, 8], // rows - [0, 3, 6], [1, 4, 7], [2, 5, 8], // columns - [0, 4, 8], [2, 4, 6] // diagonals - ]; - - for (const [a, b, c] of lines) { - if (board[a] && board[a] === board[b] && board[a] === board[c]) { - return board[a]; - } - } - - return board.every(cell => cell !== null) ? 'draw' : null; -} -``` - -**Agent Interaction:** - -```typescript -// 1. Discover available operations -const { tools } = await bridge.sendListTools({}); -// → ["get_board_state", "make_move", "reset_game"] - -// 2. Query semantic state (not visual/DOM) -const state = await bridge.sendCallTool({ - name: "get_board_state", - arguments: {} -}); -// → { board: [null, null, null, ...], currentPlayer: 'X', winner: null } - -// 3. Execute actions based on semantic understanding -if (state.structuredContent.board[4] === null) { - await bridge.sendCallTool({ - name: "make_move", - arguments: { position: 4 } - }); -} - -// 4. Query updated state -const newState = await bridge.sendCallTool({ - name: "get_board_state", - arguments: {} -}); -// → { board: [null, null, null, null, 'X', null, ...], currentPlayer: 'O', ... } -``` - -The agent interacts with the app through semantic operations rather than visual interpretation. - -#### Tool Flow Directions - -**Existing Flow (unchanged): App → Host → Server** - -Apps call server tools (proxied by host): - -```typescript -// App calls server tool -const result = await app.callServerTool("get_weather", { location: "NYC" }); -``` - -Requires host `serverTools` capability. - -**New Flow: Host/Agent → App** - -Host/Agent calls app-registered tools: - -```typescript -// Host calls app tool -const result = await bridge.sendCallTool({ - name: "tictactoe_move", - arguments: { position: 4 } -}); -``` - -Requires app `tools` capability. - -**Key Distinction:** - -| Aspect | Server Tools | App Tools | -|--------|-------------|-----------| -| **Lifetime** | Persistent (server process) | Ephemeral (while app loaded) | -| **Source** | MCP Server | App JavaScript | -| **Trust** | Trusted | Sandboxed (untrusted) | -| **Discovery** | Server `tools/list` | App `tools/list` (when app declares capability) | -| **When Available** | Always | Only while app is loaded | - -#### Use Cases - -**Introspection:** Agent queries app state semantically — the app's DOM is not exposed to the host or model - -**Voice mode:** Agent drives app interactions programmatically based on voice commands - -**Accessibility:** Structured state and operations more accessible than visual rendering - -**Complex workflows:** Agent discovers available operations and coordinates multi-step interactions - -**Stateful apps:** Apps expose operations (move, reset, query) rather than pushing state updates via messages - -#### Security Implications - -App tools run in **sandboxed iframes** (untrusted). See Security Implications section for detailed mitigations. - -Key considerations: -- App tools could provide misleading descriptions -- Tool namespacing needed to avoid conflicts with server tools -- Resource limits (max tools, execution timeouts) -- Audit trail for app tool invocations -- User confirmation for tools with side effects - -#### Relation to WebMCP - -This feature is inspired by [WebMCP](https://github.com/webmachinelearning/webmcp) (W3C incubation), which proposes allowing web pages to register JavaScript functions as tools via `navigator.modelContext.registerTool()`. - -Key differences: -- **WebMCP**: General web pages, browser API, manifest-based discovery -- **This spec**: MCP Apps, standard MCP messages, capability-based negotiation - -Similar to WebMCP but without turning the App (embedded page) into an MCP server - apps register tools within the App/Host architecture. - -See [ext-apps#35](https://github.com/modelcontextprotocol/ext-apps/issues/35) for discussion. - ### Client\<\>Server Capability Negotiation Clients and servers negotiate MCP Apps support through the standard MCP extensions capability mechanism (defined in SEP-1724). @@ -2287,9 +2415,11 @@ The host responds with its own capabilities, including support for proxying serv } ``` -**App Capability: `tools`** +**App Capability: `tools`** (Deprecated) -When present, the app can register tools that the host and agent can call. +> **Deprecated:** Prefer WebMCP (`document.modelContext`). Hosts SHOULD NOT conclude that a View has no tools from the absence of this capability, since WebMCP tools are reached through reserved Sandbox messages. See [App-Registered Tools and WebMCP](#app-registered-tools-and-webmcp). + +When present, the app handles `tools/list` and `tools/call` itself, so that the host and agent can call its tools. - `listChanged` (boolean, optional): If `true`, the app will send `notifications/tools/list_changed` when tools are added, removed, or modified @@ -2305,6 +2435,7 @@ These capabilities are independent - an app can have one, both, or neither. ```typescript interface McpUiAppCapabilities { + /** @deprecated Use WebMCP (`document.modelContext`) instead. */ tools?: { listChanged?: boolean; }; @@ -2325,11 +2456,10 @@ This specification defines the Minimum Viable Product (MVP) for MCP Apps. **Included in MVP:** -- **App-Provided Tools:** Apps can register tools via `app.registerTool()` that agents can call +- **App-Provided Tools:** Views expose tools via WebMCP (`document.modelContext.registerTool()`) that agents can call through reserved Sandbox messages. The legacy `app.registerTool()` / `tools` app capability mechanism is deprecated in favor of WebMCP. - Bidirectional tool flow (Apps consume server tools AND provide app tools) - - Full lifecycle management (enable/disable/update/remove) - - Schema validation via [Standard Schema](https://standardschema.dev/) (Zod, ArkType, Valibot, …) - - Tool list change notifications + - Registration lifecycle (register / unregister via `AbortSignal`) + - Tool list change notifications (`ui/notifications/sandbox-tools-changed`) **Content Types (deferred from MVP):** @@ -2445,6 +2575,8 @@ This proposal synthesizes feedback from the UI CWG and MCP-UI community, host im #### 6. App Tool Registration Support +> **Deprecated:** This decision is superseded by adopting WebMCP for tools a View exposes. The Host reaches them through reserved Sandbox messages (`ui/sandbox-list-tools`, `ui/sandbox-call-tool`), while the standard `tools/list` and `tools/call` described here remain for Views that declare the deprecated `tools` capability. See [App-Registered Tools and WebMCP](#app-registered-tools-and-webmcp). + **Decision:** Enable Apps to register tools using standard MCP `tools/call` and `tools/list` messages, making tools flow bidirectionally between Apps and Hosts. **Rationale:** @@ -2568,6 +2700,8 @@ const allowAttribute = allowList.join(' '); #### 5. App-Provided Tools Security +> **Deprecated:** App-provided tools are deprecated in favor of WebMCP. The considerations below apply equally to tools a Host surfaces from a View's `document.modelContext` (through the reserved Sandbox messages). + Apps can register their own tools that agents can call. Apps are forward-deployed emanations of server tools, running in the client context. Hosts need to decide how to handle approval for app tool calls. **Approval Considerations:** @@ -2612,7 +2746,7 @@ Hosts SHOULD implement the following protections for app-provided tools: - Limit maximum number of tools per app (recommended: 50) - Enforce execution timeouts for tool callbacks (recommended: 30 seconds) - Limit tool result sizes (recommended: 10 MB) - - Throttle `tools/list_changed` notifications to prevent spam + - Throttle tool list change notifications (`ui/notifications/sandbox-tools-changed`, `notifications/tools/list_changed`) to prevent spam 5. **Audit Trail:** - Log all app tool registrations with timestamps @@ -2638,7 +2772,7 @@ Hosts MAY implement different permission levels based on tool annotations: App tools MUST be tied to the app's lifecycle: -- Tools become available once the app advertises the `tools` capability in `ui/initialize` and the host issues `tools/list`; subsequent changes are signaled via `notifications/tools/list_changed` +- Tools become available once the View registers them with WebMCP and the host issues `ui/sandbox-list-tools`; subsequent changes are signaled via `ui/notifications/sandbox-tools-changed`. (Legacy: once the app advertises the `tools` capability in `ui/initialize` and the host issues `tools/list`; changes are signaled via `notifications/tools/list_changed`.) - Tools automatically disappear when app iframe is torn down - Hosts MUST NOT persist app tool registrations across sessions - Calling a tool from a closed app MUST return an error diff --git a/src/generated/schema.json b/src/generated/schema.json index 246ed7d9b..c72f3fdd3 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -4391,6 +4391,59 @@ }, "additionalProperties": {} }, + "McpUiSandboxCallToolRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "ui/sandbox-call-tool" + }, + "params": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the tool to call." + }, + "arguments": { + "description": "Arguments of the call.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["method", "params"], + "additionalProperties": false + }, + "McpUiSandboxListToolsRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "ui/sandbox-list-tools" + }, + "params": { + "type": "object", + "properties": { + "cursor": { + "description": "Pagination cursor from a previous result.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["method"], + "additionalProperties": false + }, "McpUiSandboxProxyReadyNotification": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", @@ -4501,6 +4554,23 @@ "required": ["method", "params"], "additionalProperties": false }, + "McpUiSandboxToolsChangedNotification": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "ui/notifications/sandbox-tools-changed" + }, + "params": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "required": ["method"], + "additionalProperties": false + }, "McpUiSizeChangedNotification": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/src/generated/schema.test.ts b/src/generated/schema.test.ts index 57d989fd0..e11478a9a 100644 --- a/src/generated/schema.test.ts +++ b/src/generated/schema.test.ts @@ -55,6 +55,18 @@ export type McpUiResourcePermissionsSchemaInferredType = z.infer< typeof generated.McpUiResourcePermissionsSchema >; +export type McpUiSandboxListToolsRequestSchemaInferredType = z.infer< + typeof generated.McpUiSandboxListToolsRequestSchema +>; + +export type McpUiSandboxCallToolRequestSchemaInferredType = z.infer< + typeof generated.McpUiSandboxCallToolRequestSchema +>; + +export type McpUiSandboxToolsChangedNotificationSchemaInferredType = z.infer< + typeof generated.McpUiSandboxToolsChangedNotificationSchema +>; + export type McpUiSizeChangedNotificationSchemaInferredType = z.infer< typeof generated.McpUiSizeChangedNotificationSchema >; @@ -213,6 +225,24 @@ expectType( expectType( {} as spec.McpUiResourcePermissions, ); +expectType( + {} as McpUiSandboxListToolsRequestSchemaInferredType, +); +expectType( + {} as spec.McpUiSandboxListToolsRequest, +); +expectType( + {} as McpUiSandboxCallToolRequestSchemaInferredType, +); +expectType( + {} as spec.McpUiSandboxCallToolRequest, +); +expectType( + {} as McpUiSandboxToolsChangedNotificationSchemaInferredType, +); +expectType( + {} as spec.McpUiSandboxToolsChangedNotification, +); expectType( {} as McpUiSizeChangedNotificationSchemaInferredType, ); diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 214b227c2..00b92aab3 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -338,6 +338,58 @@ export const McpUiResourcePermissionsSchema = z.object({ ), }); +/** + * @description Request (Host -> Sandbox proxy) for the tools the View exposes + * through WebMCP (`document.modelContext`). Answered by the Sandbox proxy, not + * forwarded to the View. The result is a `ListToolsResult`. + * @internal + * @see {@link app-bridge!AppBridge.listWebMcpTools `AppBridge.listWebMcpTools`} for the method that sends this request + */ +export const McpUiSandboxListToolsRequestSchema = z.object({ + method: z.literal("ui/sandbox-list-tools"), + params: z + .object({ + /** @description Pagination cursor from a previous result. */ + cursor: z + .string() + .optional() + .describe("Pagination cursor from a previous result."), + }) + .optional(), +}); + +/** + * @description Request (Host -> Sandbox proxy) to call a tool the View exposes + * through WebMCP. Answered by the Sandbox proxy, not forwarded to the View. The + * result is a `CallToolResult`: `content` is empty and `structuredContent` is + * the value the tool returned. + * @internal + * @see {@link app-bridge!AppBridge.callWebMcpTool `AppBridge.callWebMcpTool`} for the method that sends this request + */ +export const McpUiSandboxCallToolRequestSchema = z.object({ + method: z.literal("ui/sandbox-call-tool"), + params: z.object({ + /** @description Name of the tool to call. */ + name: z.string().describe("Name of the tool to call."), + /** @description Arguments of the call. */ + arguments: z + .record(z.string(), z.unknown()) + .optional() + .describe("Arguments of the call."), + }), +}); + +/** + * @description Notification (Sandbox proxy -> Host) that the tools the View + * exposes through WebMCP changed. + * @internal + * @see {@link app-bridge!AppBridge.onwebmcptoolschange `AppBridge.onwebmcptoolschange`} + */ +export const McpUiSandboxToolsChangedNotificationSchema = z.object({ + method: z.literal("ui/notifications/sandbox-tools-changed"), + params: z.object({}).optional(), +}); + /** * @description Notification of UI size changes (View -> Host). * @see {@link app!App.sendSizeChanged `App.sendSizeChanged`} for the method to send this from View @@ -598,7 +650,11 @@ export const McpUiAppCapabilitiesSchema = z.object({ ) .optional() .describe("Experimental features keyed by identifier."), - /** @description App exposes MCP-style tools that the host can call. */ + /** + * @description App exposes MCP-style tools that the host can call. + * @deprecated Expose tools with WebMCP (`document.modelContext.registerTool()`) + * instead. Hosts should not gate `tools/list` / `tools/call` on this capability. + */ tools: z .object({ /** @description App supports tools/list_changed notifications. */ diff --git a/src/spec.types.ts b/src/spec.types.ts index 0c4539637..c6e3d28c4 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -258,6 +258,50 @@ export interface McpUiSandboxResourceReadyNotification { }; } +/** + * @description Request (Host -> Sandbox proxy) for the tools the View exposes + * through WebMCP (`document.modelContext`). Answered by the Sandbox proxy, not + * forwarded to the View. The result is a `ListToolsResult`. + * @internal + * @see {@link app-bridge!AppBridge.listWebMcpTools `AppBridge.listWebMcpTools`} for the method that sends this request + */ +export interface McpUiSandboxListToolsRequest { + method: "ui/sandbox-list-tools"; + params?: { + /** @description Pagination cursor from a previous result. */ + cursor?: string; + }; +} + +/** + * @description Request (Host -> Sandbox proxy) to call a tool the View exposes + * through WebMCP. Answered by the Sandbox proxy, not forwarded to the View. The + * result is a `CallToolResult`: `content` is empty and `structuredContent` is + * the value the tool returned. + * @internal + * @see {@link app-bridge!AppBridge.callWebMcpTool `AppBridge.callWebMcpTool`} for the method that sends this request + */ +export interface McpUiSandboxCallToolRequest { + method: "ui/sandbox-call-tool"; + params: { + /** @description Name of the tool to call. */ + name: string; + /** @description Arguments of the call. */ + arguments?: { [key: string]: unknown }; + }; +} + +/** + * @description Notification (Sandbox proxy -> Host) that the tools the View + * exposes through WebMCP changed. + * @internal + * @see {@link app-bridge!AppBridge.onwebmcptoolschange `AppBridge.onwebmcptoolschange`} + */ +export interface McpUiSandboxToolsChangedNotification { + method: "ui/notifications/sandbox-tools-changed"; + params?: {}; +} + /** * @description Notification of UI size changes (View -> Host). * @see {@link app!App.sendSizeChanged `App.sendSizeChanged`} for the method to send this from View @@ -538,7 +582,11 @@ export interface McpUiHostCapabilities { export interface McpUiAppCapabilities { /** @description Experimental features keyed by identifier. */ experimental?: Record; - /** @description App exposes MCP-style tools that the host can call. */ + /** + * @description App exposes MCP-style tools that the host can call. + * @deprecated Expose tools with WebMCP (`document.modelContext.registerTool()`) + * instead. Hosts should not gate `tools/list` / `tools/call` on this capability. + */ tools?: { /** @description App supports tools/list_changed notifications. */ listChanged?: boolean; @@ -819,6 +867,12 @@ export const SANDBOX_PROXY_READY_METHOD: McpUiSandboxProxyReadyNotification["met "ui/notifications/sandbox-proxy-ready"; export const SANDBOX_RESOURCE_READY_METHOD: McpUiSandboxResourceReadyNotification["method"] = "ui/notifications/sandbox-resource-ready"; +export const SANDBOX_LIST_TOOLS_METHOD: McpUiSandboxListToolsRequest["method"] = + "ui/sandbox-list-tools"; +export const SANDBOX_CALL_TOOL_METHOD: McpUiSandboxCallToolRequest["method"] = + "ui/sandbox-call-tool"; +export const SANDBOX_TOOLS_CHANGED_METHOD: McpUiSandboxToolsChangedNotification["method"] = + "ui/notifications/sandbox-tools-changed"; export const SIZE_CHANGED_METHOD: McpUiSizeChangedNotification["method"] = "ui/notifications/size-changed"; export const TOOL_INPUT_METHOD: McpUiToolInputNotification["method"] = diff --git a/src/types.ts b/src/types.ts index e4ed629c4..c8f838678 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,8 +48,11 @@ import type { McpUiRequestTeardownNotification, McpUiResourceTeardownRequest, McpUiResourceTeardownResult, + McpUiSandboxCallToolRequest, + McpUiSandboxListToolsRequest, McpUiSandboxProxyReadyNotification, McpUiSandboxResourceReadyNotification, + McpUiSandboxToolsChangedNotification, McpUiSizeChangedNotification, McpUiToolCancelledNotification, McpUiToolInputNotification, @@ -64,8 +67,11 @@ export { OPEN_LINK_METHOD, DOWNLOAD_FILE_METHOD, MESSAGE_METHOD, + SANDBOX_CALL_TOOL_METHOD, + SANDBOX_LIST_TOOLS_METHOD, SANDBOX_PROXY_READY_METHOD, SANDBOX_RESOURCE_READY_METHOD, + SANDBOX_TOOLS_CHANGED_METHOD, SIZE_CHANGED_METHOD, TOOL_INPUT_METHOD, TOOL_INPUT_PARTIAL_METHOD, @@ -91,8 +97,11 @@ export { type McpUiMessageResult, type McpUiUpdateModelContextRequest, type McpUiSupportedContentBlockModalities, + type McpUiSandboxCallToolRequest, + type McpUiSandboxListToolsRequest, type McpUiSandboxProxyReadyNotification, type McpUiSandboxResourceReadyNotification, + type McpUiSandboxToolsChangedNotification, type McpUiSizeChangedNotification, type McpUiToolInputNotification, type McpUiToolInputPartialNotification, @@ -132,8 +141,11 @@ export { McpUiMessageResultSchema, McpUiUpdateModelContextRequestSchema, McpUiSupportedContentBlockModalitiesSchema, + McpUiSandboxCallToolRequestSchema, + McpUiSandboxListToolsRequestSchema, McpUiSandboxProxyReadyNotificationSchema, McpUiSandboxResourceReadyNotificationSchema, + McpUiSandboxToolsChangedNotificationSchema, McpUiSizeChangedNotificationSchema, McpUiToolInputNotificationSchema, McpUiToolInputPartialNotificationSchema, From d26081883ae7bc96a2206d0cd59ab4ac376e0cb2 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 30 Sep 2026 19:27:00 +0100 Subject: [PATCH 2/7] Expose View-registered tools through WebMCP App.registerTool() becomes a deprecated wrapper over document.modelContext.registerTool(): the App no longer serves the tool over tools/list / tools/call and no longer declares the `tools` capability on its behalf. A callback's structuredContent (or its content) becomes the WebMCP result; isError rejects. App.oncalltool, App.onlisttools and App.sendToolListChanged are deprecated too, and the first two warn once per App. Host side, AppBridge.sendSandboxResourceReady() injects a small document.modelContext polyfill into the View HTML by default (`modelContextPolyfill: false` opts out; a native implementation wins). createModelContextRelay() is the helper a Sandbox proxy uses to read the View's tools and answer the new reserved messages, and AppBridge gains listWebMcpTools(), callWebMcpTool() and onwebmcptoolschange. listTools() and callTool() are deprecated. A tool result is {content: [], structuredContent: value}; a tool that throws is an isError result. This is a breaking change in behavior: a View on this SDK that uses registerTool exposes no tools on a Host that has not adopted the polyfill injection and the relay. --- src/app-bridge.test.ts | 1210 +++++++---------------------- src/app-bridge.ts | 109 ++- src/app-webmcp.test.ts | 680 +++++++++++++++++ src/app.examples.ts | 2 +- src/app.test.ts | 28 +- src/app.ts | 241 +++--- src/model-context-host.test.ts | 1309 ++++++++++++++++++++++++++++++++ src/model-context-host.ts | 440 +++++++++++ src/webmcp.ts | 62 ++ src/wire-compat.test.ts | 29 +- 10 files changed, 3046 insertions(+), 1064 deletions(-) create mode 100644 src/app-webmcp.test.ts create mode 100644 src/model-context-host.test.ts create mode 100644 src/model-context-host.ts create mode 100644 src/webmcp.ts diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index a87b0e7db..ec955802d 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -4,6 +4,7 @@ import { Server, type ServerCapabilities } from "@modelcontextprotocol/server"; import { z } from "zod/v4"; import { App } from "./app.js"; +import { injectModelContextPolyfill } from "./model-context-host.js"; import { LATEST_PROTOCOL_VERSION } from "./types.js"; import { AppBridge, @@ -766,289 +767,21 @@ describe("App <-> AppBridge integration", () => { }); }); - describe("App tool registration", () => { + describe("App tools over the wire (deprecated raw handlers)", () => { + let warn: ReturnType>; + beforeEach(async () => { - app = new App( - testAppInfo, - { tools: { listChanged: true } }, - { autoResize: false }, - ); + // Assigning oncalltool/onlisttools logs a one-time deprecation warning. + warn = spyOn(console, "warn").mockImplementation(() => {}); await bridge.connect(bridgeTransport); }); - it("registerTool creates a registered tool", async () => { - const InputSchema = z.object({ name: z.string() }); - const OutputSchema = z.object({ greeting: z.string() }); - - const tool = app.registerTool( - "greet", - { - title: "Greet User", - description: "Greets a user by name", - inputSchema: InputSchema, - outputSchema: OutputSchema, - }, - async (args: any) => ({ - content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], - structuredContent: { greeting: `Hello, ${args.name}!` }, - }), - ); - - expect(tool.title).toBe("Greet User"); - expect(tool.description).toBe("Greets a user by name"); - expect(tool.enabled).toBe(true); - }); - - it("registered tool can be enabled and disabled", async () => { - await app.connect(appTransport); - - const tool = app.registerTool( - "test-tool", - { - description: "Test tool", - }, - async (_extra: any) => ({ content: [] }), - ); - - expect(tool.enabled).toBe(true); - - tool.disable(); - expect(tool.enabled).toBe(false); - - tool.enable(); - expect(tool.enabled).toBe(true); - }); - - it("registered tool can be updated", async () => { - await app.connect(appTransport); - - const tool = app.registerTool( - "test-tool", - { - description: "Original description", - }, - async (_extra: any) => ({ content: [] }), - ); - - expect(tool.description).toBe("Original description"); - - tool.update({ description: "Updated description" }); - expect(tool.description).toBe("Updated description"); - }); - - it("registered tool can be removed", async () => { - await app.connect(appTransport); - - const tool = app.registerTool( - "test-tool", - { - description: "Test tool", - }, - async (_extra: any) => ({ content: [] }), - ); - - tool.remove(); - // Tool should no longer be registered (internal check) - }); - - it("registerTool throws on duplicate name", () => { - app.registerTool("dup", {}, async () => ({ content: [] })); - expect(() => - app.registerTool("dup", {}, async () => ({ content: [] })), - ).toThrow(/already registered/); - }); - - it("enable/disable/update/remove pre-connect do not throw", () => { - const tool = app.registerTool("t", {}, async () => ({ content: [] })); - expect(() => tool.disable()).not.toThrow(); - expect(() => tool.enable()).not.toThrow(); - expect(() => tool.update({ description: "x" })).not.toThrow(); - expect(() => tool.remove()).not.toThrow(); - }); - - it("callback without inputSchema receives extra as first arg", async () => { - await app.connect(appTransport); - let receivedExtra: any; - app.registerTool("noargs", {}, async (extra: any) => { - receivedExtra = extra; - return { content: [] }; - }); - await bridge.callTool({ name: "noargs", arguments: {} }); - expect(receivedExtra).toBeDefined(); - expect(receivedExtra.mcpReq.signal).toBeInstanceOf(AbortSignal); - }); - - it("isError result skips output schema validation", async () => { - await app.connect(appTransport); - app.registerTool( - "errs", - { outputSchema: z.object({ ok: z.boolean() }) }, - async () => ({ - content: [{ type: "text" as const, text: "boom" }], - isError: true, - }), - ); - const res = await bridge.callTool({ name: "errs", arguments: {} }); - expect(res.isError).toBe(true); - expect(res.structuredContent).toBeUndefined(); - }); - - it("stale handle remove() does not delete a re-registered tool", async () => { - const t1 = app.registerTool("phoenix", {}, async () => ({ content: [] })); - t1.remove(); - app.registerTool("phoenix", {}, async () => ({ content: [] })); - t1.remove(); - await app.connect(appTransport); - const list = await bridge.listTools({}); - expect(list.tools.map((t) => t.name)).toContain("phoenix"); - }); - - it("host omitting arguments defaults to empty object", async () => { - await app.connect(appTransport); - let received: unknown; - app.registerTool( - "noargs2", - { inputSchema: z.object({}) }, - async (args) => { - received = args; - return { content: [] }; - }, - ); - await bridge.callTool({ name: "noargs2" }); - expect(received).toEqual({}); - }); - - it("update({inputSchema}) is honored by handler validation", async () => { - await app.connect(appTransport); - const tool = app.registerTool( - "evolving", - { inputSchema: z.object({ a: z.string() }) }, - async (args: any) => ({ - content: [{ type: "text" as const, text: JSON.stringify(args) }], - }), - ); - expect( - bridge.callTool({ name: "evolving", arguments: { a: 123 } }), - ).rejects.toThrow(/Invalid input/); - tool.update({ inputSchema: z.object({ a: z.number() }) }); - const result = await bridge.callTool({ - name: "evolving", - arguments: { a: 123 }, - }); - expect(result.content[0]).toEqual({ type: "text", text: '{"a":123}' }); - }); - - it("tool throws error when disabled and called", async () => { - await app.connect(appTransport); - - const tool = app.registerTool( - "test-tool", - { - description: "Test tool", - }, - async (_extra: any) => ({ content: [] }), - ); - - tool.disable(); - - const mockExtra = { - signal: new AbortController().signal, - requestId: "test", - sendNotification: async () => {}, - sendRequest: async () => ({}), - } as any; - - expect((tool.handler as any)(mockExtra)).rejects.toThrow( - "Tool test-tool is disabled", - ); - }); - - it("tool validates input schema", async () => { - const InputSchema = z.object({ name: z.string() }); - - const tool = app.registerTool( - "greet", - { - inputSchema: InputSchema, - }, - async (args: any) => ({ - content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], - }), - ); - - // Create a mock RequestHandlerExtra - const mockExtra = { - signal: new AbortController().signal, - requestId: "test", - sendNotification: async () => {}, - sendRequest: async () => ({}), - } as any; - - // Valid input should work - expect( - (tool.handler as any)({ name: "Alice" }, mockExtra), - ).resolves.toBeDefined(); - - // Invalid input should fail - expect( - (tool.handler as any)({ invalid: "field" }, mockExtra), - ).rejects.toThrow("Invalid input for tool greet"); - }); - - it("tool validates output schema", async () => { - const OutputSchema = z.object({ greeting: z.string() }); - - const tool = app.registerTool( - "greet", - { - outputSchema: OutputSchema, - }, - async (_extra: any) => ({ - content: [{ type: "text" as const, text: "Hello!" }], - structuredContent: { greeting: "Hello!" }, - }), - ); - - // Create a mock RequestHandlerExtra - const mockExtra = { - signal: new AbortController().signal, - requestId: "test", - sendNotification: async () => {}, - sendRequest: async () => ({}), - } as any; - - // Valid output should work - expect((tool.handler as any)(mockExtra)).resolves.toBeDefined(); - }); - - it("tool enable/disable/update/remove trigger sendToolListChanged", async () => { - await app.connect(appTransport); - - const tool = app.registerTool( - "test-tool", - { - description: "Test tool", - }, - async (_extra: any) => ({ content: [] }), - ); - - // The methods should not throw when connected - expect(() => tool.disable()).not.toThrow(); - expect(() => tool.enable()).not.toThrow(); - expect(() => tool.update({ description: "Updated" })).not.toThrow(); - expect(() => tool.remove()).not.toThrow(); - }); - }); - - describe("AppBridge -> App tool requests", () => { - beforeEach(async () => { - await bridge.connect(bridgeTransport); + afterEach(() => { + warn.mockRestore(); }); it("bridge.callTool calls app.oncalltool handler", async () => { - // App needs tool capabilities to handle tool calls - const appCapabilities = { tools: {} }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); + app = new App(testAppInfo, { tools: {} }, { autoResize: false }); const receivedCalls: unknown[] = []; @@ -1077,32 +810,18 @@ describe("App <-> AppBridge integration", () => { }); it("bridge.listTools calls app.onlisttools handler", async () => { - // App needs tool capabilities to handle tool list requests - const appCapabilities = { tools: {} }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); + app = new App(testAppInfo, { tools: {} }, { autoResize: false }); const receivedCalls: unknown[] = []; app.onlisttools = async (params, _extra) => { receivedCalls.push(params); return { - tools: [ - { - name: "tool1", - description: "First tool", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "tool2", - description: "Second tool", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "tool3", - description: "Third tool", - inputSchema: { type: "object", properties: {} }, - }, - ], + tools: ["tool1", "tool2", "tool3"].map((name) => ({ + name, + description: `The ${name} tool`, + inputSchema: { type: "object" as const, properties: {} }, + })), }; }; @@ -1111,715 +830,294 @@ describe("App <-> AppBridge integration", () => { const result = await bridge.listTools({}); expect(receivedCalls).toHaveLength(1); - expect(result.tools).toHaveLength(3); - expect(result.tools[0].name).toBe("tool1"); - expect(result.tools[1].name).toBe("tool2"); - expect(result.tools[2].name).toBe("tool3"); + expect(result.tools.map((t) => t.name)).toEqual([ + "tool1", + "tool2", + "tool3", + ]); }); - }); - describe("App tool capabilities", () => { - it("App with tool capabilities can handle tool calls", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - const receivedCalls: unknown[] = []; + it("surfaces an error thrown by the oncalltool handler to the host", async () => { + app = new App(testAppInfo, { tools: {} }, { autoResize: false }); app.oncalltool = async (params) => { - receivedCalls.push(params); - return { - content: [{ type: "text", text: "Success" }], - }; + throw new Error(`Unknown tool: ${params.name}`); }; - - await bridge.connect(bridgeTransport); await app.connect(appTransport); - await bridge.callTool({ - name: "test-tool", - arguments: {}, - }); - - expect(receivedCalls).toHaveLength(1); + await expect(bridge.callTool({ name: "nope" })).rejects.toThrow( + "Unknown tool: nope", + ); }); - it("registered tool is invoked via oncalltool", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - const tool = app.registerTool( - "greet", - { - description: "Greets user", - inputSchema: z.object({ name: z.string() }), - }, - async (args: any) => ({ - content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], - }), - ); + it("leaves declaring the tools capability to the caller: the raw handlers need it, the host sees it", async () => { + expect(() => { + app.oncalltool = async () => ({ content: [] }); + }).toThrow(/tool capability/); + expect(() => { + app.onlisttools = async () => ({ tools: [] }); + }).toThrow(/tool capability/); - app.oncalltool = async (params, extra) => { - if (params.name === "greet") { - return await (tool.handler as any)(params.arguments || {}, extra); - } - throw new Error(`Unknown tool: ${params.name}`); - }; + const capabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, capabilities, { autoResize: false }); + app.oncalltool = async () => ({ content: [] }); + await app.connect(appTransport); + expect(bridge.getAppCapabilities()?.tools).toEqual({ listChanged: true }); + }); - await bridge.connect(bridgeTransport); + it("app.sendToolListChanged() notifies the host", async () => { + app = new App( + testAppInfo, + { tools: { listChanged: true } }, + { autoResize: false }, + ); + const received: unknown[] = []; + bridge.setNotificationHandler( + "notifications/tools/list_changed", + (notification) => void received.push(notification.params), + ); await app.connect(appTransport); - const result = await bridge.callTool({ - name: "greet", - arguments: { name: "Alice" }, - }); + await app.sendToolListChanged(); + await flush(); - expect(result.content).toEqual([{ type: "text", text: "Hello, Alice!" }]); + expect(received).toHaveLength(1); }); }); - describe("Automatic request handlers", () => { + describe("WebMCP tools (reserved Sandbox requests)", () => { + /** Stand-in for the Sandbox proxy's relay: answers the reserved requests. */ + const answerWith = ( + method: "ui/sandbox-list-tools" | "ui/sandbox-call-tool", + handler: (params: any) => unknown, + ) => + app.setRequestHandler( + method, + { params: z.any(), result: z.any() }, + handler, + ); + beforeEach(async () => { await bridge.connect(bridgeTransport); }); - describe("oncalltool automatic handler", () => { - it("automatically calls registered tool without manual oncalltool setup", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - // Register a tool - app.registerTool( - "greet", - { - description: "Greets user", - inputSchema: z.object({ name: z.string() }), - }, - async (args: any) => ({ - content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], - }), - ); - - await app.connect(appTransport); - - // Call the tool through bridge - should work automatically - const result = await bridge.callTool({ - name: "greet", - arguments: { name: "Bob" }, - }); - - expect(result.content).toEqual([{ type: "text", text: "Hello, Bob!" }]); - }); - - it("throws error when calling non-existent tool", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - // Register a tool to initialize handlers - app.registerTool("existing-tool", {}, async (_args: any) => ({ - content: [], - })); - - await app.connect(appTransport); - - // Try to call a tool that doesn't exist - expect( - bridge.callTool({ - name: "nonexistent", - arguments: {}, - }), - ).rejects.toThrow("Tool nonexistent not found"); - }); - - it("handles multiple registered tools correctly", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - // Register multiple tools - app.registerTool( - "add", - { - description: "Add two numbers", - inputSchema: z.object({ a: z.number(), b: z.number() }), - }, - async (args: any) => ({ - content: [ - { - type: "text" as const, - text: `Result: ${args.a + args.b}`, - }, - ], - structuredContent: { result: args.a + args.b }, - }), - ); - - app.registerTool( - "multiply", - { - description: "Multiply two numbers", - inputSchema: z.object({ a: z.number(), b: z.number() }), - }, - async (args: any) => ({ - content: [ - { - type: "text" as const, - text: `Result: ${args.a * args.b}`, - }, - ], - structuredContent: { result: args.a * args.b }, - }), - ); - - await app.connect(appTransport); - - // Call first tool - const addResult = await bridge.callTool({ - name: "add", - arguments: { a: 5, b: 3 }, - }); - expect(addResult.content).toEqual([ - { type: "text", text: "Result: 8" }, - ]); - - // Call second tool - const multiplyResult = await bridge.callTool({ - name: "multiply", - arguments: { a: 5, b: 3 }, - }); - expect(multiplyResult.content).toEqual([ - { type: "text", text: "Result: 15" }, - ]); - }); - - it("respects tool enable/disable state", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - const tool = app.registerTool( - "test-tool", - { - description: "Test tool", - }, - async (_args: any) => ({ - content: [{ type: "text" as const, text: "Success" }], - }), - ); - - await app.connect(appTransport); - - // Should work when enabled - expect( - bridge.callTool({ name: "test-tool", arguments: {} }), - ).resolves.toBeDefined(); - - // Disable tool - tool.disable(); - - // Should throw when disabled - expect( - bridge.callTool({ name: "test-tool", arguments: {} }), - ).rejects.toThrow("Tool test-tool is disabled"); - }); - - it("validates input schema through automatic handler", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - app.registerTool( - "strict-tool", - { - description: "Requires specific input", - inputSchema: z.object({ - required: z.string(), - optional: z.number().optional(), - }) as any, - }, - async (args: any) => ({ - content: [{ type: "text" as const, text: `Got: ${args.required}` }], - }), - ); - - await app.connect(appTransport); - - // Valid input should work - expect( - bridge.callTool({ - name: "strict-tool", - arguments: { required: "hello" }, - }), - ).resolves.toBeDefined(); - - // Invalid input should fail - expect( - bridge.callTool({ - name: "strict-tool", - arguments: { wrong: "field" }, - }), - ).rejects.toThrow("Invalid input for tool strict-tool"); - }); - - it("validates output schema through automatic handler", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - app.registerTool( - "validated-output", - { - description: "Has output validation", - outputSchema: z.object({ - status: z.enum(["success", "error"]), - }) as any, - }, - async (_args: any) => ({ - content: [{ type: "text" as const, text: "Done" }], - structuredContent: { status: "success" }, - }), - ); - - await app.connect(appTransport); - - // Valid output should work - const result = await bridge.callTool({ - name: "validated-output", - arguments: {}, - }); - expect(result).toBeDefined(); + it("listWebMcpTools sends ui/sandbox-list-tools and parses a ListToolsResult", async () => { + const received: unknown[] = []; + answerWith("ui/sandbox-list-tools", (params) => { + received.push(params); + return { + tools: [{ name: "t", inputSchema: { type: "object" } }], + nextCursor: "next", + }; }); + await app.connect(appTransport); - it("works after tool is removed and re-registered", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - const tool = app.registerTool( - "dynamic-tool", - {}, - async (_args: any) => ({ - content: [{ type: "text" as const, text: "Version 1" }], - }), - ); - - await app.connect(appTransport); - - // First version - let result = await bridge.callTool({ - name: "dynamic-tool", - arguments: {}, - }); - expect(result.content).toEqual([{ type: "text", text: "Version 1" }]); - - // Remove tool - tool.remove(); - - // Should fail after removal - expect( - bridge.callTool({ name: "dynamic-tool", arguments: {} }), - ).rejects.toThrow("Tool dynamic-tool not found"); - - // Re-register with different behavior - app.registerTool("dynamic-tool", {}, async (_args: any) => ({ - content: [{ type: "text" as const, text: "Version 2" }], - })); - - // Should work with new version - result = await bridge.callTool({ - name: "dynamic-tool", - arguments: {}, - }); - expect(result.content).toEqual([{ type: "text", text: "Version 2" }]); - }); + const result = await bridge.listWebMcpTools({ cursor: "c1" }); + expect(received).toEqual([{ cursor: "c1" }]); + expect(result.tools.map((t) => t.name)).toEqual(["t"]); + expect(result.nextCursor).toBe("next"); + await bridge.listWebMcpTools(); // params are optional + expect(received).toHaveLength(2); }); - describe("onlisttools automatic handler", () => { - it("automatically returns list of registered tool names", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - // Register some tools - app.registerTool("tool1", {}, async (_args: any) => ({ - content: [], - })); - app.registerTool("tool2", {}, async (_args: any) => ({ - content: [], - })); - app.registerTool("tool3", {}, async (_args: any) => ({ - content: [], - })); - - await app.connect(appTransport); - - const result = await bridge.listTools({}); - - expect(result.tools).toHaveLength(3); - expect(result.tools.map((t) => t.name)).toContain("tool1"); - expect(result.tools.map((t) => t.name)).toContain("tool2"); - expect(result.tools.map((t) => t.name)).toContain("tool3"); + it("callWebMcpTool sends ui/sandbox-call-tool and parses a CallToolResult, isError included", async () => { + const received: any[] = []; + answerWith("ui/sandbox-call-tool", (params) => { + received.push(params); + return params.name === "fails" + ? { content: [{ type: "text", text: "no" }], isError: true } + : { content: [], structuredContent: { sum: 3 } }; }); + await app.connect(appTransport); - it("emits core MCP Tool fields (title, outputSchema only when provided)", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - app.registerTool( - "with-output", - { - title: "With Output", - description: "has structured output", - outputSchema: z.object({ ok: z.boolean() }), - }, - async () => ({ - content: [], - structuredContent: { ok: true }, - }), - ); - app.registerTool( - "no-output", - { description: "no structured output" }, - async () => ({ content: [] }), - ); - - await app.connect(appTransport); - const result = await bridge.listTools({}); - const byName = Object.fromEntries(result.tools.map((t) => [t.name, t])); - - expect(byName["with-output"].title).toBe("With Output"); - expect(byName["with-output"].inputSchema).toBeDefined(); - expect(byName["with-output"].outputSchema).toBeDefined(); - // outputSchema is optional in core MCP — omitted when not declared - expect(byName["no-output"]).not.toHaveProperty("outputSchema"); - expect(byName["no-output"].inputSchema).toBeDefined(); - }); - - it("accepts any Standard Schema implementation, not only zod", async () => { - // Hand-rolled StandardSchemaWithJSON — proves registerTool has no - // zod-specific runtime path. Any library implementing the spec - // (ArkType, Valibot, …) works the same way. - type Point = { x: number; y: number }; - const PointSchema = { - "~standard": { - version: 1 as const, - vendor: "test", - types: undefined as - | undefined - | { readonly input: Point; readonly output: Point }, - validate: (v: unknown) => - typeof v === "object" && - v !== null && - typeof (v as any).x === "number" && - typeof (v as any).y === "number" - ? { value: v as { x: number; y: number } } - : { issues: [{ message: "expected {x:number,y:number}" }] }, - jsonSchema: { - input: () => ({ - type: "object", - properties: { x: { type: "number" }, y: { type: "number" } }, - required: ["x", "y"], - }), - output: () => ({ - type: "object", - properties: { x: { type: "number" }, y: { type: "number" } }, - }), - }, - }, - }; - - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - app.registerTool( - "translate", - { inputSchema: PointSchema, outputSchema: PointSchema }, - async ({ x, y }) => ({ - content: [], - structuredContent: { x: x + 1, y: y + 1 }, - }), - ); - await app.connect(appTransport); - - const list = await bridge.listTools({}); - expect(list.tools[0].inputSchema).toEqual({ - type: "object", - properties: { x: { type: "number" }, y: { type: "number" } }, - required: ["x", "y"], - }); - expect(list.tools[0].outputSchema).toBeDefined(); - - const ok = await bridge.callTool({ - name: "translate", - arguments: { x: 1, y: 2 }, - }); - expect(ok.structuredContent).toEqual({ x: 2, y: 3 }); - - expect( - bridge.callTool({ name: "translate", arguments: { x: "bad" } }), - ).rejects.toThrow(/Invalid input for tool translate/); - }); - - it("rejects listTools when a tool schema does not implement Standard JSON Schema", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - app.registerTool( - "broken", - { - inputSchema: { - "~standard": { - version: 1 as const, - vendor: "mystery", - validate: () => ({ value: {} }), - }, - }, - }, - async () => ({ content: [] }), - ); - await app.connect(appTransport); - - expect(bridge.listTools({})).rejects.toThrow( - /does not implement Standard JSON Schema/, - ); + expect( + await bridge.callWebMcpTool({ name: "add", arguments: { a: 1 } }), + ).toEqual({ content: [], structuredContent: { sum: 3 } }); + expect(await bridge.callWebMcpTool({ name: "fails" })).toEqual({ + content: [{ type: "text", text: "no" }], + isError: true, }); + expect(received).toEqual([ + { name: "add", arguments: { a: 1 } }, + { name: "fails" }, + ]); + }); - it("returns empty list when no tools registered", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - // Register a tool to ensure handlers are initialized - const dummyTool = app.registerTool("dummy", {}, async () => ({ - content: [], - })); - - await app.connect(appTransport); - - // Remove the tool after connecting - dummyTool.remove(); + it("rejects when the peer does not implement the requests or answers with something else", async () => { + await app.connect(appTransport); + await expect(bridge.listWebMcpTools()).rejects.toThrow( + /method not found/i, + ); + await expect(bridge.callWebMcpTool({ name: "t" })).rejects.toThrow( + /method not found/i, + ); - const result = await bridge.listTools({}); + answerWith("ui/sandbox-list-tools", () => ({ tools: "nope" })); + answerWith("ui/sandbox-call-tool", () => ({ content: "nope" })); + await expect(bridge.listWebMcpTools()).rejects.toThrow(); + await expect(bridge.callWebMcpTool({ name: "t" })).rejects.toThrow(); + }); + + it("onwebmcptoolschange and addEventListener fire on ui/notifications/sandbox-tools-changed", async () => { + const handled: unknown[] = []; + const listened: unknown[] = []; + const handler = (params: unknown) => void handled.push(params); + expect(bridge.onwebmcptoolschange).toBeUndefined(); + bridge.onwebmcptoolschange = handler; + expect(bridge.onwebmcptoolschange).toBe(handler); + bridge.addEventListener( + "webmcptoolschange", + (p) => void listened.push(p), + ); + await app.connect(appTransport); - expect(result.tools).toEqual([]); - }); + const changed = { + jsonrpc: "2.0" as const, + method: "ui/notifications/sandbox-tools-changed", + }; + await appTransport.send(changed); + await flush(); + expect(handled).toHaveLength(1); + expect(listened).toHaveLength(1); - it("updates list when tools are added", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); + bridge.onwebmcptoolschange = undefined; + expect(bridge.onwebmcptoolschange).toBeUndefined(); + await appTransport.send(changed); + await flush(); + expect(handled).toHaveLength(1); + expect(listened).toHaveLength(2); + }); + }); - await app.connect(appTransport); + describe("App.registerTool does not use the wire", () => { + let warn: ReturnType>; - // Register then remove a tool to initialize handlers - const dummy = app.registerTool("init", {}, async () => ({ - content: [], - })); - dummy.remove(); - - // Initially no tools - let result = await bridge.listTools({}); - expect(result.tools).toEqual([]); - - // Add a tool - app.registerTool("new-tool", {}, async (_args: any) => ({ - content: [], - })); - - // Should now include the new tool - result = await bridge.listTools({}); - expect(result.tools.map((t) => t.name)).toEqual(["new-tool"]); - - // Add another tool - app.registerTool("another-tool", {}, async (_args: any) => ({ - content: [], - })); - - // Should now include both tools - result = await bridge.listTools({}); - expect(result.tools).toHaveLength(2); - expect(result.tools.map((t) => t.name)).toContain("new-tool"); - expect(result.tools.map((t) => t.name)).toContain("another-tool"); + beforeEach(() => { + warn = spyOn(console, "warn").mockImplementation(() => {}); + // A View whose browser (or the host) provides WebMCP. + Object.assign(globalThis, { + document: { modelContext: { registerTool: async () => {} } }, }); + }); - it("updates list when tools are removed", async () => { - const appCapabilities = { tools: { listChanged: true } }; - app = new App(testAppInfo, appCapabilities, { autoResize: false }); - - const tool1 = app.registerTool("tool1", {}, async (_args: any) => ({ - content: [], - })); - const tool2 = app.registerTool("tool2", {}, async (_args: any) => ({ - content: [], - })); - app.registerTool("tool3", {}, async (_args: any) => ({ - content: [], - })); - - await app.connect(appTransport); - - // Initially all three tools - let result = await bridge.listTools({}); - expect(result.tools).toHaveLength(3); + afterEach(() => { + warn.mockRestore(); + Reflect.deleteProperty(globalThis, "document"); + }); - // Remove one tool - tool2.remove(); + it("declares no tools capability and leaves tools/list, tools/call and list_changed unanswered/unsent", async () => { + app = new App(testAppInfo, {}, { autoResize: false }); + const listChanged: unknown[] = []; + bridge.setNotificationHandler( + "notifications/tools/list_changed", + (notification) => void listChanged.push(notification), + ); + await bridge.connect(bridgeTransport); + await app.connect(appTransport); - // Should now have two tools - result = await bridge.listTools({}); - expect(result.tools).toHaveLength(2); - expect(result.tools.map((t) => t.name)).toContain("tool1"); - expect(result.tools.map((t) => t.name)).toContain("tool3"); - expect(result.tools.map((t) => t.name)).not.toContain("tool2"); + const tool = app.registerTool("greet", {}, async () => ({ content: [] })); + tool.disable(); + tool.enable(); + tool.update({ description: "changed" }); + await flush(); - // Remove another tool - tool1.remove(); + expect(bridge.getAppCapabilities()?.tools).toBeUndefined(); + await expect(bridge.listTools({})).rejects.toThrow(/method not found/i); + await expect(bridge.callTool({ name: "greet" })).rejects.toThrow( + /method not found/i, + ); + expect(listChanged).toEqual([]); + }); + }); - // Should now have one tool - result = await bridge.listTools({}); - expect(result.tools.map((t) => t.name)).toEqual(["tool3"]); + describe("sendSandboxResourceReady", () => { + const html = "view"; + + /** A host whose transport records what it sends to the sandbox. */ + async function connectCapturingBridge(options?: { + modelContextPolyfill?: boolean; + }) { + const sent: Array<{ method?: string; params?: any }> = []; + const transport = { + async start() {}, + async send(message: object) { + sent.push(message); + }, + async close() {}, + }; + const capturing = new AppBridge( + null, + testHostInfo, + testHostCapabilities, + options, + ); + await capturing.connect(transport); + return { bridge: capturing, sent }; + } + /** The View HTML above has no script of its own. */ + const countPolyfills = (text: string) => + (text.match(/`; + + it("adds the polyfill source as a single inline script that cannot be ended or escaped early", () => { + expect(injectModelContextPolyfill("")).toBe(script); + expect(MODEL_CONTEXT_POLYFILL_SOURCE).not.toMatch( + /<\/script| { + const html = "

hi

"; + expect(injectModelContextPolyfill(html)).toBe(script + html); + }); + + it.each([ + [""], + [""], + [''], + ["\n \t"], + ["\uFEFF"], + [""], + ["\uFEFF\n \n"], + ])("keeps the View's own doctype ahead of the script: %j", (doctype) => { + const rest = "\n"; + expect(injectModelContextPolyfill(doctype + rest)).toBe( + doctype + script + rest, + ); + }); + + it("does not treat a doctype-lookalike later in the document as the doctype", () => { + for (const html of [ + "", + "", + ]) { + expect(injectModelContextPolyfill(html)).toBe(script + html); + } + }); + + it("produces a script that installs a working modelContext", async () => { + const body = /^` + + html.slice(at) + ); +} + +// ── Sandbox-side relay ──────────────────────────────────────────────────── + +/** The subset of a WebMCP `RegisteredTool` the relay reads. */ +interface WebMcpRegisteredTool { + name: string; + title?: string; + description: string; + inputSchema?: Record; + annotations?: { readOnlyHint?: boolean }; + window?: unknown; +} + +/** The subset of WebMCP's consumer-side `ModelContext` the relay uses. */ +interface WebMcpConsumer extends EventTarget { + getTools(): Promise; + executeTool(tool: WebMcpRegisteredTool, input?: object): Promise; +} + +export interface ModelContextRelayOptions { + /** + * The View's document, or `null` while it is not (yet) accessible, e.g. when + * the iframe's `sandbox` lacks `allow-same-origin`. While it is, the relay + * leaves requests for the View to answer. + */ + getViewDocument(): Document | null | undefined; + /** Send a JSON-RPC message to the Host (mind the target origin). */ + postToHost(message: Record): void; +} + +export interface ModelContextRelay { + /** + * Call for each message the Host sends towards the View. Answers the + * reserved `ui/sandbox-list-tools` and `ui/sandbox-call-tool` requests from + * the View's `document.modelContext` and returns `true`; the caller must not + * forward those to the View. Returns `false` for everything else (including + * `tools/list` / `tools/call`, which a View with the deprecated `tools` + * capability answers itself), and while the View's document or + * `modelContext` is not accessible. + */ + handleHostMessage(data: unknown): boolean; + /** + * Call for each message the View sends towards the Host (before forwarding + * it). Once the View sends `ui/notifications/initialized`, the relay starts + * telling the Host (`ui/notifications/sandbox-tools-changed`) when the View's + * tools change. It never consumes a message. + */ + handleViewMessage(data: unknown): void; +} + +const INITIALIZED = "ui/notifications/initialized"; +const INVALID_PARAMS = -32602; +const INTERNAL_ERROR = -32603; + +// Everything read from the View comes from another realm: copy it so the +// Host only ever sees plain JSON. +const plain = (value: T): T => JSON.parse(JSON.stringify(value)); + +const isPlainObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * The value behind an `executeTool()` result string. Implementations answer + * with a tool's return value as JSON, except that a string may come back raw + * (not JSON-quoted) and nothing as "" or "undefined". + */ +function parseToolResult(json: unknown): unknown { + if (json === undefined || json === "" || json === "undefined") return null; + // The View controls what executeTool() resolves with: only text is a result. + if (typeof json !== "string") { + throw new TypeError("The tool did not return a serializable result"); + } + try { + return JSON.parse(json); + } catch { + return json; + } +} + +const TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/; + +/** + * Reads a View's tool descriptor into a plain MCP `Tool`, or `undefined` if it + * is malformed. The View is untrusted: never rely on the types it reports, and + * read every member once (a getter may answer differently the second time). + */ +function toMcpTool(tool: WebMcpRegisteredTool) { + try { + const { name, title, description, annotations } = tool; + let schema: unknown = tool.inputSchema; + const readOnlyHint = annotations?.readOnlyHint; + if ( + typeof name !== "string" || + !TOOL_NAME.test(name) || + typeof description !== "string" + ) { + return undefined; + } + // Some implementations report the schema as a JSON string. + if (typeof schema === "string") schema = JSON.parse(schema); + return plain({ + name, + ...(typeof title === "string" && title !== "" && { title }), + description, + // MCP requires a schema of `type: "object"`; WebMCP does not. + inputSchema: { + properties: {}, + ...(isPlainObject(schema) ? schema : {}), + type: "object", + }, + ...(typeof readOnlyHint === "boolean" && { + annotations: { readOnlyHint }, + }), + }) as { name: string } & Record; + } catch { + return undefined; + } +} + +/** An error's message; the View's errors are not `instanceof Error` here. */ +function messageOf(error: unknown): string { + try { + return String((error as { message?: unknown } | null)?.message ?? error); + } catch { + return "Tool call failed"; + } +} + +/** + * Sandbox-side bridge between the View's WebMCP `document.modelContext` and + * the Host's `tools/list` / `tools/call` / `notifications/tools/list_changed`. + * + * Only the Sandbox proxy should use this: it is same-origin with the View, + * whereas a Host page is not, so it cannot reach into the View itself. The Host + * reaches it with `bridge.listWebMcpTools()` / `bridge.callWebMcpTool()`; the + * plain `tools/list` / `tools/call` of `bridge.listTools()` / `callTool()` keep + * going to the View. + * + * A tool's result is returned as `structuredContent` (a non-object value `v` + * as `{ result: v }`) with empty `content`; a failing tool yields + * `isError: true` with its message as text. + * + * The View is untrusted: its tool descriptors are validated and copied, and a + * request always gets a response. Limits on the number of tools or the size of + * results are the Host's to enforce on what it receives. Use a fresh inner + * iframe for every View: a View can leave its own `document.modelContext` + * behind in a document that is written again, where it would be read as the + * next View's. + */ +export function createModelContextRelay({ + getViewDocument, + postToHost, +}: ModelContextRelayOptions): ModelContextRelay { + let watching: { consumer: WebMcpConsumer; listener: () => void } | undefined; + + const viewTools = async (doc: Document, consumer: WebMcpConsumer) => + // Native getTools() also returns tools of other frames of the page. + (await consumer.getTools()).filter((tool) => { + try { + return tool.window === undefined || tool.window === doc.defaultView; + } catch { + return false; // malformed entry (null, throwing getter): drop it + } + }); + + async function answer( + doc: Document, + consumer: WebMcpConsumer, + method: string, + params: any, + ): Promise> { + const viewOwn = await viewTools(doc, consumer); + const tools = viewOwn.flatMap((tool) => { + const mcp = toMcpTool(tool); + return mcp ? [{ tool, mcp }] : []; + }); + if (method === SANDBOX_LIST_TOOLS_METHOD) { + return { result: { tools: tools.map(({ mcp }) => mcp) } }; + } + + const name = params?.name; + const found = tools.find(({ mcp }) => mcp.name === name); + if (!found) { + return { + error: { code: INVALID_PARAMS, message: `Tool ${name} not found` }, + }; + } + try { + const args = plain(params.arguments ?? {}); + const json = await consumer.executeTool( + found.tool, + // Chrome's current implementation reports schemas as JSON strings and + // takes the input as one too (the spec has objects for both). That is + // a property of the implementation, which a tool without a schema does + // not show: look at all of the View's tools. + viewOwn.some((t) => typeof t?.inputSchema === "string") + ? (JSON.stringify(args) as unknown as object) + : args, + ); + const value = parseToolResult(json); + return { + result: { + content: [], + structuredContent: isPlainObject(value) ? value : { result: value }, + }, + }; + } catch (error) { + return { + result: { + content: [{ type: "text", text: messageOf(error) }], + isError: true, + }, + }; + } + } + + // The View controls its `document.modelContext` (it can replace it with a + // throwing getter, say), so anything that goes wrong while reading it just + // means "not accessible": requests are then left for the caller to forward. + const viewConsumer = () => { + try { + const doc = getViewDocument(); + const modelContext = ( + doc as { modelContext?: unknown } | null | undefined + )?.modelContext as Partial | undefined; + return doc && + typeof modelContext?.getTools === "function" && + typeof modelContext.executeTool === "function" && + typeof modelContext.addEventListener === "function" + ? { doc, consumer: modelContext as WebMcpConsumer } + : undefined; + } catch { + return undefined; + } + }; + + function watch() { + const view = viewConsumer(); + if (!view || view.consumer === watching?.consumer) return; + const listener = () => + postToHost({ + jsonrpc: "2.0", + method: SANDBOX_TOOLS_CHANGED_METHOD, + }); + // The View's document was written again: stop listening to the old one + // (whatever it is, it must not keep us from attaching to the new one). + try { + watching?.consumer.removeEventListener("toolchange", watching.listener); + } catch {} + try { + view.consumer.addEventListener("toolchange", listener); + watching = { consumer: view.consumer, listener }; + } catch { + watching = undefined; + } + } + + return { + handleHostMessage(data) { + const { id, method, params } = (data ?? {}) as Record; + if ( + id === undefined || + (method !== SANDBOX_LIST_TOOLS_METHOD && + method !== SANDBOX_CALL_TOOL_METHOD) + ) { + return false; + } + const view = viewConsumer(); + if (!view) return false; + void answer(view.doc, view.consumer, method, params) + // A hostile View must not leave the Host's request unanswered. + .catch(() => ({ + error: { code: INTERNAL_ERROR, message: "Internal error" }, + })) + .then((response) => postToHost({ jsonrpc: "2.0", id, ...response })) + // Nobody to tell if the Host cannot be reached. + .catch(() => {}); + return true; + }, + + handleViewMessage(data) { + if ((data as { method?: unknown } | null)?.method === INITIALIZED) { + watch(); + } + }, + }; +} diff --git a/src/webmcp.ts b/src/webmcp.ts new file mode 100644 index 000000000..656c6c91b --- /dev/null +++ b/src/webmcp.ts @@ -0,0 +1,62 @@ +/** + * Structural subset of the WebMCP `ModelContext` API + * (https://webmachinelearning.github.io/webmcp/) that the SDK needs in order to + * mirror {@link App.registerTool `App.registerTool`} tools into + * `document.modelContext`. + * + * Declared locally (rather than depending on `webmcp-types`) so the SDK adds + * neither a dependency nor ambient DOM type augmentations for consumers. + * + * @internal + */ +export interface WebMcpTool { + name: string; + title?: string; + description: string; + inputSchema?: Record; + annotations?: { readOnlyHint?: boolean }; + execute( + input: Record, + options: { signal: AbortSignal }, + ): Promise; +} + +/** @internal */ +export interface WebMcpModelContext { + /** + * Resolves once the tool is registered; rejects if it cannot be (duplicate + * or invalid name, insecure context, permissions policy, …). Aborting + * `options.signal` unregisters the tool. + */ + registerTool( + tool: WebMcpTool, + options?: { signal?: AbortSignal }, + ): Promise; +} + +/** + * The document's WebMCP `modelContext`, if the browser provides one natively + * or the host injected a polyfill into the View's HTML. + * + * @internal + */ +export function getWebMcpModelContext(): WebMcpModelContext | undefined { + const modelContext = ( + globalThis as { document?: { modelContext?: Partial } } + ).document?.modelContext; + return typeof modelContext?.registerTool === "function" + ? (modelContext as WebMcpModelContext) + : undefined; +} + +/** The text blocks of a `CallToolResult`, joined (used as an error message). */ +export function callToolResultText(result: { + content: Array<{ type: string; text?: string }>; +}): string { + return ( + result.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("\n") || "Tool call failed" + ); +} diff --git a/src/wire-compat.test.ts b/src/wire-compat.test.ts index 762ac77a9..e424f20b2 100644 --- a/src/wire-compat.test.ts +++ b/src/wire-compat.test.ts @@ -7,7 +7,7 @@ * wire protocol is meant to be unchanged across the major bump; the few * host-side error deltas are pinned here so they stay deliberate. */ -import { describe, it, expect, afterEach } from "bun:test"; +import { describe, it, expect, afterEach, spyOn } from "bun:test"; import { Client, InMemoryTransport, @@ -477,13 +477,26 @@ describe("wire compatibility: 2.x App with a 1.x host", () => { it("answers 1.x tools/list (no params) and tools/call requests from the host", async () => { const { channel } = await handshake({ tools: { listChanged: true } }); - app.registerTool( - "viewtool", - { description: "view tool", inputSchema: z.object({ x: z.number() }) }, - async ({ x }) => ({ - content: [{ type: "text", text: `view got ${x}` }], - }), - ); + // registerTool() no longer serves tools over the wire (it goes through + // WebMCP); a View that still answers them does so with the raw handlers. + const warn = spyOn(console, "warn").mockImplementation(() => {}); + app.onlisttools = async () => ({ + tools: [ + { + name: "viewtool", + description: "view tool", + inputSchema: { + type: "object", + properties: { x: { type: "number" } }, + required: ["x"], + }, + }, + ], + }); + app.oncalltool = async (params) => ({ + content: [{ type: "text", text: `view got ${params.arguments?.x}` }], + }); + warn.mockRestore(); await flush(); const mark = channel.sent.length; From 57aff7004d3d1308da72d228c6cb7b7a091e1d90 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 30 Sep 2026 19:27:00 +0100 Subject: [PATCH 3/7] basic-host: list and call View tools through the sandbox relay The sandbox proxy uses createModelContextRelay() to answer the Host's reserved tool messages from the View's document.modelContext, and the Host lists and calls the tools with listWebMcpTools() / callWebMcpTool() in a new "App tools (WebMCP)" panel. The sandbox iframe delegates the `tools` Permissions Policy feature so native WebMCP works too. --- examples/basic-host/README.md | 9 +++ examples/basic-host/src/implementation.ts | 29 +++++++-- examples/basic-host/src/index.module.css | 41 ++++++++++++ examples/basic-host/src/index.tsx | 77 ++++++++++++++++++++++- examples/basic-host/src/sandbox.ts | 17 ++++- 5 files changed, 164 insertions(+), 9 deletions(-) diff --git a/examples/basic-host/README.md b/examples/basic-host/README.md index c64cda75d..168517e1b 100644 --- a/examples/basic-host/README.md +++ b/examples/basic-host/README.md @@ -41,3 +41,12 @@ Host (port 8080) - Messages flow through the outer iframe which validates and relays them bidirectionally This architecture ensures that even if tool UI code is malicious, it cannot access the host application's DOM, cookies, or JavaScript context. + +## App tools (WebMCP) + +Views can register tools with [WebMCP](https://webmachinelearning.github.io/webmcp/) (`document.modelContext.registerTool()`). The host lists them in an "App tools (WebMCP)" panel, where they can be called with JSON input. + +- `AppBridge.sendSandboxResourceReady()` prepends a WebMCP polyfill to the View HTML by default (a native `document.modelContext` is left untouched), so the sandbox proxy injects nothing. For native WebMCP, `loadSandboxProxy` also delegates the `tools` permissions policy to the sandbox iframe (`allow="tools"`). +- The host reaches the tools with `appBridge.listWebMcpTools()` / `appBridge.callWebMcpTool()`, which send the reserved sandbox messages `ui/sandbox-list-tools` / `ui/sandbox-call-tool`. The panel refreshes (debounced) on `appBridge.onwebmcptoolschange` (`ui/notifications/sandbox-tools-changed`). +- Only the sandbox proxy is same-origin with the View, so it reads the tools: `createModelContextRelay()` (from `@modelcontextprotocol/ext-apps/app-bridge`) answers those reserved requests in `sandbox.ts` via `handleHostMessage()` (they are not forwarded to the View), and `handleViewMessage()` lets it start watching the View's tools once the View has initialized. +- `tools/list` / `tools/call` (`appBridge.listTools()` / `callTool()`, deprecated) still go straight to the View, which only answers them if it handles them itself. diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index 5f680ffdc..0c36c49cf 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -170,11 +170,13 @@ export function loadSandboxProxy( iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); - // Set Permission Policy allow attribute based on requested permissions - const allowAttribute = buildAllowAttribute(permissions); - if (allowAttribute) { - iframe.setAttribute("allow", allowAttribute); - } + // Set Permission Policy allow attribute based on requested permissions, plus + // `tools`: in a browser with native WebMCP, the View's `document.modelContext` + // only works in nested cross-origin frames if the policy is delegated. + const allowAttribute = [buildAllowAttribute(permissions), "tools"] + .filter(Boolean) + .join("; "); + iframe.setAttribute("allow", allowAttribute); const readyNotification: McpUiSandboxProxyReadyNotification["method"] = "ui/notifications/sandbox-proxy-ready"; @@ -247,6 +249,17 @@ export async function initializeApp( ); } +/** + * Lists the tools the View registered with WebMCP (`document.modelContext`). + * The sandbox proxy answers from the View's document, so the View does not need + * to declare anything. + */ +export async function listViewTools(appBridge: AppBridge): Promise { + const { tools } = await appBridge.listWebMcpTools({}); + log.info("View tools (WebMCP):", tools.map((tool) => tool.name)); + return tools; +} + /** * Hooks into `AppBridge.oninitialized` and returns a Promise that resolves when * the MCP App is initialized (i.e., when the inner iframe is ready). @@ -270,6 +283,7 @@ export interface AppBridgeCallbacks { onContextUpdate?: (context: ModelContext | null) => void; onMessage?: (message: AppMessage) => void; onDisplayModeChange?: (mode: "inline" | "fullscreen") => void; + onAppToolsChanged?: () => void; } export interface AppBridgeOptions { @@ -348,6 +362,11 @@ export function newAppBridge( return {}; }; + appBridge.onwebmcptoolschange = () => { + log.info("View tools (WebMCP) changed"); + callbacks?.onAppToolsChanged?.(); + }; + appBridge.onloggingmessage = (params) => { log.info("Log message from MCP App:", params); }; diff --git a/examples/basic-host/src/index.module.css b/examples/basic-host/src/index.module.css index 0bd09dd05..d894470c9 100644 --- a/examples/basic-host/src/index.module.css +++ b/examples/basic-host/src/index.module.css @@ -250,6 +250,47 @@ font-size: 0.75rem; } +.appTool { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: 0.5rem; + cursor: default; +} + +.appToolName { + font-family: monospace; + font-weight: 600; +} + +.appToolCall { + display: flex; + gap: 0.5rem; + + textarea { + flex: 1; + padding: 0.25rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background-color: var(--color-bg); + color: var(--color-text); + font-family: monospace; + font-size: 0.8rem; + } + + button { + border: none; + border-radius: 4px; + background-color: var(--color-primary); + color: white; + cursor: pointer; + + &:hover { + background-color: var(--color-primary-hover); + } + } +} + .collapsiblePreview { margin-top: 0.25rem; color: var(--color-text-secondary); diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx index 00c0d52b2..9db16642d 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -1,8 +1,8 @@ import { getToolUiResourceUri, McpUiToolMetaSchema } from "@modelcontextprotocol/ext-apps/app-bridge"; -import type { Tool } from "@modelcontextprotocol/client"; +import type { CallToolResult, Tool } from "@modelcontextprotocol/client"; import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; -import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage } from "./implementation"; +import { callTool, connectToServer, hasAppHtml, initializeApp, listViewTools, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage } from "./implementation"; import { getTheme, toggleTheme, onThemeChange, type Theme } from "./theme"; import styles from "./index.module.css"; @@ -417,6 +417,54 @@ function CollapsiblePanel({ icon, label, content, badge, defaultExpanded = false } +/** + * Lists the tools the View registered (WebMCP `document.modelContext`, or the + * deprecated `App.registerTool`) and lets you call them with JSON input. + */ +interface AppToolsPanelProps { + tools: Tool[]; + onCall: (name: string, args: Record) => Promise; +} +function AppToolsPanel({ tools, onCall }: AppToolsPanelProps) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
setExpanded(!expanded)}> + 🧰 App tools (WebMCP) + {tools.length} tool{tools.length > 1 ? "s" : ""} + {expanded ? "▼" : "▶"} +
+ {expanded && tools.map((tool) => )} +
+ ); +} + +function AppToolRow({ tool, onCall }: { tool: Tool; onCall: AppToolsPanelProps["onCall"] }) { + const [input, setInput] = useState(() => getToolDefaults(tool)); + const [result, setResult] = useState(""); + + const call = async () => { + try { + setResult(JSON.stringify(await onCall(tool.name, JSON.parse(input)), null, 2)); + } catch (error) { + setResult(`Error: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + return ( +
+
{tool.name} {tool.description}
+
+