From aa6cbbeaee8e8fb8f4304fe3ce19a5f966459452 Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Wed, 29 Jul 2026 09:23:16 -0700 Subject: [PATCH 1/3] Add app-rendered elicitation support --- specification/draft/apps.mdx | 657 ++++++++++++++++++++++++----------- src/app-bridge.test.ts | 55 +++ src/app-bridge.ts | 42 ++- src/app.ts | 53 +++ src/generated/schema.json | 55 +++ src/generated/schema.test.ts | 20 ++ src/generated/schema.ts | 61 ++++ src/server/index.test.ts | 86 +++++ src/server/index.ts | 120 +++++++ src/spec.types.ts | 37 ++ src/types.ts | 9 + 11 files changed, 989 insertions(+), 206 deletions(-) diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index f2523f25e..3c2d9f330 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -106,51 +106,51 @@ interface UIResource { */ _meta?: { ui?: UIResourceMeta; - } + }; } interface McpUiResourceCsp { - /** - * Origins for network requests (fetch/XHR/WebSocket) - * - * - Empty or omitted = no external connections (secure default) - * - Maps to CSP `connect-src` directive - * - * @example - * ["https://api.weather.com", "wss://realtime.service.com"] - */ - connectDomains?: string[], - /** - * Origins for static resources (images, scripts, stylesheets, fonts, media) - * - * - Empty or omitted = no external resources (secure default) - * - Wildcard subdomains supported: `https://*.example.com` - * - Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives - * - * @example - * ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"] - */ - resourceDomains?: string[], - /** - * Origins for nested iframes - * - * - Empty or omitted = no nested iframes allowed (`frame-src 'none'`) - * - Maps to CSP `frame-src` directive - * - * @example - * ["https://www.youtube.com", "https://player.vimeo.com"] - */ - frameDomains?: string[], - /** - * Allowed base URIs for the document - * - * - Empty or omitted = only same origin allowed (`base-uri 'self'`) - * - Maps to CSP `base-uri` directive - * - * @example - * ["https://cdn.example.com"] - */ - baseUriDomains?: string[], + /** + * Origins for network requests (fetch/XHR/WebSocket) + * + * - Empty or omitted = no external connections (secure default) + * - Maps to CSP `connect-src` directive + * + * @example + * ["https://api.weather.com", "wss://realtime.service.com"] + */ + connectDomains?: string[]; + /** + * Origins for static resources (images, scripts, stylesheets, fonts, media) + * + * - Empty or omitted = no external resources (secure default) + * - Wildcard subdomains supported: `https://*.example.com` + * - Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives + * + * @example + * ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"] + */ + resourceDomains?: string[]; + /** + * Origins for nested iframes + * + * - Empty or omitted = no nested iframes allowed (`frame-src 'none'`) + * - Maps to CSP `frame-src` directive + * + * @example + * ["https://www.youtube.com", "https://player.vimeo.com"] + */ + frameDomains?: string[]; + /** + * Allowed base URIs for the document + * + * - Empty or omitted = only same origin allowed (`base-uri 'self'`) + * - Maps to CSP `base-uri` directive + * + * @example + * ["https://cdn.example.com"] + */ + baseUriDomains?: string[]; } interface UIResourceMeta { @@ -160,7 +160,7 @@ interface UIResourceMeta { * Servers declare which external origins their UI needs to access. * Hosts use this to enforce appropriate CSP headers. */ - csp?: McpUiResourceCsp, + csp?: McpUiResourceCsp; /** * Sandbox permissions requested by the UI * @@ -174,26 +174,26 @@ interface UIResourceMeta { * * Maps to Permission Policy `camera` feature */ - camera?: {}, + camera?: {}; /** * Request microphone access * * Maps to Permission Policy `microphone` feature */ - microphone?: {}, + microphone?: {}; /** * Request geolocation access * * Maps to Permission Policy `geolocation` feature */ - geolocation?: {}, + geolocation?: {}; /** * Request clipboard write access * * Maps to Permission Policy `clipboard-write` feature */ - clipboardWrite?: {}, - }, + clipboardWrite?: {}; + }; /** * Dedicated origin for view * @@ -213,7 +213,7 @@ interface UIResourceMeta { * @example * "www-example-com.oaiusercontent.com" */ - domain?: string, + domain?: string; /** * Visual boundary preference * @@ -224,7 +224,7 @@ interface UIResourceMeta { * - `false`: Request no visible border + background * - omitted: host decides border */ - prefersBorder?: boolean, + prefersBorder?: boolean; } ``` @@ -448,12 +448,12 @@ Note that you don’t need an SDK to “talk MCP” with the host: let nextId = 1; function sendRequest(method: string, params: any) { const id = nextId++; - window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, '*'); + window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*"); return new Promise((resolve, reject) => { - window.addEventListener('message', function listener(event) { + window.addEventListener("message", function listener(event) { const data: JSONRPCMessage = event.data; if (event.data?.id === id) { - window.removeEventListener('message', listener); + window.removeEventListener("message", listener); if (event.data?.result) { resolve(event.data?.result); } else if (event.data?.error) { @@ -466,20 +466,19 @@ function sendRequest(method: string, params: any) { }); } function sendNotification(method: string, params: any) { - window.parent.postMessage({ jsonrpc: "2.0", method, params }, '*'); + window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*"); } function onNotification(method: string, handler: (params: any) => void) { - window.addEventListener('message', function listener(event) { + window.addEventListener("message", function listener(event) { if (event.data?.method === method) { handler(event.data.params); } }); } - const initializeResult = await sendRequest("initialize", { capabilities: {}, - clientInfo: {name: "My UI", version: "1.0.0"}, + clientInfo: { name: "My UI", version: "1.0.0" }, protocolVersion: "2025-06-18", }); ``` @@ -510,6 +509,7 @@ If the Host is a web page, it MUST wrap the View and communicate with it through 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) @@ -572,9 +572,9 @@ interface HostContext { /** Metadata of the tool call that instantiated the View */ toolInfo?: { /** JSON-RPC id of the tools/call request */ - id?: RequestId, + id?: RequestId; /** Contains name, inputSchema, etc… */ - tool: Tool, + tool: Tool; }; /** Current color theme preference */ theme?: "light" | "dark"; @@ -594,12 +594,13 @@ interface HostContext { availableDisplayModes?: string[]; /** Container dimensions for the iframe. Specify either width or maxWidth, and either height or maxHeight. */ containerDimensions?: ( - | { height: number } // If specified, container is fixed at this height - | { maxHeight?: number } // Otherwise, container height is determined by the View's height, up to this maximum height (if defined) - ) & ( - | { width: number } // If specified, container is fixed at this width - | { maxWidth?: number } // Otherwise, container width is determined by the View's width, up to this maximum width (if defined) - ); + | { height: number } // If specified, container is fixed at this height + | { maxHeight?: number } // Otherwise, container height is determined by the View's height, up to this maximum height (if defined) + ) & + ( + | { width: number } // If specified, container is fixed at this width + | { maxWidth?: number } // Otherwise, container width is determined by the View's width, up to this maximum width (if defined) + ); /** User's language/region preference (BCP 47, e.g., "en-US") */ locale?: string; /** User's timezone (IANA, e.g., "America/New_York") */ @@ -612,7 +613,7 @@ interface HostContext { deviceCapabilities?: { touch?: boolean; hover?: boolean; - } + }; /** Safe area boundaries in pixels */ safeAreaInsets?: { top: number; @@ -742,11 +743,11 @@ The `HostContext` provides sizing information via `containerDimensions`: #### Dimension Modes -| Mode | Dimensions Field | Meaning | -|------|-----------------|---------| -| Fixed | `height` or `width` | Host controls the size. View should fill the available space. | -| Flexible | `maxHeight` or `maxWidth` | View controls the size, up to the specified maximum. | -| Unbounded | Field omitted | View controls the size with no limit. | +| Mode | Dimensions Field | Meaning | +| --------- | ------------------------- | ------------------------------------------------------------- | +| Fixed | `height` or `width` | Host controls the size. View should fill the available space. | +| Flexible | `maxHeight` or `maxWidth` | View controls the size, up to the specified maximum. | +| Unbounded | Field omitted | View controls the size with no limit. | These modes can be combined independently. For example, a host might specify a fixed width but flexible height, allowing the View to grow vertically based on content. @@ -763,7 +764,10 @@ if (containerDimensions) { if ("height" in containerDimensions) { // Fixed height: fill the container document.documentElement.style.height = "100vh"; - } else if ("maxHeight" in containerDimensions && containerDimensions.maxHeight) { + } else if ( + "maxHeight" in containerDimensions && + containerDimensions.maxHeight + ) { // Flexible with max: let content determine size, up to max document.documentElement.style.maxHeight = `${containerDimensions.maxHeight}px`; } @@ -773,7 +777,10 @@ if (containerDimensions) { if ("width" in containerDimensions) { // Fixed width: fill the container document.documentElement.style.width = "100vw"; - } else if ("maxWidth" in containerDimensions && containerDimensions.maxWidth) { + } else if ( + "maxWidth" in containerDimensions && + containerDimensions.maxWidth + ) { // Flexible with max: let content determine size, up to max document.documentElement.style.maxWidth = `${containerDimensions.maxWidth}px`; } @@ -846,11 +853,13 @@ Hosts notify views of display mode changes via `ui/notifications/host-context-ch #### Requirements **View behavior:** + - View MUST declare all display modes it supports in `appCapabilities.availableDisplayModes` during initialization. - View MUST check if the requested mode is in `availableDisplayModes` from host context before requesting a mode change. - View MUST handle the response mode differing from the requested mode. **Host behavior:** + - Host MUST NOT switch the View to a display mode that does not appear in its `appCapabilities.availableDisplayModes`, if set. - Host MUST return the resulting mode (whether updated or not) in the response to `ui/request-display-mode`. - If the requested mode is not available, Host SHOULD return the current display mode in the response. @@ -965,6 +974,7 @@ type McpUiStyleVariableKey = #### View Behavior - Views should set default fallback values for the set of these variables that they use, to account for hosts who don't pass some or all style variables. This ensures graceful degradation when hosts omit `styles` or specific variables: + ``` :root { --color-text-primary: light-dark(#171717, #000000); @@ -972,8 +982,9 @@ type McpUiStyleVariableKey = ... } ``` + - Views can use the `applyHostStyleVariables` utility (or `useHostStyleVariables` if they prefer a React hook) to easily populate the host-provided CSS variables into their style sheet -- Views can use the `applyDocumentTheme` utility (or `useDocumentTheme` if they prefer a React hook) to easily respond to Host Context `theme` changes in a way that is compatible with the host's light/dark color variables +- Views can use the `applyDocumentTheme` utility (or `useDocumentTheme` if they prefer a React hook) to easily respond to Host Context `theme` changes in a way that is compatible with the host's light/dark color variables Example usage of standardized CSS variables: @@ -1125,10 +1136,11 @@ MCP Apps run in sandboxed iframes where direct file downloads are blocked (`allo The `contents` array uses standard MCP resource types (`EmbeddedResource` and `ResourceLink`), avoiding custom content formats. For `EmbeddedResource`, content is inline via `text` (UTF-8) or `blob` (base64). For `ResourceLink`, the host can retrieve the content directly from the URI. Host behavior: -* Host SHOULD show a confirmation dialog before initiating the download. -* For `EmbeddedResource`, host SHOULD derive the filename from the last segment of `resource.uri`. -* Host MAY reject the download based on security policy, file size limits, or user preferences. -* Host SHOULD sanitize filenames to prevent path traversal. + +- Host SHOULD show a confirmation dialog before initiating the download. +- For `EmbeddedResource`, host SHOULD derive the filename from the last segment of `resource.uri`. +- Host MAY reject the download based on security policy, file size limits, or user preferences. +- Host SHOULD sanitize filenames to prevent path traversal. `ui/message` - Send message content to the host's chat interface @@ -1164,9 +1176,11 @@ Host behavior: } } ``` + Host behavior: -* Host SHOULD add the message to the conversation context, preserving the specified role. -* Host MAY request user consent. + +- Host SHOULD add the message to the conversation context, preserving the specified role. +- Host MAY request user consent. `ui/request-display-mode` - Request host to change display mode @@ -1229,6 +1243,7 @@ The View MAY send this request to update the Host's model context. This context This event serves a different use case from `notifications/message` (logging) and `ui/message` (which also trigger follow-ups). Host behavior: + - SHOULD provide the context to the model in future turns - MAY overwrite the previous model context with the new update - MAY defer sending the context to the model until the next user message (including `ui/message`) @@ -1278,6 +1293,7 @@ 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 SHOULD validate tool names and arguments - Apps MAY use `app.registerTool()` SDK helper for automatic validation @@ -1308,19 +1324,21 @@ When Apps declare the `tools` capability, the Host can send standard MCP tool re ``` **Tool Structure** (identical to [core MCP `Tool`](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool)): + ```typescript interface Tool { - name: string; // Unique tool identifier - title?: string; // Display name - description?: string; // Human-readable description - inputSchema: object; // JSON Schema for arguments - outputSchema?: object; // JSON Schema for structuredContent - annotations?: ToolAnnotations; // MCP tool annotations (e.g., readOnlyHint) + name: string; // Unique tool identifier + title?: string; // Display name + description?: string; // Human-readable description + inputSchema: object; // JSON Schema for arguments + outputSchema?: object; // JSON Schema for structuredContent + annotations?: ToolAnnotations; // MCP tool annotations (e.g., readOnlyHint) _meta?: object; } ``` **App Behavior:** + - Apps MUST implement `onlisttools` handler if they declare `tools` capability - Apps SHOULD return complete tool metadata including schemas - Apps MAY filter tools based on context or permissions @@ -1455,6 +1473,7 @@ The View SHOULD send this notification when rendered content body size changes ( The View MAY send this notification to request that the host tear it down. This enables View-initiated teardown flows (e.g., user clicks a "Done" button in the View). **Host behavior:** + - Host MAY defer or ignore the teardown request. - If the Host accepts the request, it MUST follow the graceful termination process by sending `ui/resource-teardown` to the View. The Host SHOULD wait for a response before tearing down the resource (to prevent data loss). @@ -1743,28 +1762,30 @@ Apps can register their own tools that hosts and agents can call, making apps ** #### Motivation: Semantic Introspection Without tool registration, apps are opaque to the model: + - The host renders the app in a sandboxed iframe and has no guaranteed access to its internals — apps may embed further cross-origin iframes (via `frameDomains`), and even where same-origin, DOM access is not part of the host↔app contract - 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 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 -This is *pull*-based and complements the *push*-based [`ui/update-model-context`](#uiupdate-model-context) request already defined in this spec (and analogous mechanisms elsewhere, e.g. OAI Apps SDK's [`setWidgetState()`](https://developers.openai.com/apps-sdk/reference/sdk#setwidgetstate)). Push lets an app proactively keep the model's context current; pull lets the agent query and command the app on demand. Apps may use either or both. +This is _pull_-based and complements the _push_-based [`ui/update-model-context`](#uiupdate-model-context) request already defined in this spec (and analogous mechanisms elsewhere, e.g. OAI Apps SDK's [`setWidgetState()`](https://developers.openai.com/apps-sdk/reference/sdk#setwidgetstate)). Push lets an app proactively keep the model's context current; pull lets the agent query and command the app on demand. Apps may use either or both. #### App Tool Registration Apps register tools using the SDK's `registerTool()` method: ```typescript -import { App } from '@modelcontextprotocol/ext-apps'; -import { z } from 'zod'; +import { App } from "@modelcontextprotocol/ext-apps"; +import { z } from "zod"; const app = new App( { name: "TicTacToe", version: "1.0.0" }, - { tools: { listChanged: true } } // Declare tool capability + { tools: { listChanged: true } }, // Declare tool capability ); // Register a tool with schema validation @@ -1774,15 +1795,15 @@ const moveTool = app.registerTool( description: "Make a move in the tic-tac-toe game", inputSchema: z.object({ position: z.number().int().min(0).max(8), - player: z.enum(['X', 'O']) + player: z.enum(["X", "O"]), }), outputSchema: z.object({ board: z.array(z.string()).length(9), - winner: z.enum(['X', 'O', 'draw', null]).nullable() + winner: z.enum(["X", "O", "draw", null]).nullable(), }), annotations: { - readOnlyHint: false // This tool has side effects - } + readOnlyHint: false, // This tool has side effects + }, }, async (params) => { // Validate and execute move @@ -1790,16 +1811,18 @@ const moveTool = app.registerTool( const winner = checkWinner(newBoard); return { - content: [{ - type: "text", - text: `Move made at position ${params.position}` - }], + content: [ + { + type: "text", + text: `Move made at position ${params.position}`, + }, + ], structuredContent: { board: newBoard, - winner - } + winner, + }, }; - } + }, ); await app.connect(new PostMessageTransport(window.parent)); @@ -1807,14 +1830,14 @@ await app.connect(new PostMessageTransport(window.parent)); **Registration Options:** -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `name` | string | Yes | Unique tool identifier | -| `description` | string | No | Human-readable description for agent | -| `inputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates arguments; serialized to JSON Schema for `tools/list` | -| `outputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates `structuredContent` | -| `annotations` | ToolAnnotations | No | MCP tool hints (e.g., `readOnlyHint`) | -| `_meta` | object | No | Custom metadata | +| Field | Type | Required | Description | +| -------------- | ---------------------------------------------- | -------- | --------------------------------------------------------------- | +| `name` | string | Yes | Unique tool identifier | +| `description` | string | No | Human-readable description for agent | +| `inputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates arguments; serialized to JSON Schema for `tools/list` | +| `outputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates `structuredContent` | +| `annotations` | ToolAnnotations | No | MCP tool hints (e.g., `readOnlyHint`) | +| `_meta` | object | No | Custom metadata | Apps can also implement tool handling manually without the SDK: @@ -1822,16 +1845,19 @@ Apps can also implement tool handling manually without the SDK: app.oncalltool = async (params, extra) => { if (params.name === "tictactoe_move") { // Manual validation - if (typeof params.arguments?.position !== 'number') { + if (typeof params.arguments?.position !== "number") { throw new Error("Invalid position"); } // Execute tool - const newBoard = makeMove(params.arguments.position, params.arguments.player); + const newBoard = makeMove( + params.arguments.position, + params.arguments.player, + ); return { content: [{ type: "text", text: "Move made" }], - structuredContent: { board: newBoard } + structuredContent: { board: newBoard }, }; } @@ -1848,12 +1874,12 @@ app.onlisttools = async () => { type: "object", properties: { position: { type: "number", minimum: 0, maximum: 8 }, - player: { type: "string", enum: ["X", "O"] } + player: { type: "string", enum: ["X", "O"] }, }, - required: ["position", "player"] - } - } - ] + required: ["position", "player"], + }, + }, + ], }; }; ``` @@ -1882,7 +1908,7 @@ When a tool is disabled/enabled, the app automatically sends `notifications/tool // Update tool description or schema tool.update({ description: "New description", - inputSchema: newSchema + inputSchema: newSchema, }); ``` @@ -1911,14 +1937,14 @@ app.registerTool( { inputSchema: z.object({ query: z.string().min(1).max(100), - limit: z.number().int().positive().default(10) - }) + limit: z.number().int().positive().default(10), + }), }, async (params) => { // params.query is guaranteed to be a string (1-100 chars) // params.limit is guaranteed to be a positive integer (default 10) return performSearch(params.query, params.limit); - } + }, ); ``` @@ -1931,19 +1957,19 @@ app.registerTool( "get_status", { outputSchema: z.object({ - status: z.enum(['ready', 'busy', 'error']), - timestamp: z.string().datetime() - }) + status: z.enum(["ready", "busy", "error"]), + timestamp: z.string().datetime(), + }), }, async () => { return { content: [{ type: "text", text: "Status retrieved" }], structuredContent: { - status: 'ready', - timestamp: new Date().toISOString() - } + status: "ready", + timestamp: new Date().toISOString(), + }, }; - } + }, ); ``` @@ -1954,45 +1980,48 @@ If the callback returns data that doesn't match `outputSchema`, the tool returns This example demonstrates how apps expose semantic interfaces through tools: ```typescript -import { App } from '@modelcontextprotocol/ext-apps'; -import { z } from 'zod'; +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 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 } } + { 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", + 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()) - }) + 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}` - }], + content: [ + { + type: "text", + text: `Board: ${board.map((c) => c || "-").join("")}, Player: ${currentPlayer}`, + }, + ], structuredContent: { board, currentPlayer, winner: checkWinner(board), - moveHistory - } + moveHistory, + }, }; - } + }, ); // Agent can execute moves @@ -2001,37 +2030,40 @@ app.registerTool( { description: "Place a piece at the specified position", inputSchema: z.object({ - position: z.number().int().min(0).max(8) + position: z.number().int().min(0).max(8), }), - annotations: { readOnlyHint: false } + annotations: { readOnlyHint: false }, }, async ({ position }) => { if (board[position] !== null) { return { content: [{ type: "text", text: "Position already taken" }], - isError: true + isError: true, }; } board[position] = currentPlayer; moveHistory.push(position); const winner = checkWinner(board); - currentPlayer = currentPlayer === 'X' ? 'O' : 'X'; + currentPlayer = currentPlayer === "X" ? "O" : "X"; return { - content: [{ - type: "text", - text: `Player ${board[position]} moved to position ${position}` + - (winner ? `. ${winner} wins!` : '') - }], + content: [ + { + type: "text", + text: + `Player ${board[position]} moved to position ${position}` + + (winner ? `. ${winner} wins!` : ""), + }, + ], structuredContent: { board, currentPlayer, winner, - moveHistory - } + moveHistory, + }, }; - } + }, ); // Agent can reset game @@ -2039,27 +2071,34 @@ app.registerTool( "reset_game", { description: "Reset the game board to initial state", - annotations: { readOnlyHint: false } + annotations: { readOnlyHint: false }, }, async () => { board = Array(9).fill(null); - currentPlayer = 'X'; + currentPlayer = "X"; moveHistory = []; return { content: [{ type: "text", text: "Game reset" }], - structuredContent: { board, currentPlayer, moveHistory } + structuredContent: { board, currentPlayer, moveHistory }, }; - } + }, ); await app.connect(new PostMessageTransport(window.parent)); -function checkWinner(board: Array<'X' | 'O' | null>): 'X' | 'O' | 'draw' | null { +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 + [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) { @@ -2068,7 +2107,7 @@ function checkWinner(board: Array<'X' | 'O' | null>): 'X' | 'O' | 'draw' | null } } - return board.every(cell => cell !== null) ? 'draw' : null; + return board.every((cell) => cell !== null) ? "draw" : null; } ``` @@ -2082,7 +2121,7 @@ const { tools } = await bridge.sendListTools({}); // 2. Query semantic state (not visual/DOM) const state = await bridge.sendCallTool({ name: "get_board_state", - arguments: {} + arguments: {}, }); // → { board: [null, null, null, ...], currentPlayer: 'X', winner: null } @@ -2090,14 +2129,14 @@ const state = await bridge.sendCallTool({ if (state.structuredContent.board[4] === null) { await bridge.sendCallTool({ name: "make_move", - arguments: { position: 4 } + arguments: { position: 4 }, }); } // 4. Query updated state const newState = await bridge.sendCallTool({ name: "get_board_state", - arguments: {} + arguments: {}, }); // → { board: [null, null, null, null, 'X', null, ...], currentPlayer: 'O', ... } ``` @@ -2125,7 +2164,7 @@ Host/Agent calls app-registered tools: // Host calls app tool const result = await bridge.sendCallTool({ name: "tictactoe_move", - arguments: { position: 4 } + arguments: { position: 4 }, }); ``` @@ -2133,13 +2172,13 @@ 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 | +| 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 @@ -2158,6 +2197,7 @@ Requires app `tools` capability. 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) @@ -2169,6 +2209,7 @@ Key considerations: 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 @@ -2178,7 +2219,10 @@ See [ext-apps#35](https://github.com/modelcontextprotocol/ext-apps/issues/35) fo ### Client\<\>Server Capability Negotiation -Clients and servers negotiate MCP Apps support through the standard MCP extensions capability mechanism (defined in SEP-1724). +Clients and servers negotiate MCP Apps support through the standard MCP +extensions capability mechanism (defined in SEP-1724). Features of MCP Apps +are settings of the existing `io.modelcontextprotocol/ui` extension. They do +not introduce additional extension identifiers. #### Client (Host) Capabilities @@ -2188,11 +2232,15 @@ Clients advertise MCP Apps support in the initialize request using the extension { "method": "initialize", "params": { - "protocolVersion": "2024-11-05", + "protocolVersion": "2025-11-25", "capabilities": { + "elicitation": { + "form": {} + }, "extensions": { "io.modelcontextprotocol/ui": { - "mimeTypes": ["text/html;profile=mcp-app"] + "mimeTypes": ["text/html;profile=mcp-app"], + "elicitation": {} } } }, @@ -2206,35 +2254,82 @@ Clients advertise MCP Apps support in the initialize request using the extension **Extension Settings:** -- `mimeTypes`: Array of supported content types (REQUIRED, e.g., `["text/html;profile=mcp-app"]`) +- `mimeTypes`: Array of supported content types (REQUIRED, e.g., + `["text/html;profile=mcp-app"]`) +- `elicitation`: Client can use an MCP App to render and resolve form-mode + `elicitation/create` requests that identify a UI resource Future versions may add additional settings: - `features`: Specific feature support (e.g., `["streaming", "persistence"]`) - `sandboxPolicies`: Supported sandbox attribute configurations +#### Server Capabilities + +A server that may attach an MCP App to a form-mode elicitation advertises the +same setting in its initialize result: + +```json +{ + "result": { + "protocolVersion": "2025-11-25", + "capabilities": { + "extensions": { + "io.modelcontextprotocol/ui": { + "elicitation": {} + } + } + }, + "serverInfo": { + "name": "example-server", + "version": "1.0.0" + } + } +} +``` + +App-rendered elicitation is negotiated only when all of the following are +true: + +1. The client advertises the core `elicitation.form` capability. +2. The client advertises `text/html;profile=mcp-app` in + `io.modelcontextprotocol/ui.mimeTypes`. +3. The client advertises `io.modelcontextprotocol/ui.elicitation`. +4. The server advertises `io.modelcontextprotocol/ui.elicitation`. + +A MIME type match alone does not negotiate app-rendered elicitation. This +explicit two-sided setting lets either peer use MCP Apps without also opting +in to app-rendered elicitation. + #### Server Behavior Servers SHOULD check client capabilities before registering UI-enabled tools. The SDK provides the `getUiCapability` helper for this: ```typescript -import { getUiCapability, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; +import { + getUiCapability, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; const uiCap = getUiCapability(clientCapabilities); if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) { // Register tools with UI templates server.registerTool("get_weather", { description: "Get weather with interactive dashboard", - inputSchema: { /* ... */ }, + inputSchema: { + /* ... */ + }, _meta: { - ui: { resourceUri: "ui://weather-server/dashboard" } - } + ui: { resourceUri: "ui://weather-server/dashboard" }, + }, }); } else { // Register text-only version server.registerTool("get_weather", { description: "Get weather as text", - inputSchema: { /* ... */ } + inputSchema: { + /* ... */ + }, // No UI metadata }); } @@ -2246,7 +2341,159 @@ if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) { - Tools MUST return meaningful content array even when UI is available - Servers MAY register different tool variants based on host capabilities -#### App (Guest UI) Capabilities +### App-Rendered Elicitation + +When app-rendered elicitation is negotiated, a server can associate an MCP App +resource with a standard form-mode `elicitation/create` request. This feature +uses the existing MCP Apps extension, the standard MCP elicitation request and +result, and the existing View↔Host JSON-RPC channel. It defines no new method, +result, or extension identifier. + +#### Associating an App with an Elicitation + +The server attaches the app resource URI at `_meta.ui.resourceUri`: + +```json +{ + "jsonrpc": "2.0", + "id": 12, + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "Choose a delivery window", + "requestedSchema": { + "type": "object", + "properties": { + "window": { + "type": "string", + "oneOf": [ + { "const": "morning", "title": "Morning" }, + { "const": "afternoon", "title": "Afternoon" } + ] + } + }, + "required": ["window"] + }, + "_meta": { + "ui": { + "resourceUri": "ui://delivery/choose-window.html" + } + } + } +} +``` + +`resourceUri` MUST be an absolute `ui://` URI. It is resolved against the same +MCP server connection that sent the elicitation. A server MUST NOT attach this +metadata unless app-rendered elicitation was negotiated. + +App rendering is defined only for form mode. A client MUST NOT route URL-mode +elicitations to an MCP App under this setting. + +#### Host↔App Capability Negotiation + +The host and the selected app independently confirm that their View↔Host +channel supports elicitation: + +```json +{ + "method": "ui/initialize", + "params": { + "protocolVersion": "2025-11-25", + "appInfo": { + "name": "delivery-window", + "version": "1.0.0" + }, + "appCapabilities": { + "elicitation": {} + } + } +} +``` + +```json +{ + "result": { + "protocolVersion": "2025-11-25", + "hostInfo": { + "name": "example-host", + "version": "1.0.0" + }, + "hostCapabilities": { + "elicitation": {} + }, + "hostContext": {} + } +} +``` + +An app MUST advertise `appCapabilities.elicitation` before it receives an +elicitation. A host MUST advertise `hostCapabilities.elicitation` before it +forwards one. If either setting is absent, the host MUST use its native +form-elicitation UI instead. + +#### Resolution Flow + +For each app-rendered elicitation, the client: + +1. Receives the standard form-mode `elicitation/create` request. +2. Confirms that app-rendered elicitation was negotiated and validates + `_meta.ui.resourceUri`. +3. Reads and validates the MCP App resource using the same server connection. +4. Creates or selects an app instance that is bound to this exact request and + resource URI, then completes the `ui/initialize` handshake. +5. If the app and host advertised their View↔Host `elicitation` settings, + forwards the unchanged `elicitation/create` request to that app instance. +6. Validates the app's response as a standard MCP `ElicitResult` and returns it + unchanged to the server. + +For example, an app resolves the request by returning the standard result: + +```json +{ + "jsonrpc": "2.0", + "id": 12, + "result": { + "action": "accept", + "content": { + "window": "morning" + } + } +} +``` + +The host remains the MCP client and policy enforcement point. The app does not +connect to the server directly and does not introduce an app-specific result +or callback method. + +The host MUST bind forwarding to the originating request, resource URI, and app +instance. It MUST NOT forward to a global or "currently active" app bridge. +This requirement also applies when multiple elicitations are in flight. +Cancellation and timeout signals SHOULD be propagated across the bound +View↔Host request. + +#### Validation, Fallback, and Retry + +The host MUST validate an accepted app result against the standard +`ElicitResult` shape and the request's `requestedSchema` before returning it to +the server. + +If the app resource is missing or invalid, cannot be loaded, fails to +initialize, does not advertise `elicitation`, cannot complete the bridge +request, or returns an invalid result, the host SHOULD fall back to its native +form-elicitation UI. The original message and requested schema MUST be +preserved. + +An app result with `action: "decline"` or `action: "cancel"` is a successful +resolution and MUST be returned to the server. It MUST NOT trigger native +fallback. + +Subsequent validation and retry use the core MCP elicitation flow. MCP Apps add +no retry method. When the core protocol associates a retry with the same +request, the host SHOULD preserve the app instance and its local state when +policy and lifecycle permit. + +### App (Guest UI) Capability Negotiation Apps advertise their capabilities in the `ui/initialize` request to the host. When an app supports tool registration, it includes the `tools` capability: @@ -2541,23 +2788,23 @@ const permissions = uiMeta?.permissions; const cspValue = ` default-src 'none'; - script-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''}; - style-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''}; - connect-src 'self' ${csp?.connectDomains?.join(' ') || ''}; - img-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''}; - font-src 'self' ${csp?.resourceDomains?.join(' ') || ''}; - media-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''}; - frame-src ${csp?.frameDomains?.join(' ') || "'none'"}; + script-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(" ") || ""}; + style-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(" ") || ""}; + connect-src 'self' ${csp?.connectDomains?.join(" ") || ""}; + img-src 'self' data: ${csp?.resourceDomains?.join(" ") || ""}; + font-src 'self' ${csp?.resourceDomains?.join(" ") || ""}; + media-src 'self' data: ${csp?.resourceDomains?.join(" ") || ""}; + frame-src ${csp?.frameDomains?.join(" ") || "'none'"}; object-src 'none'; - base-uri ${csp?.baseUriDomains?.join(' ') || "'self'"}; + base-uri ${csp?.baseUriDomains?.join(" ") || "'self'"}; `; // Permission Policy for iframe allow attribute const allowList: string[] = []; -if (permissions?.camera) allowList.push('camera'); -if (permissions?.microphone) allowList.push('microphone'); -if (permissions?.geolocation) allowList.push('geolocation'); -const allowAttribute = allowList.join(' '); +if (permissions?.camera) allowList.push("camera"); +if (permissions?.microphone) allowList.push("microphone"); +if (permissions?.geolocation) allowList.push("geolocation"); +const allowAttribute = allowList.join(" "); ``` **Security Requirements:** @@ -2628,11 +2875,11 @@ Hosts SHOULD implement the following protections for app-provided tools: Hosts MAY implement different permission levels based on tool annotations: -| Annotation | Recommended Permission | Example | -|---------------------|------------------------|-------------------| -| `readOnlyHint: true`| Auto-approve (with caution) | `get_board_state()` | -| `readOnlyHint: false` | User confirmation required | `make_move()` | -| No annotation | User confirmation required (safe default) | Any tool | +| Annotation | Recommended Permission | Example | +| --------------------- | ----------------------------------------- | ------------------- | +| `readOnlyHint: true` | Auto-approve (with caution) | `get_board_state()` | +| `readOnlyHint: false` | User confirmation required | `make_move()` | +| No annotation | User confirmation required (safe default) | Any tool | **App Tool Lifecycle:** diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index d327c95fe..8562c0f4b 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -151,6 +151,61 @@ describe("App <-> AppBridge integration", () => { }); }); + describe("app-rendered elicitation", () => { + it("forwards the standard request to the bound app and returns its standard result", async () => { + const elicitationHostCapabilities: McpUiHostCapabilities = { + ...testHostCapabilities, + elicitation: {}, + }; + bridge = new AppBridge( + createMockClient() as Client, + testHostInfo, + elicitationHostCapabilities, + ); + app.onelicitation = async (params) => ({ + action: "accept", + content: { choice: params.message }, + }); + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + expect(bridge.getAppCapabilities()?.elicitation).toEqual({}); + await expect( + bridge.requestElicitation({ + message: "Choose an option", + requestedSchema: { + type: "object", + properties: { + choice: { type: "string" }, + }, + required: ["choice"], + }, + }), + ).resolves.toEqual({ + action: "accept", + content: { choice: "Choose an option" }, + }); + }); + + it("fails closed when the app did not advertise elicitation", async () => { + bridge = new AppBridge(createMockClient() as Client, testHostInfo, { + ...testHostCapabilities, + elicitation: {}, + }); + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + expect(() => + bridge.requestElicitation({ + message: "Choose an option", + requestedSchema: { type: "object", properties: {} }, + }), + ).toThrow("App does not support elicitation"); + }); + }); + describe("Host -> App notifications", () => { beforeEach(async () => { await bridge.connect(bridgeTransport); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 23383c40f..2cd2bea6f 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -10,6 +10,9 @@ import { CreateMessageResult, CreateMessageResultWithTools, EmptyResult, + ElicitRequest, + ElicitResult, + ElicitResultSchema, Implementation, ListPromptsRequest, ListPromptsRequestSchema, @@ -443,6 +446,36 @@ export class AppBridge extends ProtocolWithEvents< return this._appCapabilities; } + /** + * Forward a form-mode MCP elicitation to this exact app instance. + * + * Hosts should call this only from the core MCP client's handler for the + * originating `elicitation/create` request. The returned value is the + * standard MCP `ElicitResult` and can be returned directly to the server. + */ + requestElicitation( + params: ElicitRequest["params"], + options?: RequestOptions, + ): Promise { + if (!this._initializedReceived) { + throw new Error("App has not completed ui/initialize"); + } + if (!this._capabilities.elicitation) { + throw new Error("Host does not support app-rendered elicitation"); + } + if (!this._appCapabilities?.elicitation) { + throw new Error("App does not support elicitation"); + } + if (params.mode !== undefined && params.mode !== "form") { + throw new Error("MCP Apps only support form-mode elicitations"); + } + return this.request( + { method: "elicitation/create", params }, + ElicitResultSchema, + options, + ); + } + /** * Get the view's implementation info discovered during initialization. * @@ -1408,7 +1441,14 @@ export class AppBridge extends ProtocolWithEvents< * @internal */ assertCapabilityForMethod(method: AppRequest["method"]): void { - // TODO + if ( + method === "elicitation/create" && + !this._appCapabilities?.elicitation + ) { + throw new Error( + `App does not support elicitation capability (required for ${method})`, + ); + } } /** diff --git a/src/app.ts b/src/app.ts index adfad5c77..61b6d698f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -15,6 +15,9 @@ import { CreateMessageResultWithTools, CreateMessageResultWithToolsSchema, EmptyResultSchema, + ElicitRequest, + ElicitRequestSchema, + ElicitResult, Implementation, ListResourcesRequest, ListResourcesResult, @@ -1013,6 +1016,49 @@ export class App extends ProtocolWithEvents< ); } + /** + * Handle a form-mode elicitation that the host has bound to this app. + * + * The callback receives the unchanged standard MCP `elicitation/create` + * parameters and returns the unchanged standard MCP `ElicitResult`. + * Register this handler before {@link connect `connect`}; doing so advertises + * the app's `elicitation` capability during `ui/initialize`. + */ + private _onelicitation?: ( + params: ElicitRequest["params"], + extra: RequestHandlerExtra, + ) => ElicitResult | Promise; + get onelicitation() { + return this._onelicitation; + } + set onelicitation( + callback: + | (( + params: ElicitRequest["params"], + extra: RequestHandlerExtra, + ) => ElicitResult | Promise) + | undefined, + ) { + this.warnIfRequestHandlerReplaced( + "onelicitation", + this._onelicitation, + callback, + ); + this._onelicitation = callback; + if (callback && !this._capabilities.elicitation && !this.transport) { + this.registerCapabilities({ elicitation: {} }); + } + this.replaceRequestHandler(ElicitRequestSchema, (request, extra) => { + if (!this._onelicitation) { + throw new Error("No onelicitation handler set"); + } + if (request.params.mode !== undefined && request.params.mode !== "form") { + throw new Error("MCP Apps only support form-mode elicitations"); + } + return this._onelicitation(request.params, extra); + }); + } + /** * Convenience handler for tool call requests from the host. * @@ -1165,6 +1211,13 @@ export class App extends ProtocolWithEvents< ); } return; + case "elicitation/create": + if (!this._capabilities.elicitation) { + throw new Error( + `App does not support elicitation capability (required for ${method})`, + ); + } + return; case "ping": case "ui/resource-teardown": return; diff --git a/src/generated/schema.json b/src/generated/schema.json index 6b66482c4..afa590a1a 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -54,6 +54,12 @@ ], "description": "Display mode for UI presentation." } + }, + "elicitation": { + "description": "App can render and resolve form-mode `elicitation/create`\nrequests using the standard MCP request and result shapes.", + "type": "object", + "properties": {}, + "additionalProperties": false } }, "additionalProperties": false @@ -68,6 +74,12 @@ "items": { "type": "string" } + }, + "elicitation": { + "description": "Client can use an MCP App to render form-mode elicitations\nwhen a request identifies a UI resource.", + "type": "object", + "properties": {}, + "additionalProperties": false } }, "additionalProperties": false @@ -302,6 +314,18 @@ }, "additionalProperties": {} }, + "McpUiElicitationMeta": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "resourceUri": { + "type": "string", + "description": "Absolute `ui://` URI of the MCP App resource that can render\nand resolve this elicitation." + } + }, + "required": ["resourceUri"], + "additionalProperties": false + }, "McpUiHostCapabilities": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", @@ -532,6 +556,12 @@ } }, "additionalProperties": false + }, + "elicitation": { + "description": "Host can forward form-mode `elicitation/create` requests to\nthis app and return its standard MCP `ElicitResult` to the server.", + "type": "object", + "properties": {}, + "additionalProperties": false } }, "additionalProperties": false @@ -2600,6 +2630,12 @@ ], "description": "Display mode for UI presentation." } + }, + "elicitation": { + "description": "App can render and resolve form-mode `elicitation/create`\nrequests using the standard MCP request and result shapes.", + "type": "object", + "properties": {}, + "additionalProperties": false } }, "additionalProperties": false, @@ -2903,6 +2939,12 @@ } }, "additionalProperties": false + }, + "elicitation": { + "description": "Host can forward form-mode `elicitation/create` requests to\nthis app and return its standard MCP `ElicitResult` to the server.", + "type": "object", + "properties": {}, + "additionalProperties": false } }, "additionalProperties": false, @@ -4441,6 +4483,19 @@ "required": ["method", "params"], "additionalProperties": false }, + "McpUiServerCapabilities": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "elicitation": { + "description": "Server may attach an MCP App resource to form-mode\n`elicitation/create` requests.", + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "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..a0c62c4a9 100644 --- a/src/generated/schema.test.ts +++ b/src/generated/schema.test.ts @@ -131,6 +131,14 @@ export type McpUiClientCapabilitiesSchemaInferredType = z.infer< typeof generated.McpUiClientCapabilitiesSchema >; +export type McpUiServerCapabilitiesSchemaInferredType = z.infer< + typeof generated.McpUiServerCapabilitiesSchema +>; + +export type McpUiElicitationMetaSchemaInferredType = z.infer< + typeof generated.McpUiElicitationMetaSchema +>; + export type McpUiDownloadFileRequestSchemaInferredType = z.infer< typeof generated.McpUiDownloadFileRequestSchema >; @@ -311,6 +319,18 @@ expectType( expectType( {} as spec.McpUiClientCapabilities, ); +expectType( + {} as McpUiServerCapabilitiesSchemaInferredType, +); +expectType( + {} as spec.McpUiServerCapabilities, +); +expectType( + {} as McpUiElicitationMetaSchemaInferredType, +); +expectType( + {} as spec.McpUiElicitationMeta, +); expectType( {} as McpUiDownloadFileRequestSchemaInferredType, ); diff --git a/src/generated/schema.ts b/src/generated/schema.ts index a8bc1b6f5..7c0ed5385 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -581,6 +581,16 @@ export const McpUiHostCapabilitiesSchema = z.object({ .describe( "Host supports LLM sampling (sampling/createMessage) from the view.\nMirrors the MCP `ClientCapabilities.sampling` shape so hosts can pass it through.", ), + /** + * @description Host can forward form-mode `elicitation/create` requests to + * this app and return its standard MCP `ElicitResult` to the server. + */ + elicitation: z + .object({}) + .optional() + .describe( + "Host can forward form-mode `elicitation/create` requests to\nthis app and return its standard MCP `ElicitResult` to the server.", + ), }); /** @@ -614,6 +624,16 @@ export const McpUiAppCapabilitiesSchema = z.object({ .array(McpUiDisplayModeSchema) .optional() .describe("Display modes the app supports."), + /** + * @description App can render and resolve form-mode `elicitation/create` + * requests using the standard MCP request and result shapes. + */ + elicitation: z + .object({}) + .optional() + .describe( + "App can render and resolve form-mode `elicitation/create`\nrequests using the standard MCP request and result shapes.", + ), }); /** @@ -770,6 +790,47 @@ export const McpUiClientCapabilitiesSchema = z.object({ .describe( 'Array of supported MIME types for UI resources.\nMust include `"text/html;profile=mcp-app"` for MCP Apps support.', ), + /** + * @description Client can use an MCP App to render form-mode elicitations + * when a request identifies a UI resource. + */ + elicitation: z + .object({}) + .optional() + .describe( + "Client can use an MCP App to render form-mode elicitations\nwhen a request identifies a UI resource.", + ), +}); + +/** + * @description MCP Apps capability settings advertised by servers to clients. + */ +export const McpUiServerCapabilitiesSchema = z.object({ + /** + * @description Server may attach an MCP App resource to form-mode + * `elicitation/create` requests. + */ + elicitation: z + .object({}) + .optional() + .describe( + "Server may attach an MCP App resource to form-mode\n`elicitation/create` requests.", + ), +}); + +/** + * @description MCP Apps metadata attached to a form-mode elicitation request. + */ +export const McpUiElicitationMetaSchema = z.object({ + /** + * @description Absolute `ui://` URI of the MCP App resource that can render + * and resolve this elicitation. + */ + resourceUri: z + .string() + .describe( + "Absolute `ui://` URI of the MCP App resource that can render\nand resolve this elicitation.", + ), }); /** diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 1853cc05a..03e65ef54 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -5,6 +5,10 @@ import { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE, getUiCapability, + getUiServerCapability, + supportsAppElicitation, + withElicitationUi, + getElicitationUiResourceUri, EXTENSION_ID, } from "./index"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -356,3 +360,85 @@ describe("getUiCapability", () => { expect(getUiCapability(caps)).toBeUndefined(); }); }); + +describe("app-rendered elicitation negotiation", () => { + const clientCapabilities = { + elicitation: { form: {} }, + extensions: { + [EXTENSION_ID]: { + mimeTypes: [RESOURCE_MIME_TYPE], + elicitation: {}, + }, + }, + }; + const serverCapabilities = { + extensions: { + [EXTENSION_ID]: { + elicitation: {}, + }, + }, + }; + + it("requires matching client, server, core form, MIME, and UI settings", () => { + expect(supportsAppElicitation(clientCapabilities, serverCapabilities)).toBe( + true, + ); + expect(supportsAppElicitation({}, serverCapabilities)).toBe(false); + expect(supportsAppElicitation(clientCapabilities, {})).toBe(false); + expect( + supportsAppElicitation( + { + ...clientCapabilities, + elicitation: {}, + }, + serverCapabilities, + ), + ).toBe(false); + }); + + it("reads the server setting from the existing MCP Apps extension", () => { + expect(getUiServerCapability(serverCapabilities)).toEqual({ + elicitation: {}, + }); + }); +}); + +describe("elicitation UI metadata", () => { + const params = { + message: "Choose an option", + requestedSchema: { + type: "object" as const, + properties: { + choice: { type: "string" as const }, + }, + }, + }; + + it("attaches and reads an absolute ui:// resource URI", () => { + const enhanced = withElicitationUi( + params, + "ui://example/choose-option.html", + ); + expect(enhanced.requestedSchema).toEqual(params.requestedSchema); + expect(getElicitationUiResourceUri(enhanced)).toBe( + "ui://example/choose-option.html", + ); + }); + + it("rejects URL-mode elicitation and non-ui resource URIs", () => { + expect(() => + withElicitationUi( + { + mode: "url", + message: "Continue", + elicitationId: "123", + url: "https://example.com", + } as any, + "ui://example/view.html", + ), + ).toThrow("form-mode"); + expect(() => withElicitationUi(params, "https://example.com")).toThrow( + "absolute ui://", + ); + }); +}); diff --git a/src/server/index.ts b/src/server/index.ts index c90514acd..fe0b24859 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -38,6 +38,8 @@ import { McpUiResourceMeta, McpUiToolMeta, McpUiClientCapabilities, + McpUiServerCapabilities, + McpUiElicitationMeta, } from "../app.js"; import type { BaseToolCallback, @@ -55,7 +57,10 @@ import type { import type { StandardSchemaWithJSON } from "../standard-schema"; import type { ClientCapabilities, + ElicitRequest, + ElicitRequestFormParams, ReadResourceResult, + ServerCapabilities, ToolAnnotations, } from "@modelcontextprotocol/sdk/types.js"; @@ -469,3 +474,118 @@ export function getUiCapability( | McpUiClientCapabilities | undefined; } + +/** + * Get the MCP Apps extension settings advertised by a server. + */ +export function getUiServerCapability( + serverCapabilities: + | (ServerCapabilities & { extensions?: Record }) + | null + | undefined, +): McpUiServerCapabilities | undefined { + if (!serverCapabilities) { + return undefined; + } + + return serverCapabilities.extensions?.[EXTENSION_ID] as + | McpUiServerCapabilities + | undefined; +} + +/** + * Determine whether the client and server negotiated app-rendered form + * elicitations. + * + * This deliberately requires the core form-elicitation capability and the + * nested `elicitation` setting on both sides of the existing MCP Apps + * extension. A MIME type match alone is not sufficient. + */ +export function supportsAppElicitation( + clientCapabilities: + | (ClientCapabilities & { extensions?: Record }) + | null + | undefined, + serverCapabilities: + | (ServerCapabilities & { extensions?: Record }) + | null + | undefined, +): boolean { + const clientUi = getUiCapability(clientCapabilities); + const serverUi = getUiServerCapability(serverCapabilities); + + return Boolean( + clientCapabilities?.elicitation?.form && + clientUi?.mimeTypes?.includes(RESOURCE_MIME_TYPE) && + clientUi.elicitation && + serverUi?.elicitation, + ); +} + +/** + * Attach an MCP App resource to a standard form-mode elicitation request. + */ +export function withElicitationUi( + params: ElicitRequestFormParams, + resourceUri: string, +): ElicitRequestFormParams & { + _meta: { ui: McpUiElicitationMeta; [key: string]: unknown }; +} { + if ((params as ElicitRequest["params"]).mode === "url") { + throw new Error("MCP Apps only support form-mode elicitations"); + } + + let parsed: URL; + try { + parsed = new URL(resourceUri); + } catch { + throw new Error("Elicitation UI resourceUri must be an absolute ui:// URI"); + } + if (parsed.protocol !== "ui:") { + throw new Error("Elicitation UI resourceUri must be an absolute ui:// URI"); + } + + const meta = params._meta ?? {}; + const ui = + typeof meta.ui === "object" && meta.ui !== null + ? (meta.ui as Record) + : {}; + + return { + ...params, + _meta: { + ...meta, + ui: { ...ui, resourceUri }, + }, + }; +} + +/** + * Read and validate the MCP App resource URI from elicitation metadata. + */ +export function getElicitationUiResourceUri( + params: ElicitRequest["params"], +): string | undefined { + const ui = params._meta?.ui; + if (typeof ui !== "object" || ui === null) { + return undefined; + } + const resourceUri = (ui as Record).resourceUri; + if (resourceUri === undefined) { + return undefined; + } + if (typeof resourceUri !== "string") { + throw new Error("Elicitation UI resourceUri must be a string"); + } + + let parsed: URL; + try { + parsed = new URL(resourceUri); + } catch { + throw new Error("Elicitation UI resourceUri must be an absolute ui:// URI"); + } + if (parsed.protocol !== "ui:") { + throw new Error("Elicitation UI resourceUri must be an absolute ui:// URI"); + } + return resourceUri; +} diff --git a/src/spec.types.ts b/src/spec.types.ts index bedc1f5c6..fe39d8349 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -529,6 +529,11 @@ export interface McpUiHostCapabilities { /** @description Host supports tool use via `tools` and `toolChoice` parameters. */ tools?: {}; }; + /** + * @description Host can forward form-mode `elicitation/create` requests to + * this app and return its standard MCP `ElicitResult` to the server. + */ + elicitation?: {}; } /** @@ -545,6 +550,11 @@ export interface McpUiAppCapabilities { }; /** @description Display modes the app supports. */ availableDisplayModes?: McpUiDisplayMode[]; + /** + * @description App can render and resolve form-mode `elicitation/create` + * requests using the standard MCP request and result shapes. + */ + elicitation?: {}; } /** @@ -855,4 +865,31 @@ export interface McpUiClientCapabilities { * Must include `"text/html;profile=mcp-app"` for MCP Apps support. */ mimeTypes?: string[]; + /** + * @description Client can use an MCP App to render form-mode elicitations + * when a request identifies a UI resource. + */ + elicitation?: {}; +} + +/** + * @description MCP Apps capability settings advertised by servers to clients. + */ +export interface McpUiServerCapabilities { + /** + * @description Server may attach an MCP App resource to form-mode + * `elicitation/create` requests. + */ + elicitation?: {}; +} + +/** + * @description MCP Apps metadata attached to a form-mode elicitation request. + */ +export interface McpUiElicitationMeta { + /** + * @description Absolute `ui://` URI of the MCP App resource that can render + * and resolve this elicitation. + */ + resourceUri: string; } diff --git a/src/types.ts b/src/types.ts index 7fc6b7188..7573eddda 100644 --- a/src/types.ts +++ b/src/types.ts @@ -67,6 +67,8 @@ export { type McpUiToolVisibility, type McpUiToolMeta, type McpUiClientCapabilities, + type McpUiServerCapabilities, + type McpUiElicitationMeta, } from "./spec.types.js"; // Import types needed for protocol type unions (not re-exported, just used internally) @@ -134,6 +136,9 @@ export { McpUiRequestDisplayModeResultSchema, McpUiToolVisibilitySchema, McpUiToolMetaSchema, + McpUiClientCapabilitiesSchema, + McpUiServerCapabilitiesSchema, + McpUiElicitationMetaSchema, } from "./generated/schema.js"; // Re-export SDK types used in protocol type unions @@ -144,6 +149,8 @@ import { CreateMessageResult, CreateMessageResultWithTools, EmptyResult, + ElicitRequest, + ElicitResult, ListPromptsRequest, ListPromptsResult, ListResourcesRequest, @@ -186,6 +193,7 @@ export type AppRequest = | ReadResourceRequest | ListPromptsRequest | CreateMessageRequest + | ElicitRequest | PingRequest; /** @@ -237,4 +245,5 @@ export type AppResult = | ListPromptsResult | CreateMessageResult | CreateMessageResultWithTools + | ElicitResult | EmptyResult; From 7fe4b56e2cf0f7ae8b06ee55c0c9584d556ea699 Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Wed, 29 Jul 2026 10:11:08 -0700 Subject: [PATCH 2/3] Remove unrelated specification formatting changes --- specification/draft/apps.mdx | 460 ++++++++++++++++------------------- 1 file changed, 205 insertions(+), 255 deletions(-) diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index 3c2d9f330..fd8c7c1f1 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -106,51 +106,51 @@ interface UIResource { */ _meta?: { ui?: UIResourceMeta; - }; + } } interface McpUiResourceCsp { - /** - * Origins for network requests (fetch/XHR/WebSocket) - * - * - Empty or omitted = no external connections (secure default) - * - Maps to CSP `connect-src` directive - * - * @example - * ["https://api.weather.com", "wss://realtime.service.com"] - */ - connectDomains?: string[]; - /** - * Origins for static resources (images, scripts, stylesheets, fonts, media) - * - * - Empty or omitted = no external resources (secure default) - * - Wildcard subdomains supported: `https://*.example.com` - * - Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives - * - * @example - * ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"] - */ - resourceDomains?: string[]; - /** - * Origins for nested iframes - * - * - Empty or omitted = no nested iframes allowed (`frame-src 'none'`) - * - Maps to CSP `frame-src` directive - * - * @example - * ["https://www.youtube.com", "https://player.vimeo.com"] - */ - frameDomains?: string[]; - /** - * Allowed base URIs for the document - * - * - Empty or omitted = only same origin allowed (`base-uri 'self'`) - * - Maps to CSP `base-uri` directive - * - * @example - * ["https://cdn.example.com"] - */ - baseUriDomains?: string[]; + /** + * Origins for network requests (fetch/XHR/WebSocket) + * + * - Empty or omitted = no external connections (secure default) + * - Maps to CSP `connect-src` directive + * + * @example + * ["https://api.weather.com", "wss://realtime.service.com"] + */ + connectDomains?: string[], + /** + * Origins for static resources (images, scripts, stylesheets, fonts, media) + * + * - Empty or omitted = no external resources (secure default) + * - Wildcard subdomains supported: `https://*.example.com` + * - Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives + * + * @example + * ["https://cdn.jsdelivr.net", "https://*.cloudflare.com"] + */ + resourceDomains?: string[], + /** + * Origins for nested iframes + * + * - Empty or omitted = no nested iframes allowed (`frame-src 'none'`) + * - Maps to CSP `frame-src` directive + * + * @example + * ["https://www.youtube.com", "https://player.vimeo.com"] + */ + frameDomains?: string[], + /** + * Allowed base URIs for the document + * + * - Empty or omitted = only same origin allowed (`base-uri 'self'`) + * - Maps to CSP `base-uri` directive + * + * @example + * ["https://cdn.example.com"] + */ + baseUriDomains?: string[], } interface UIResourceMeta { @@ -160,7 +160,7 @@ interface UIResourceMeta { * Servers declare which external origins their UI needs to access. * Hosts use this to enforce appropriate CSP headers. */ - csp?: McpUiResourceCsp; + csp?: McpUiResourceCsp, /** * Sandbox permissions requested by the UI * @@ -174,26 +174,26 @@ interface UIResourceMeta { * * Maps to Permission Policy `camera` feature */ - camera?: {}; + camera?: {}, /** * Request microphone access * * Maps to Permission Policy `microphone` feature */ - microphone?: {}; + microphone?: {}, /** * Request geolocation access * * Maps to Permission Policy `geolocation` feature */ - geolocation?: {}; + geolocation?: {}, /** * Request clipboard write access * * Maps to Permission Policy `clipboard-write` feature */ - clipboardWrite?: {}; - }; + clipboardWrite?: {}, + }, /** * Dedicated origin for view * @@ -213,7 +213,7 @@ interface UIResourceMeta { * @example * "www-example-com.oaiusercontent.com" */ - domain?: string; + domain?: string, /** * Visual boundary preference * @@ -224,7 +224,7 @@ interface UIResourceMeta { * - `false`: Request no visible border + background * - omitted: host decides border */ - prefersBorder?: boolean; + prefersBorder?: boolean, } ``` @@ -448,12 +448,12 @@ Note that you don’t need an SDK to “talk MCP” with the host: let nextId = 1; function sendRequest(method: string, params: any) { const id = nextId++; - window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*"); + window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, '*'); return new Promise((resolve, reject) => { - window.addEventListener("message", function listener(event) { + window.addEventListener('message', function listener(event) { const data: JSONRPCMessage = event.data; if (event.data?.id === id) { - window.removeEventListener("message", listener); + window.removeEventListener('message', listener); if (event.data?.result) { resolve(event.data?.result); } else if (event.data?.error) { @@ -466,19 +466,20 @@ function sendRequest(method: string, params: any) { }); } function sendNotification(method: string, params: any) { - window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*"); + window.parent.postMessage({ jsonrpc: "2.0", method, params }, '*'); } function onNotification(method: string, handler: (params: any) => void) { - window.addEventListener("message", function listener(event) { + window.addEventListener('message', function listener(event) { if (event.data?.method === method) { handler(event.data.params); } }); } + const initializeResult = await sendRequest("initialize", { capabilities: {}, - clientInfo: { name: "My UI", version: "1.0.0" }, + clientInfo: {name: "My UI", version: "1.0.0"}, protocolVersion: "2025-06-18", }); ``` @@ -509,7 +510,6 @@ If the Host is a web page, it MUST wrap the View and communicate with it through 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) @@ -572,9 +572,9 @@ interface HostContext { /** Metadata of the tool call that instantiated the View */ toolInfo?: { /** JSON-RPC id of the tools/call request */ - id?: RequestId; + id?: RequestId, /** Contains name, inputSchema, etc… */ - tool: Tool; + tool: Tool, }; /** Current color theme preference */ theme?: "light" | "dark"; @@ -594,13 +594,12 @@ interface HostContext { availableDisplayModes?: string[]; /** Container dimensions for the iframe. Specify either width or maxWidth, and either height or maxHeight. */ containerDimensions?: ( - | { height: number } // If specified, container is fixed at this height - | { maxHeight?: number } // Otherwise, container height is determined by the View's height, up to this maximum height (if defined) - ) & - ( - | { width: number } // If specified, container is fixed at this width - | { maxWidth?: number } // Otherwise, container width is determined by the View's width, up to this maximum width (if defined) - ); + | { height: number } // If specified, container is fixed at this height + | { maxHeight?: number } // Otherwise, container height is determined by the View's height, up to this maximum height (if defined) + ) & ( + | { width: number } // If specified, container is fixed at this width + | { maxWidth?: number } // Otherwise, container width is determined by the View's width, up to this maximum width (if defined) + ); /** User's language/region preference (BCP 47, e.g., "en-US") */ locale?: string; /** User's timezone (IANA, e.g., "America/New_York") */ @@ -613,7 +612,7 @@ interface HostContext { deviceCapabilities?: { touch?: boolean; hover?: boolean; - }; + } /** Safe area boundaries in pixels */ safeAreaInsets?: { top: number; @@ -743,11 +742,11 @@ The `HostContext` provides sizing information via `containerDimensions`: #### Dimension Modes -| Mode | Dimensions Field | Meaning | -| --------- | ------------------------- | ------------------------------------------------------------- | -| Fixed | `height` or `width` | Host controls the size. View should fill the available space. | -| Flexible | `maxHeight` or `maxWidth` | View controls the size, up to the specified maximum. | -| Unbounded | Field omitted | View controls the size with no limit. | +| Mode | Dimensions Field | Meaning | +|------|-----------------|---------| +| Fixed | `height` or `width` | Host controls the size. View should fill the available space. | +| Flexible | `maxHeight` or `maxWidth` | View controls the size, up to the specified maximum. | +| Unbounded | Field omitted | View controls the size with no limit. | These modes can be combined independently. For example, a host might specify a fixed width but flexible height, allowing the View to grow vertically based on content. @@ -764,10 +763,7 @@ if (containerDimensions) { if ("height" in containerDimensions) { // Fixed height: fill the container document.documentElement.style.height = "100vh"; - } else if ( - "maxHeight" in containerDimensions && - containerDimensions.maxHeight - ) { + } else if ("maxHeight" in containerDimensions && containerDimensions.maxHeight) { // Flexible with max: let content determine size, up to max document.documentElement.style.maxHeight = `${containerDimensions.maxHeight}px`; } @@ -777,10 +773,7 @@ if (containerDimensions) { if ("width" in containerDimensions) { // Fixed width: fill the container document.documentElement.style.width = "100vw"; - } else if ( - "maxWidth" in containerDimensions && - containerDimensions.maxWidth - ) { + } else if ("maxWidth" in containerDimensions && containerDimensions.maxWidth) { // Flexible with max: let content determine size, up to max document.documentElement.style.maxWidth = `${containerDimensions.maxWidth}px`; } @@ -853,13 +846,11 @@ Hosts notify views of display mode changes via `ui/notifications/host-context-ch #### Requirements **View behavior:** - - View MUST declare all display modes it supports in `appCapabilities.availableDisplayModes` during initialization. - View MUST check if the requested mode is in `availableDisplayModes` from host context before requesting a mode change. - View MUST handle the response mode differing from the requested mode. **Host behavior:** - - Host MUST NOT switch the View to a display mode that does not appear in its `appCapabilities.availableDisplayModes`, if set. - Host MUST return the resulting mode (whether updated or not) in the response to `ui/request-display-mode`. - If the requested mode is not available, Host SHOULD return the current display mode in the response. @@ -974,7 +965,6 @@ type McpUiStyleVariableKey = #### View Behavior - Views should set default fallback values for the set of these variables that they use, to account for hosts who don't pass some or all style variables. This ensures graceful degradation when hosts omit `styles` or specific variables: - ``` :root { --color-text-primary: light-dark(#171717, #000000); @@ -982,9 +972,8 @@ type McpUiStyleVariableKey = ... } ``` - - Views can use the `applyHostStyleVariables` utility (or `useHostStyleVariables` if they prefer a React hook) to easily populate the host-provided CSS variables into their style sheet -- Views can use the `applyDocumentTheme` utility (or `useDocumentTheme` if they prefer a React hook) to easily respond to Host Context `theme` changes in a way that is compatible with the host's light/dark color variables +- Views can use the `applyDocumentTheme` utility (or `useDocumentTheme` if they prefer a React hook) to easily respond to Host Context `theme` changes in a way that is compatible with the host's light/dark color variables Example usage of standardized CSS variables: @@ -1136,11 +1125,10 @@ MCP Apps run in sandboxed iframes where direct file downloads are blocked (`allo The `contents` array uses standard MCP resource types (`EmbeddedResource` and `ResourceLink`), avoiding custom content formats. For `EmbeddedResource`, content is inline via `text` (UTF-8) or `blob` (base64). For `ResourceLink`, the host can retrieve the content directly from the URI. Host behavior: - -- Host SHOULD show a confirmation dialog before initiating the download. -- For `EmbeddedResource`, host SHOULD derive the filename from the last segment of `resource.uri`. -- Host MAY reject the download based on security policy, file size limits, or user preferences. -- Host SHOULD sanitize filenames to prevent path traversal. +* Host SHOULD show a confirmation dialog before initiating the download. +* For `EmbeddedResource`, host SHOULD derive the filename from the last segment of `resource.uri`. +* Host MAY reject the download based on security policy, file size limits, or user preferences. +* Host SHOULD sanitize filenames to prevent path traversal. `ui/message` - Send message content to the host's chat interface @@ -1176,11 +1164,9 @@ Host behavior: } } ``` - Host behavior: - -- Host SHOULD add the message to the conversation context, preserving the specified role. -- Host MAY request user consent. +* Host SHOULD add the message to the conversation context, preserving the specified role. +* Host MAY request user consent. `ui/request-display-mode` - Request host to change display mode @@ -1243,7 +1229,6 @@ The View MAY send this request to update the Host's model context. This context This event serves a different use case from `notifications/message` (logging) and `ui/message` (which also trigger follow-ups). Host behavior: - - SHOULD provide the context to the model in future turns - MAY overwrite the previous model context with the new update - MAY defer sending the context to the model until the next user message (including `ui/message`) @@ -1293,7 +1278,6 @@ 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 SHOULD validate tool names and arguments - Apps MAY use `app.registerTool()` SDK helper for automatic validation @@ -1324,21 +1308,19 @@ When Apps declare the `tools` capability, the Host can send standard MCP tool re ``` **Tool Structure** (identical to [core MCP `Tool`](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool)): - ```typescript interface Tool { - name: string; // Unique tool identifier - title?: string; // Display name - description?: string; // Human-readable description - inputSchema: object; // JSON Schema for arguments - outputSchema?: object; // JSON Schema for structuredContent - annotations?: ToolAnnotations; // MCP tool annotations (e.g., readOnlyHint) + name: string; // Unique tool identifier + title?: string; // Display name + description?: string; // Human-readable description + inputSchema: object; // JSON Schema for arguments + outputSchema?: object; // JSON Schema for structuredContent + annotations?: ToolAnnotations; // MCP tool annotations (e.g., readOnlyHint) _meta?: object; } ``` **App Behavior:** - - Apps MUST implement `onlisttools` handler if they declare `tools` capability - Apps SHOULD return complete tool metadata including schemas - Apps MAY filter tools based on context or permissions @@ -1473,7 +1455,6 @@ The View SHOULD send this notification when rendered content body size changes ( The View MAY send this notification to request that the host tear it down. This enables View-initiated teardown flows (e.g., user clicks a "Done" button in the View). **Host behavior:** - - Host MAY defer or ignore the teardown request. - If the Host accepts the request, it MUST follow the graceful termination process by sending `ui/resource-teardown` to the View. The Host SHOULD wait for a response before tearing down the resource (to prevent data loss). @@ -1762,30 +1743,28 @@ Apps can register their own tools that hosts and agents can call, making apps ** #### Motivation: Semantic Introspection Without tool registration, apps are opaque to the model: - - The host renders the app in a sandboxed iframe and has no guaranteed access to its internals — apps may embed further cross-origin iframes (via `frameDomains`), and even where same-origin, DOM access is not part of the host↔app contract - 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 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 -This is _pull_-based and complements the _push_-based [`ui/update-model-context`](#uiupdate-model-context) request already defined in this spec (and analogous mechanisms elsewhere, e.g. OAI Apps SDK's [`setWidgetState()`](https://developers.openai.com/apps-sdk/reference/sdk#setwidgetstate)). Push lets an app proactively keep the model's context current; pull lets the agent query and command the app on demand. Apps may use either or both. +This is *pull*-based and complements the *push*-based [`ui/update-model-context`](#uiupdate-model-context) request already defined in this spec (and analogous mechanisms elsewhere, e.g. OAI Apps SDK's [`setWidgetState()`](https://developers.openai.com/apps-sdk/reference/sdk#setwidgetstate)). Push lets an app proactively keep the model's context current; pull lets the agent query and command the app on demand. Apps may use either or both. #### App Tool Registration Apps register tools using the SDK's `registerTool()` method: ```typescript -import { App } from "@modelcontextprotocol/ext-apps"; -import { z } from "zod"; +import { App } from '@modelcontextprotocol/ext-apps'; +import { z } from 'zod'; const app = new App( { name: "TicTacToe", version: "1.0.0" }, - { tools: { listChanged: true } }, // Declare tool capability + { tools: { listChanged: true } } // Declare tool capability ); // Register a tool with schema validation @@ -1795,15 +1774,15 @@ const moveTool = app.registerTool( description: "Make a move in the tic-tac-toe game", inputSchema: z.object({ position: z.number().int().min(0).max(8), - player: z.enum(["X", "O"]), + player: z.enum(['X', 'O']) }), outputSchema: z.object({ board: z.array(z.string()).length(9), - winner: z.enum(["X", "O", "draw", null]).nullable(), + winner: z.enum(['X', 'O', 'draw', null]).nullable() }), annotations: { - readOnlyHint: false, // This tool has side effects - }, + readOnlyHint: false // This tool has side effects + } }, async (params) => { // Validate and execute move @@ -1811,18 +1790,16 @@ const moveTool = app.registerTool( const winner = checkWinner(newBoard); return { - content: [ - { - type: "text", - text: `Move made at position ${params.position}`, - }, - ], + content: [{ + type: "text", + text: `Move made at position ${params.position}` + }], structuredContent: { board: newBoard, - winner, - }, + winner + } }; - }, + } ); await app.connect(new PostMessageTransport(window.parent)); @@ -1830,14 +1807,14 @@ await app.connect(new PostMessageTransport(window.parent)); **Registration Options:** -| Field | Type | Required | Description | -| -------------- | ---------------------------------------------- | -------- | --------------------------------------------------------------- | -| `name` | string | Yes | Unique tool identifier | -| `description` | string | No | Human-readable description for agent | -| `inputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates arguments; serialized to JSON Schema for `tools/list` | -| `outputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates `structuredContent` | -| `annotations` | ToolAnnotations | No | MCP tool hints (e.g., `readOnlyHint`) | -| `_meta` | object | No | Custom metadata | +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Unique tool identifier | +| `description` | string | No | Human-readable description for agent | +| `inputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates arguments; serialized to JSON Schema for `tools/list` | +| `outputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates `structuredContent` | +| `annotations` | ToolAnnotations | No | MCP tool hints (e.g., `readOnlyHint`) | +| `_meta` | object | No | Custom metadata | Apps can also implement tool handling manually without the SDK: @@ -1845,19 +1822,16 @@ Apps can also implement tool handling manually without the SDK: app.oncalltool = async (params, extra) => { if (params.name === "tictactoe_move") { // Manual validation - if (typeof params.arguments?.position !== "number") { + if (typeof params.arguments?.position !== 'number') { throw new Error("Invalid position"); } // Execute tool - const newBoard = makeMove( - params.arguments.position, - params.arguments.player, - ); + const newBoard = makeMove(params.arguments.position, params.arguments.player); return { content: [{ type: "text", text: "Move made" }], - structuredContent: { board: newBoard }, + structuredContent: { board: newBoard } }; } @@ -1874,12 +1848,12 @@ app.onlisttools = async () => { type: "object", properties: { position: { type: "number", minimum: 0, maximum: 8 }, - player: { type: "string", enum: ["X", "O"] }, + player: { type: "string", enum: ["X", "O"] } }, - required: ["position", "player"], - }, - }, - ], + required: ["position", "player"] + } + } + ] }; }; ``` @@ -1908,7 +1882,7 @@ When a tool is disabled/enabled, the app automatically sends `notifications/tool // Update tool description or schema tool.update({ description: "New description", - inputSchema: newSchema, + inputSchema: newSchema }); ``` @@ -1937,14 +1911,14 @@ app.registerTool( { inputSchema: z.object({ query: z.string().min(1).max(100), - limit: z.number().int().positive().default(10), - }), + limit: z.number().int().positive().default(10) + }) }, async (params) => { // params.query is guaranteed to be a string (1-100 chars) // params.limit is guaranteed to be a positive integer (default 10) return performSearch(params.query, params.limit); - }, + } ); ``` @@ -1957,19 +1931,19 @@ app.registerTool( "get_status", { outputSchema: z.object({ - status: z.enum(["ready", "busy", "error"]), - timestamp: z.string().datetime(), - }), + status: z.enum(['ready', 'busy', 'error']), + timestamp: z.string().datetime() + }) }, async () => { return { content: [{ type: "text", text: "Status retrieved" }], structuredContent: { - status: "ready", - timestamp: new Date().toISOString(), - }, + status: 'ready', + timestamp: new Date().toISOString() + } }; - }, + } ); ``` @@ -1980,48 +1954,45 @@ If the callback returns data that doesn't match `outputSchema`, the tool returns This example demonstrates how apps expose semantic interfaces through tools: ```typescript -import { App } from "@modelcontextprotocol/ext-apps"; -import { z } from "zod"; +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 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 } }, + { 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", + 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()), - }), + 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}`, - }, - ], + content: [{ + type: "text", + text: `Board: ${board.map(c => c || '-').join('')}, Player: ${currentPlayer}` + }], structuredContent: { board, currentPlayer, winner: checkWinner(board), - moveHistory, - }, + moveHistory + } }; - }, + } ); // Agent can execute moves @@ -2030,40 +2001,37 @@ app.registerTool( { description: "Place a piece at the specified position", inputSchema: z.object({ - position: z.number().int().min(0).max(8), + position: z.number().int().min(0).max(8) }), - annotations: { readOnlyHint: false }, + annotations: { readOnlyHint: false } }, async ({ position }) => { if (board[position] !== null) { return { content: [{ type: "text", text: "Position already taken" }], - isError: true, + isError: true }; } board[position] = currentPlayer; moveHistory.push(position); const winner = checkWinner(board); - currentPlayer = currentPlayer === "X" ? "O" : "X"; + currentPlayer = currentPlayer === 'X' ? 'O' : 'X'; return { - content: [ - { - type: "text", - text: - `Player ${board[position]} moved to position ${position}` + - (winner ? `. ${winner} wins!` : ""), - }, - ], + content: [{ + type: "text", + text: `Player ${board[position]} moved to position ${position}` + + (winner ? `. ${winner} wins!` : '') + }], structuredContent: { board, currentPlayer, winner, - moveHistory, - }, + moveHistory + } }; - }, + } ); // Agent can reset game @@ -2071,34 +2039,27 @@ app.registerTool( "reset_game", { description: "Reset the game board to initial state", - annotations: { readOnlyHint: false }, + annotations: { readOnlyHint: false } }, async () => { board = Array(9).fill(null); - currentPlayer = "X"; + currentPlayer = 'X'; moveHistory = []; return { content: [{ type: "text", text: "Game reset" }], - structuredContent: { board, currentPlayer, moveHistory }, + structuredContent: { board, currentPlayer, moveHistory } }; - }, + } ); await app.connect(new PostMessageTransport(window.parent)); -function checkWinner( - board: Array<"X" | "O" | null>, -): "X" | "O" | "draw" | null { +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 + [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) { @@ -2107,7 +2068,7 @@ function checkWinner( } } - return board.every((cell) => cell !== null) ? "draw" : null; + return board.every(cell => cell !== null) ? 'draw' : null; } ``` @@ -2121,7 +2082,7 @@ const { tools } = await bridge.sendListTools({}); // 2. Query semantic state (not visual/DOM) const state = await bridge.sendCallTool({ name: "get_board_state", - arguments: {}, + arguments: {} }); // → { board: [null, null, null, ...], currentPlayer: 'X', winner: null } @@ -2129,14 +2090,14 @@ const state = await bridge.sendCallTool({ if (state.structuredContent.board[4] === null) { await bridge.sendCallTool({ name: "make_move", - arguments: { position: 4 }, + arguments: { position: 4 } }); } // 4. Query updated state const newState = await bridge.sendCallTool({ name: "get_board_state", - arguments: {}, + arguments: {} }); // → { board: [null, null, null, null, 'X', null, ...], currentPlayer: 'O', ... } ``` @@ -2164,7 +2125,7 @@ Host/Agent calls app-registered tools: // Host calls app tool const result = await bridge.sendCallTool({ name: "tictactoe_move", - arguments: { position: 4 }, + arguments: { position: 4 } }); ``` @@ -2172,13 +2133,13 @@ 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 | +| 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 @@ -2197,7 +2158,6 @@ Requires app `tools` capability. 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) @@ -2209,7 +2169,6 @@ Key considerations: 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 @@ -2219,10 +2178,9 @@ See [ext-apps#35](https://github.com/modelcontextprotocol/ext-apps/issues/35) fo ### Client\<\>Server Capability Negotiation -Clients and servers negotiate MCP Apps support through the standard MCP -extensions capability mechanism (defined in SEP-1724). Features of MCP Apps -are settings of the existing `io.modelcontextprotocol/ui` extension. They do -not introduce additional extension identifiers. +Clients and servers negotiate MCP Apps support through the standard MCP extensions capability mechanism (defined in SEP-1724). +Features of MCP Apps are settings of the existing `io.modelcontextprotocol/ui` +extension. They do not introduce additional extension identifiers. #### Client (Host) Capabilities @@ -2254,8 +2212,7 @@ Clients advertise MCP Apps support in the initialize request using the extension **Extension Settings:** -- `mimeTypes`: Array of supported content types (REQUIRED, e.g., - `["text/html;profile=mcp-app"]`) +- `mimeTypes`: Array of supported content types (REQUIRED, e.g., `["text/html;profile=mcp-app"]`) - `elicitation`: Client can use an MCP App to render and resolve form-mode `elicitation/create` requests that identify a UI resource @@ -2306,30 +2263,23 @@ in to app-rendered elicitation. Servers SHOULD check client capabilities before registering UI-enabled tools. The SDK provides the `getUiCapability` helper for this: ```typescript -import { - getUiCapability, - RESOURCE_MIME_TYPE, -} from "@modelcontextprotocol/ext-apps/server"; +import { getUiCapability, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; const uiCap = getUiCapability(clientCapabilities); if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) { // Register tools with UI templates server.registerTool("get_weather", { description: "Get weather with interactive dashboard", - inputSchema: { - /* ... */ - }, + inputSchema: { /* ... */ }, _meta: { - ui: { resourceUri: "ui://weather-server/dashboard" }, - }, + ui: { resourceUri: "ui://weather-server/dashboard" } + } }); } else { // Register text-only version server.registerTool("get_weather", { description: "Get weather as text", - inputSchema: { - /* ... */ - }, + inputSchema: { /* ... */ } // No UI metadata }); } @@ -2493,7 +2443,7 @@ no retry method. When the core protocol associates a retry with the same request, the host SHOULD preserve the app instance and its local state when policy and lifecycle permit. -### App (Guest UI) Capability Negotiation +#### App (Guest UI) Capabilities Apps advertise their capabilities in the `ui/initialize` request to the host. When an app supports tool registration, it includes the `tools` capability: @@ -2788,23 +2738,23 @@ const permissions = uiMeta?.permissions; const cspValue = ` default-src 'none'; - script-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(" ") || ""}; - style-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(" ") || ""}; - connect-src 'self' ${csp?.connectDomains?.join(" ") || ""}; - img-src 'self' data: ${csp?.resourceDomains?.join(" ") || ""}; - font-src 'self' ${csp?.resourceDomains?.join(" ") || ""}; - media-src 'self' data: ${csp?.resourceDomains?.join(" ") || ""}; - frame-src ${csp?.frameDomains?.join(" ") || "'none'"}; + script-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''}; + style-src 'self' 'unsafe-inline' ${csp?.resourceDomains?.join(' ') || ''}; + connect-src 'self' ${csp?.connectDomains?.join(' ') || ''}; + img-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''}; + font-src 'self' ${csp?.resourceDomains?.join(' ') || ''}; + media-src 'self' data: ${csp?.resourceDomains?.join(' ') || ''}; + frame-src ${csp?.frameDomains?.join(' ') || "'none'"}; object-src 'none'; - base-uri ${csp?.baseUriDomains?.join(" ") || "'self'"}; + base-uri ${csp?.baseUriDomains?.join(' ') || "'self'"}; `; // Permission Policy for iframe allow attribute const allowList: string[] = []; -if (permissions?.camera) allowList.push("camera"); -if (permissions?.microphone) allowList.push("microphone"); -if (permissions?.geolocation) allowList.push("geolocation"); -const allowAttribute = allowList.join(" "); +if (permissions?.camera) allowList.push('camera'); +if (permissions?.microphone) allowList.push('microphone'); +if (permissions?.geolocation) allowList.push('geolocation'); +const allowAttribute = allowList.join(' '); ``` **Security Requirements:** @@ -2875,11 +2825,11 @@ Hosts SHOULD implement the following protections for app-provided tools: Hosts MAY implement different permission levels based on tool annotations: -| Annotation | Recommended Permission | Example | -| --------------------- | ----------------------------------------- | ------------------- | -| `readOnlyHint: true` | Auto-approve (with caution) | `get_board_state()` | -| `readOnlyHint: false` | User confirmation required | `make_move()` | -| No annotation | User confirmation required (safe default) | Any tool | +| Annotation | Recommended Permission | Example | +|---------------------|------------------------|-------------------| +| `readOnlyHint: true`| Auto-approve (with caution) | `get_board_state()` | +| `readOnlyHint: false` | User confirmation required | `make_move()` | +| No annotation | User confirmation required (safe default) | Any tool | **App Tool Lifecycle:** From 89ab2bcbf066ca21f1bd38cb115949f857950b7a Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Wed, 29 Jul 2026 10:49:48 -0700 Subject: [PATCH 3/3] docs: align app elicitation with 2026 MRTR --- specification/draft/apps.mdx | 226 +++++++++++++++++++++++------------ 1 file changed, 149 insertions(+), 77 deletions(-) diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index fd8c7c1f1..a1e4cf324 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -2184,32 +2184,44 @@ extension. They do not introduce additional extension identifiers. #### Client (Host) Capabilities -Clients advertise MCP Apps support in the initialize request using the extension identifier `io.modelcontextprotocol/ui`: +In MCP protocol revision `2026-07-28`, clients advertise MCP Apps support in +the request-scoped capabilities carried in +`_meta["io.modelcontextprotocol/clientCapabilities"]`. The extension identifier +is `io.modelcontextprotocol/ui`: ```json { - "method": "initialize", + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", "params": { - "protocolVersion": "2025-11-25", - "capabilities": { - "elicitation": { - "form": {} + "name": "schedule_delivery", + "arguments": {}, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "example-client", + "version": "1.0.0" }, - "extensions": { - "io.modelcontextprotocol/ui": { - "mimeTypes": ["text/html;profile=mcp-app"], - "elicitation": {} + "io.modelcontextprotocol/clientCapabilities": { + "elicitation": { + "form": {} + }, + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"], + "elicitation": {} + } } } - }, - "clientInfo": { - "name": "claude-desktop", - "version": "1.0.0" } } } ``` +For protocol revisions that use the `initialize` handshake, clients place the +same capability object in `InitializeRequest.params.capabilities`. + **Extension Settings:** - `mimeTypes`: Array of supported content types (REQUIRED, e.g., `["text/html;profile=mcp-app"]`) @@ -2224,27 +2236,29 @@ Future versions may add additional settings: #### Server Capabilities A server that may attach an MCP App to a form-mode elicitation advertises the -same setting in its initialize result: +same setting in its `server/discover` result: ```json { + "jsonrpc": "2.0", + "id": "discover-1", "result": { - "protocolVersion": "2025-11-25", + "resultType": "complete", + "supportedVersions": ["2026-07-28"], "capabilities": { "extensions": { "io.modelcontextprotocol/ui": { "elicitation": {} } } - }, - "serverInfo": { - "name": "example-server", - "version": "1.0.0" } } } ``` +For protocol revisions that use the `initialize` handshake, servers place the +same setting in `InitializeResult.capabilities`. + App-rendered elicitation is negotiated only when all of the following are true: @@ -2260,75 +2274,86 @@ in to app-rendered elicitation. #### Server Behavior -Servers SHOULD check client capabilities before registering UI-enabled tools. The SDK provides the `getUiCapability` helper for this: +Servers SHOULD check the effective client capabilities before attaching MCP +Apps metadata. In `2026-07-28`, those capabilities are request-scoped: ```typescript import { getUiCapability, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; -const uiCap = getUiCapability(clientCapabilities); -if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) { - // Register tools with UI templates - server.registerTool("get_weather", { - description: "Get weather with interactive dashboard", - inputSchema: { /* ... */ }, - _meta: { - ui: { resourceUri: "ui://weather-server/dashboard" } - } - }); -} else { - // Register text-only version - server.registerTool("get_weather", { - description: "Get weather as text", - inputSchema: { /* ... */ } - // No UI metadata - }); +const uiCap = getUiCapability(requestContext.clientCapabilities); +const useApp = + requestContext.clientCapabilities.elicitation?.form !== undefined && + uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE) && + uiCap?.elicitation !== undefined; + +if (useApp) { + elicitation._meta = { + ...elicitation._meta, + ui: { resourceUri: "ui://delivery/choose-window.html" }, + }; } ``` +For protocol revisions that use an `initialize` handshake, +`requestContext.clientCapabilities` is the initialized session capability +object. + **Graceful Degradation:** - Servers SHOULD provide text-only fallback behavior for all UI-enabled tools - Tools MUST return meaningful content array even when UI is available -- Servers MAY register different tool variants based on host capabilities +- Servers MAY select different result metadata based on effective host + capabilities ### App-Rendered Elicitation When app-rendered elicitation is negotiated, a server can associate an MCP App -resource with a standard form-mode `elicitation/create` request. This feature -uses the existing MCP Apps extension, the standard MCP elicitation request and -result, and the existing View↔Host JSON-RPC channel. It defines no new method, -result, or extension identifier. +resource with a standard form-mode `elicitation/create` input request. In +protocol revision `2026-07-28`, that input request is carried by the core +multi-round-trip request (MRTR) flow. This feature uses the existing MCP Apps +extension, the standard MCP elicitation request and result, and the existing +View↔Host JSON-RPC channel. It defines no new method, result, or extension +identifier. #### Associating an App with an Elicitation -The server attaches the app resource URI at `_meta.ui.resourceUri`: +The server attaches the app resource URI at `_meta.ui.resourceUri` on the +embedded elicitation request: ```json { "jsonrpc": "2.0", "id": 12, - "method": "elicitation/create", - "params": { - "mode": "form", - "message": "Choose a delivery window", - "requestedSchema": { - "type": "object", - "properties": { - "window": { - "type": "string", - "oneOf": [ - { "const": "morning", "title": "Morning" }, - { "const": "afternoon", "title": "Afternoon" } - ] + "result": { + "resultType": "input_required", + "inputRequests": { + "delivery-window": { + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "Choose a delivery window", + "requestedSchema": { + "type": "object", + "properties": { + "window": { + "type": "string", + "oneOf": [ + { "const": "morning", "title": "Morning" }, + { "const": "afternoon", "title": "Afternoon" } + ] + } + }, + "required": ["window"] + }, + "_meta": { + "ui": { + "resourceUri": "ui://delivery/choose-window.html" + } + } } - }, - "required": ["window"] - }, - "_meta": { - "ui": { - "resourceUri": "ui://delivery/choose-window.html" } - } + }, + "requestState": "schedule-delivery:v1" } } ``` @@ -2349,7 +2374,7 @@ channel supports elicitation: { "method": "ui/initialize", "params": { - "protocolVersion": "2025-11-25", + "protocolVersion": "2026-01-26", "appInfo": { "name": "delivery-window", "version": "1.0.0" @@ -2364,7 +2389,7 @@ channel supports elicitation: ```json { "result": { - "protocolVersion": "2025-11-25", + "protocolVersion": "2026-01-26", "hostInfo": { "name": "example-host", "version": "1.0.0" @@ -2384,9 +2409,12 @@ form-elicitation UI instead. #### Resolution Flow -For each app-rendered elicitation, the client: +For each app-rendered elicitation in protocol revision `2026-07-28`, the +client: -1. Receives the standard form-mode `elicitation/create` request. +1. Receives an `InputRequiredResult` while processing an originating client + request and selects the standard form-mode `elicitation/create` entry from + `inputRequests`. 2. Confirms that app-rendered elicitation was negotiated and validates `_meta.ui.resourceUri`. 3. Reads and validates the MCP App resource using the same server connection. @@ -2394,10 +2422,12 @@ For each app-rendered elicitation, the client: resource URI, then completes the `ui/initialize` handshake. 5. If the app and host advertised their View↔Host `elicitation` settings, forwards the unchanged `elicitation/create` request to that app instance. -6. Validates the app's response as a standard MCP `ElicitResult` and returns it - unchanged to the server. +6. Validates the app's response as a standard MCP `ElicitResult`. +7. Places the result under the same key in `inputResponses` and retries the + originating request with the server-provided `requestState`, if any. -For example, an app resolves the request by returning the standard result: +For example, an app resolves the View↔Host request by returning the standard +result: ```json { @@ -2412,6 +2442,43 @@ For example, an app resolves the request by returning the standard result: } ``` +The host then resumes the originating MCP operation using the core MRTR shape: + +```json +{ + "jsonrpc": "2.0", + "id": 13, + "method": "tools/call", + "params": { + "name": "schedule_delivery", + "arguments": {}, + "inputResponses": { + "delivery-window": { + "action": "accept", + "content": { + "window": "morning" + } + } + }, + "requestState": "schedule-delivery:v1", + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": { + "elicitation": { + "form": {} + }, + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"], + "elicitation": {} + } + } + } + } + } +} +``` + The host remains the MCP client and policy enforcement point. The app does not connect to the server directly and does not introduce an app-specific result or callback method. @@ -2425,8 +2492,8 @@ View↔Host request. #### Validation, Fallback, and Retry The host MUST validate an accepted app result against the standard -`ElicitResult` shape and the request's `requestedSchema` before returning it to -the server. +`ElicitResult` shape and the request's `requestedSchema` before adding it to +`inputResponses`. If the app resource is missing or invalid, cannot be loaded, fails to initialize, does not advertise `elicitation`, cannot complete the bridge @@ -2438,10 +2505,15 @@ An app result with `action: "decline"` or `action: "cancel"` is a successful resolution and MUST be returned to the server. It MUST NOT trigger native fallback. -Subsequent validation and retry use the core MCP elicitation flow. MCP Apps add -no retry method. When the core protocol associates a retry with the same -request, the host SHOULD preserve the app instance and its local state when -policy and lifecycle permit. +Subsequent validation and retry use the core MCP MRTR flow. MCP Apps add no +retry method. When the core protocol associates a retry with the same request, +the host SHOULD preserve the app instance and its local state when policy and +lifecycle permit. + +On protocol revisions that use direct server-to-client requests, the same +semantic `elicitation/create` request can be delivered directly and its +`ElicitResult` can be returned as the JSON-RPC response. Implementations MUST +NOT assume that connection-bound state survives between MRTR rounds. #### App (Guest UI) Capabilities