From 74c1700644fb5d74f7cc07cc2050c4e832e03af4 Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Wed, 29 Jul 2026 09:23:16 -0700 Subject: [PATCH 1/8] Add app-rendered elicitation support --- specification/draft/apps.mdx | 657 ++++++++++++++++++++++++----------- src/app-bridge.test.ts | 55 +++ src/app-bridge.ts | 39 ++- src/app.ts | 70 +++- 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, 991 insertions(+), 218 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 a87b0e7db..252234c94 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -196,6 +196,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 1ac4db485..65dd7f07c 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -9,6 +9,8 @@ import { type CreateMessageResult, type CreateMessageResultWithTools, type EmptyResult, + type ElicitRequest, + type ElicitResult, type Implementation, type JSONRPCRequest, type ListPromptsRequest, @@ -32,6 +34,7 @@ import { } from "@modelcontextprotocol/client"; import { EmptyResultSchema, + ElicitResultSchema, LoggingMessageNotificationSchema, } from "@modelcontextprotocol/core"; import { EventDispatcher, MethodRegistry } from "./events.js"; @@ -579,8 +582,10 @@ export class AppBridge extends Protocol { * Verify that the view supports the capability required for the given request method. * @internal */ - protected assertCapabilityForMethod(_method: string): void { - // Host-to-view requests are not capability-gated. + protected assertCapabilityForMethod(method: string): void { + if (method === "elicitation/create" && !this._appCapabilities?.elicitation) { + throw new Error(`App does not support elicitation capability (required for ${method})`); + } } /** @@ -624,6 +629,36 @@ export class AppBridge extends Protocol { 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._hostCapabilities.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 }, + options, + ).then((result) => ElicitResultSchema.parse(result) + ); + } + /** * Get the view's implementation info discovered during initialization. * diff --git a/src/app.ts b/src/app.ts index 89c6c35e7..bd8df5280 100644 --- a/src/app.ts +++ b/src/app.ts @@ -21,7 +21,7 @@ import { type ToolListChangedNotification, type Transport, } from "@modelcontextprotocol/client"; -import { EmptyResultSchema } from "@modelcontextprotocol/core"; +import { ElicitRequestParamsSchema, ElicitResultSchema, EmptyResultSchema } from "@modelcontextprotocol/core"; export { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./constants.js"; import { EventDispatcher, MethodRegistry } from "./events.js"; import { @@ -34,7 +34,7 @@ export type { LegacyNotificationHandler, LegacyNotificationHandlerSetter, LegacyRequestHandler, - LegacyRequestHandlerExtra, + LegacyBaseContext, LegacyRequestHandlerSetter, } from "./legacy-handlers.js"; export { EventDispatcher } from "./events.js"; @@ -198,10 +198,10 @@ export type AppToolCallback< > = In extends StandardSchemaV1 ? ( args: StandardSchemaV1.InferOutput, - extra: RequestHandlerExtra, + extra: BaseContext, ) => AppToolResult | Promise> : ( - extra: RequestHandlerExtra, + extra: BaseContext, ) => AppToolResult | Promise>; /** @@ -224,7 +224,7 @@ export type RegisteredAppTool = { /** @internal */ handler: ( args: unknown, - extra: RequestHandlerExtra, + extra: BaseContext, ) => Promise; }; @@ -1072,7 +1072,7 @@ export class App extends Protocol { */ private _onteardown?: ( params: McpUiResourceTeardownRequest["params"], - extra: RequestHandlerExtra, + extra: BaseContext, ) => McpUiResourceTeardownResult | Promise; get onteardown() { return this._onteardown; @@ -1081,7 +1081,7 @@ export class App extends Protocol { callback: | (( params: McpUiResourceTeardownRequest["params"], - extra: RequestHandlerExtra, + extra: BaseContext, ) => McpUiResourceTeardownResult | Promise) | undefined, ) { @@ -1100,6 +1100,49 @@ export class App extends Protocol { ); } + /** + * 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: BaseContext, + ) => ElicitResult | Promise; + get onelicitation() { + return this._onelicitation; + } + set onelicitation( + callback: + | (( + params: ElicitRequest["params"], + extra: BaseContext, + ) => ElicitResult | Promise) + | undefined, + ) { + this.warnIfRequestHandlerReplaced( + "onelicitation", + this._onelicitation, + callback, + ); + this._onelicitation = callback; + if (callback && !this._appCapabilities.elicitation && !this.transport) { + this._appCapabilities.elicitation = {}; + } + this.replaceRequestHandler("elicitation/create", { params: ElicitRequestParamsSchema, result: ElicitResultSchema }, (params, extra) => { + if (!this._onelicitation) { + throw new Error("No onelicitation handler set"); + } + if (params.mode !== undefined && params.mode !== "form") { + throw new Error("MCP Apps only support form-mode elicitations"); + } + return this._onelicitation(params, extra); + }); + } + /** * Convenience handler for tool call requests from the host. * @@ -1130,7 +1173,7 @@ export class App extends Protocol { */ private _oncalltool?: ( params: CallToolRequest["params"], - extra: RequestHandlerExtra, + extra: BaseContext, ) => Promise; get oncalltool() { return this._oncalltool; @@ -1139,7 +1182,7 @@ export class App extends Protocol { callback: | (( params: CallToolRequest["params"], - extra: RequestHandlerExtra, + extra: BaseContext, ) => Promise) | undefined, ) { @@ -1197,7 +1240,7 @@ export class App extends Protocol { */ private _onlisttools?: ( params: ListToolsRequest["params"], - extra: RequestHandlerExtra, + extra: BaseContext, ) => Promise; get onlisttools() { return this._onlisttools; @@ -1206,7 +1249,7 @@ export class App extends Protocol { callback: | (( params: ListToolsRequest["params"], - extra: RequestHandlerExtra, + extra: BaseContext, ) => Promise) | undefined, ) { @@ -1252,6 +1295,11 @@ export class App extends Protocol { ); } return; + case "elicitation/create": + if (!this._appCapabilities.elicitation) { + throw new Error(`App does not support elicitation capability (required for ${method})`); + } + return; default: // `ping`, `ui/*`, and custom (vendor-prefixed) methods need no // declared capability. The base SDK explicitly supports custom diff --git a/src/generated/schema.json b/src/generated/schema.json index 246ed7d9b..8c32c22d3 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 @@ -2640,6 +2670,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, @@ -2943,6 +2979,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, @@ -4501,6 +4543,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 214b227c2..a7671ec4b 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 489d0b0de..2cf82c991 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.js"; import { @@ -475,3 +479,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 5fad055a0..27e346689 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -36,11 +36,16 @@ import { McpUiResourceMeta, McpUiToolMeta, McpUiClientCapabilities, + McpUiServerCapabilities, + McpUiElicitationMeta, } from "../spec.types.js"; import { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "../constants.js"; import type { CallToolResult, ClientCapabilities, + ElicitRequest, + ElicitRequestFormParams, + ServerCapabilities, InputRequiredResult, McpServer, ReadResourceResult, @@ -510,3 +515,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 0c4539637..d55ba434d 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 e4ed629c4..135daed6d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,6 +16,8 @@ import type { CreateMessageResult, CreateMessageResultWithTools, EmptyResult, + ElicitRequest, + ElicitResult, ListPromptsRequest, ListPromptsResult, ListResourceTemplatesRequest, @@ -116,6 +118,8 @@ export { type McpUiToolVisibility, type McpUiToolMeta, type McpUiClientCapabilities, + type McpUiServerCapabilities, + type McpUiElicitationMeta, } from "./spec.types.js"; // Re-export all schemas from generated/schema.ts (already PascalCase) @@ -156,6 +160,9 @@ export { McpUiRequestDisplayModeResultSchema, McpUiToolVisibilitySchema, McpUiToolMetaSchema, + McpUiClientCapabilitiesSchema, + McpUiServerCapabilitiesSchema, + McpUiElicitationMetaSchema, } from "./generated/schema.js"; /** @@ -179,6 +186,7 @@ export type AppRequest = | ReadResourceRequest | ListPromptsRequest | CreateMessageRequest + | ElicitRequest | PingRequest; /** @@ -224,4 +232,5 @@ export type AppResult = | ListPromptsResult | CreateMessageResult | CreateMessageResultWithTools + | ElicitResult | EmptyResult; From 6d7acd26776d69ad7ef3a532b3c681c0dadd9a2f Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Wed, 29 Jul 2026 10:11:08 -0700 Subject: [PATCH 2/8] 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 1206461889fd896cb3128cc70e614286fb258745 Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Wed, 29 Jul 2026 10:49:48 -0700 Subject: [PATCH 3/8] 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 From b7bd89ff1861a91cae219ae17d24e7f5f9786dfe Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Mon, 3 Aug 2026 16:09:54 -0700 Subject: [PATCH 4/8] fix(server): require hierarchical elicitation UI URIs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/server/index.test.ts | 34 ++++++++++++++++++++++++++++------ src/server/index.ts | 40 ++++++++++++++++++++++------------------ 2 files changed, 50 insertions(+), 24 deletions(-) diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 2cf82c991..228f15919 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -533,14 +533,11 @@ describe("elicitation UI metadata", () => { }, }; - it("attaches and reads an absolute ui:// resource URI", () => { - const enhanced = withElicitationUi( - params, - "ui://example/choose-option.html", - ); + it("attaches and reads an absolute hierarchical ui:// resource URI", () => { + const enhanced = withElicitationUi(params, "ui://weather/view.html"); expect(enhanced.requestedSchema).toEqual(params.requestedSchema); expect(getElicitationUiResourceUri(enhanced)).toBe( - "ui://example/choose-option.html", + "ui://weather/view.html", ); }); @@ -560,4 +557,29 @@ describe("elicitation UI metadata", () => { "absolute ui://", ); }); + + it.each([ + "ui:example/view.html", + "ui:///view.html", + "ui://", + "ui://[invalid", + ])("rejects invalid resource URI in the setter: %s", (resourceUri) => { + expect(() => withElicitationUi(params, resourceUri)).toThrow( + "absolute ui://", + ); + }); + + it.each([ + "ui:example/view.html", + "ui:///view.html", + "ui://", + "ui://[invalid", + ])("rejects invalid resource URI in the getter: %s", (resourceUri) => { + expect(() => + getElicitationUiResourceUri({ + ...params, + _meta: { ui: { resourceUri } }, + }), + ).toThrow("absolute ui://"); + }); }); diff --git a/src/server/index.ts b/src/server/index.ts index 27e346689..99a8c9f36 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -563,6 +563,26 @@ export function supportsAppElicitation( ); } +const ELICITATION_UI_URI_ERROR = + "Elicitation UI resourceUri must be an absolute ui:// URI"; + +function validateElicitationUiResourceUri(resourceUri: string): void { + let parsed: URL; + try { + parsed = new URL(resourceUri); + } catch { + throw new Error(ELICITATION_UI_URI_ERROR); + } + + if ( + parsed.protocol !== "ui:" || + !/^ui:\/\//i.test(resourceUri) || + parsed.host.length === 0 + ) { + throw new Error(ELICITATION_UI_URI_ERROR); + } +} + /** * Attach an MCP App resource to a standard form-mode elicitation request. */ @@ -576,15 +596,7 @@ export function withElicitationUi( 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"); - } + validateElicitationUiResourceUri(resourceUri); const meta = params._meta ?? {}; const ui = @@ -619,14 +631,6 @@ export function getElicitationUiResourceUri( 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"); - } + validateElicitationUiResourceUri(resourceUri); return resourceUri; } From bc25e0accb9b99e4da751cf254da9c044255a3e5 Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Sun, 25 Oct 2026 20:06:41 -0700 Subject: [PATCH 5/8] Refresh app elicitation for SDK 2 and extension-only specification --- specification/draft/apps.mdx | 6 +++ src/app-bridge.test.ts | 85 ++++++++++++++++++++++++++++++++++++ src/app-bridge.ts | 16 +++++-- src/app.ts | 58 ++++++++++++++---------- 4 files changed, 138 insertions(+), 27 deletions(-) diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index a1e4cf324..6236ff131 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -2315,6 +2315,12 @@ 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. +This feature is specified entirely by MCP Apps. It uses core capability +extension points and request metadata without changing the core MCP protocol. +The MCP Apps bridge version is negotiated independently of the core version; +app rendering also works with initialized sessions that deliver elicitations as +direct server-to-client requests. + #### Associating an App with an Elicitation The server attaches the app resource URI at `_meta.ui.resourceUri` on the diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 252234c94..267370343 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -233,6 +233,91 @@ describe("App <-> AppBridge integration", () => { }); }); + it.each(["decline", "cancel"] as const)( + "preserves the user's %s result", + async (action) => { + bridge = new AppBridge(createMockClient() as Client, testHostInfo, { + ...testHostCapabilities, + elicitation: {}, + }); + app.onelicitation = async () => ({ action }); + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + await expect( + bridge.requestElicitation({ + message: "Choose", + requestedSchema: { type: "object", properties: {} }, + }), + ).resolves.toEqual({ action }); + }, + ); + + it("keeps metadata and cancellation bound to the bridge request", async () => { + bridge = new AppBridge(createMockClient() as Client, testHostInfo, { + ...testHostCapabilities, + elicitation: {}, + }); + const controller = new AbortController(); + let received!: () => void; + const started = new Promise((resolve) => { + received = resolve; + }); + let cancelled!: () => void; + const aborted = new Promise((resolve) => { + cancelled = resolve; + }); + app.onelicitation = async (params, ctx) => { + expect(params._meta?.ui).toEqual({ + resourceUri: "ui://delivery/choose.html", + }); + ctx.mcpReq.signal.addEventListener("abort", cancelled, { once: true }); + received(); + await aborted; + return { action: "cancel" }; + }; + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + // SDK 2.0.0 ignores cancellation for request ID 0. Use a nonzero + // request ID here to test propagation independently of that SDK bug. + await bridge.request({ method: "ping" }); + const pending = bridge.requestElicitation( + { + message: "Choose", + requestedSchema: { type: "object", properties: {} }, + _meta: { ui: { resourceUri: "ui://delivery/choose.html" } }, + }, + { signal: controller.signal }, + ); + const rejection = pending.then( + () => { + throw new Error("Expected cancellation"); + }, + () => {}, + ); + await started; + controller.abort("test cancellation"); + await rejection; + await aborted; + }); + + it("rejects URL-mode requests before forwarding them", async () => { + bridge = new AppBridge(createMockClient() as Client, testHostInfo, { + ...testHostCapabilities, + elicitation: {}, + }); + app.onelicitation = async () => ({ action: "cancel" }); + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + expect(() => + bridge.requestElicitation({ + mode: "url", + message: "Open", + elicitationId: "test", + url: "https://example.com", + }), + ).toThrow("form-mode"); + }); + it("fails closed when the app did not advertise elicitation", async () => { bridge = new AppBridge(createMockClient() as Client, testHostInfo, { ...testHostCapabilities, diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 65dd7f07c..897254cf8 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -583,8 +583,13 @@ export class AppBridge extends Protocol { * @internal */ protected assertCapabilityForMethod(method: string): void { - if (method === "elicitation/create" && !this._appCapabilities?.elicitation) { - throw new Error(`App does not support elicitation capability (required for ${method})`); + if ( + method === "elicitation/create" && + !this._appCapabilities?.elicitation + ) { + throw new Error( + `App does not support elicitation capability (required for ${method})`, + ); } } @@ -634,7 +639,10 @@ export class AppBridge extends Protocol { * * 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. + * standard MCP `ElicitResult`. Before returning an accepted result to the + * server, hosts must validate its content against `params.requestedSchema`. + * Invalid results should fall back to native form rendering; explicit + * `decline` and `cancel` results must be preserved. */ requestElicitation( params: ElicitRequest["params"], @@ -654,8 +662,8 @@ export class AppBridge extends Protocol { } return this.request( { method: "elicitation/create", params }, + ElicitResultSchema, options, - ).then((result) => ElicitResultSchema.parse(result) ); } diff --git a/src/app.ts b/src/app.ts index bd8df5280..83c9e409d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,8 @@ import { type CreateMessageRequest, type CreateMessageResult, type CreateMessageResultWithTools, + type ElicitRequest, + type ElicitResult, type Implementation, type ListResourcesRequest, type ListResourcesResult, @@ -21,7 +23,11 @@ import { type ToolListChangedNotification, type Transport, } from "@modelcontextprotocol/client"; -import { ElicitRequestParamsSchema, ElicitResultSchema, EmptyResultSchema } from "@modelcontextprotocol/core"; +import { + ElicitRequestParamsSchema, + ElicitResultSchema, + EmptyResultSchema, +} from "@modelcontextprotocol/core"; export { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./constants.js"; import { EventDispatcher, MethodRegistry } from "./events.js"; import { @@ -34,7 +40,7 @@ export type { LegacyNotificationHandler, LegacyNotificationHandlerSetter, LegacyRequestHandler, - LegacyBaseContext, + LegacyRequestHandlerExtra, LegacyRequestHandlerSetter, } from "./legacy-handlers.js"; export { EventDispatcher } from "./events.js"; @@ -198,10 +204,10 @@ export type AppToolCallback< > = In extends StandardSchemaV1 ? ( args: StandardSchemaV1.InferOutput, - extra: BaseContext, + extra: RequestHandlerExtra, ) => AppToolResult | Promise> : ( - extra: BaseContext, + extra: RequestHandlerExtra, ) => AppToolResult | Promise>; /** @@ -224,7 +230,7 @@ export type RegisteredAppTool = { /** @internal */ handler: ( args: unknown, - extra: BaseContext, + extra: RequestHandlerExtra, ) => Promise; }; @@ -1072,7 +1078,7 @@ export class App extends Protocol { */ private _onteardown?: ( params: McpUiResourceTeardownRequest["params"], - extra: BaseContext, + extra: RequestHandlerExtra, ) => McpUiResourceTeardownResult | Promise; get onteardown() { return this._onteardown; @@ -1081,7 +1087,7 @@ export class App extends Protocol { callback: | (( params: McpUiResourceTeardownRequest["params"], - extra: BaseContext, + extra: RequestHandlerExtra, ) => McpUiResourceTeardownResult | Promise) | undefined, ) { @@ -1110,7 +1116,7 @@ export class App extends Protocol { */ private _onelicitation?: ( params: ElicitRequest["params"], - extra: BaseContext, + extra: RequestHandlerExtra, ) => ElicitResult | Promise; get onelicitation() { return this._onelicitation; @@ -1119,7 +1125,7 @@ export class App extends Protocol { callback: | (( params: ElicitRequest["params"], - extra: BaseContext, + extra: RequestHandlerExtra, ) => ElicitResult | Promise) | undefined, ) { @@ -1132,15 +1138,19 @@ export class App extends Protocol { if (callback && !this._appCapabilities.elicitation && !this.transport) { this._appCapabilities.elicitation = {}; } - this.replaceRequestHandler("elicitation/create", { params: ElicitRequestParamsSchema, result: ElicitResultSchema }, (params, extra) => { - if (!this._onelicitation) { - throw new Error("No onelicitation handler set"); - } - if (params.mode !== undefined && params.mode !== "form") { - throw new Error("MCP Apps only support form-mode elicitations"); - } - return this._onelicitation(params, extra); - }); + this.replaceRequestHandler( + "elicitation/create", + { params: ElicitRequestParamsSchema, result: ElicitResultSchema }, + (params, extra) => { + if (!this._onelicitation) { + throw new Error("No onelicitation handler set"); + } + if (params.mode !== undefined && params.mode !== "form") { + throw new Error("MCP Apps only support form-mode elicitations"); + } + return this._onelicitation(params, extra); + }, + ); } /** @@ -1173,7 +1183,7 @@ export class App extends Protocol { */ private _oncalltool?: ( params: CallToolRequest["params"], - extra: BaseContext, + extra: RequestHandlerExtra, ) => Promise; get oncalltool() { return this._oncalltool; @@ -1182,7 +1192,7 @@ export class App extends Protocol { callback: | (( params: CallToolRequest["params"], - extra: BaseContext, + extra: RequestHandlerExtra, ) => Promise) | undefined, ) { @@ -1240,7 +1250,7 @@ export class App extends Protocol { */ private _onlisttools?: ( params: ListToolsRequest["params"], - extra: BaseContext, + extra: RequestHandlerExtra, ) => Promise; get onlisttools() { return this._onlisttools; @@ -1249,7 +1259,7 @@ export class App extends Protocol { callback: | (( params: ListToolsRequest["params"], - extra: BaseContext, + extra: RequestHandlerExtra, ) => Promise) | undefined, ) { @@ -1297,7 +1307,9 @@ export class App extends Protocol { return; case "elicitation/create": if (!this._appCapabilities.elicitation) { - throw new Error(`App does not support elicitation capability (required for ${method})`); + throw new Error( + `App does not support elicitation capability (required for ${method})`, + ); } return; default: From be78bbb1ae655976580b848f6a049e82fd9620bc Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Sun, 25 Oct 2026 20:14:56 -0700 Subject: [PATCH 6/8] Require SDK 2.1 for request ID zero cancellation --- examples/basic-host/package.json | 2 +- examples/basic-server-preact/package.json | 4 +- examples/basic-server-react/package.json | 4 +- examples/basic-server-solid/package.json | 4 +- examples/basic-server-svelte/package.json | 4 +- examples/basic-server-vanillajs/package.json | 4 +- examples/basic-server-vue/package.json | 4 +- examples/budget-allocator-server/package.json | 2 +- examples/cohort-heatmap-server/package.json | 2 +- .../customer-segmentation-server/package.json | 2 +- examples/debug-server/package.json | 2 +- examples/integration-server/package.json | 4 +- examples/lazy-auth-server/package.json | 2 +- examples/map-server/package.json | 4 +- examples/pdf-server/package.json | 4 +- examples/quickstart/package.json | 2 +- examples/scenario-modeler-server/package.json | 2 +- examples/shadertoy-server/package.json | 2 +- examples/sheet-music-server/package.json | 2 +- examples/system-monitor-server/package.json | 2 +- examples/threejs-server/package.json | 4 +- examples/transcript-server/package.json | 2 +- examples/video-resource-server/package.json | 4 +- examples/wiki-explorer-server/package.json | 4 +- package-lock.json | 106 +++++++++--------- package.json | 12 +- src/app-bridge.test.ts | 6 +- 27 files changed, 97 insertions(+), 99 deletions(-) diff --git a/examples/basic-host/package.json b/examples/basic-host/package.json index 1a92a3c3c..828c629a9 100644 --- a/examples/basic-host/package.json +++ b/examples/basic-host/package.json @@ -11,7 +11,7 @@ "dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/examples/basic-server-preact/package.json b/examples/basic-server-preact/package.json index 0e0d416f6..487004f27 100644 --- a/examples/basic-server-preact/package.json +++ b/examples/basic-server-preact/package.json @@ -27,11 +27,11 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "preact": "^10.0.0", diff --git a/examples/basic-server-react/package.json b/examples/basic-server-react/package.json index d51986b04..fd883861f 100644 --- a/examples/basic-server-react/package.json +++ b/examples/basic-server-react/package.json @@ -37,11 +37,11 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", diff --git a/examples/basic-server-solid/package.json b/examples/basic-server-solid/package.json index dff791f9b..2694e5e5e 100644 --- a/examples/basic-server-solid/package.json +++ b/examples/basic-server-solid/package.json @@ -27,11 +27,11 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "solid-js": "1.9.15", diff --git a/examples/basic-server-svelte/package.json b/examples/basic-server-svelte/package.json index 2636ce8d8..76d638a85 100644 --- a/examples/basic-server-svelte/package.json +++ b/examples/basic-server-svelte/package.json @@ -27,11 +27,11 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "svelte": "^5.0.0", diff --git a/examples/basic-server-vanillajs/package.json b/examples/basic-server-vanillajs/package.json index 8d880f8e8..a2c03ce02 100644 --- a/examples/basic-server-vanillajs/package.json +++ b/examples/basic-server-vanillajs/package.json @@ -27,11 +27,11 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "zod": "^4.2.0" diff --git a/examples/basic-server-vue/package.json b/examples/basic-server-vue/package.json index 8fc7c7cb6..d3e406ff3 100644 --- a/examples/basic-server-vue/package.json +++ b/examples/basic-server-vue/package.json @@ -27,11 +27,11 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "vue": "^3.5.0", diff --git a/examples/budget-allocator-server/package.json b/examples/budget-allocator-server/package.json index 52ba7b11f..694f9d105 100644 --- a/examples/budget-allocator-server/package.json +++ b/examples/budget-allocator-server/package.json @@ -32,7 +32,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/cohort-heatmap-server/package.json b/examples/cohort-heatmap-server/package.json index 9c1b2b071..2f5d55ea6 100644 --- a/examples/cohort-heatmap-server/package.json +++ b/examples/cohort-heatmap-server/package.json @@ -32,7 +32,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", diff --git a/examples/customer-segmentation-server/package.json b/examples/customer-segmentation-server/package.json index ad96a4316..a02c479e9 100644 --- a/examples/customer-segmentation-server/package.json +++ b/examples/customer-segmentation-server/package.json @@ -32,7 +32,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/debug-server/package.json b/examples/debug-server/package.json index 136529a03..5577a0a2b 100644 --- a/examples/debug-server/package.json +++ b/examples/debug-server/package.json @@ -40,7 +40,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "zod": "^4.2.0" }, "devDependencies": { diff --git a/examples/integration-server/package.json b/examples/integration-server/package.json index d9061345f..cc7386e94 100644 --- a/examples/integration-server/package.json +++ b/examples/integration-server/package.json @@ -15,11 +15,11 @@ "serve": "bun --watch main.ts" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", diff --git a/examples/lazy-auth-server/package.json b/examples/lazy-auth-server/package.json index 190a18696..1b29b4081 100644 --- a/examples/lazy-auth-server/package.json +++ b/examples/lazy-auth-server/package.json @@ -27,7 +27,7 @@ "dependencies": { "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "jose": "^6.0.0", diff --git a/examples/map-server/package.json b/examples/map-server/package.json index 707ea7f5e..abff1edf8 100644 --- a/examples/map-server/package.json +++ b/examples/map-server/package.json @@ -29,11 +29,11 @@ "serve": "bun --watch main.ts" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "zod": "^4.2.0" diff --git a/examples/pdf-server/package.json b/examples/pdf-server/package.json index de822cd05..247690bca 100644 --- a/examples/pdf-server/package.json +++ b/examples/pdf-server/package.json @@ -29,11 +29,11 @@ }, "dependencies": { "@cantoo/pdf-lib": "2.6.5", - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "pdfjs-dist": "5.4.530", diff --git a/examples/quickstart/package.json b/examples/quickstart/package.json index fde84666c..b679bd6ec 100644 --- a/examples/quickstart/package.json +++ b/examples/quickstart/package.json @@ -18,7 +18,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "zod": "^4.2.0" diff --git a/examples/scenario-modeler-server/package.json b/examples/scenario-modeler-server/package.json index a9b7c9f22..eff1fbb6a 100644 --- a/examples/scenario-modeler-server/package.json +++ b/examples/scenario-modeler-server/package.json @@ -32,7 +32,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/shadertoy-server/package.json b/examples/shadertoy-server/package.json index d093d390d..ec73e3217 100644 --- a/examples/shadertoy-server/package.json +++ b/examples/shadertoy-server/package.json @@ -30,7 +30,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "zod": "^4.2.0" diff --git a/examples/sheet-music-server/package.json b/examples/sheet-music-server/package.json index a0c3b8fa9..fe5e44f64 100644 --- a/examples/sheet-music-server/package.json +++ b/examples/sheet-music-server/package.json @@ -30,7 +30,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "abcjs": "^6.4.4", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/system-monitor-server/package.json b/examples/system-monitor-server/package.json index 03200adb7..a3efeb044 100644 --- a/examples/system-monitor-server/package.json +++ b/examples/system-monitor-server/package.json @@ -32,7 +32,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/examples/threejs-server/package.json b/examples/threejs-server/package.json index ea71f26ca..1547a1ca5 100644 --- a/examples/threejs-server/package.json +++ b/examples/threejs-server/package.json @@ -29,11 +29,11 @@ "serve": "bun --watch main.ts" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", diff --git a/examples/transcript-server/package.json b/examples/transcript-server/package.json index 0f7caf60b..20b8c4e90 100644 --- a/examples/transcript-server/package.json +++ b/examples/transcript-server/package.json @@ -30,7 +30,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "zod": "^4.2.0" diff --git a/examples/video-resource-server/package.json b/examples/video-resource-server/package.json index cf81bd40f..3780d2db9 100644 --- a/examples/video-resource-server/package.json +++ b/examples/video-resource-server/package.json @@ -27,11 +27,11 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "zod": "^4.2.0" diff --git a/examples/wiki-explorer-server/package.json b/examples/wiki-explorer-server/package.json index 1c101ea93..16486235e 100644 --- a/examples/wiki-explorer-server/package.json +++ b/examples/wiki-explorer-server/package.json @@ -29,11 +29,11 @@ "serve": "bun --watch main.ts" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cheerio": "^1.0.0", "cors": "^2.8.5", "express": "^5.1.0", diff --git a/package-lock.json b/package-lock.json index bfddf50aa..caf4ef845 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,10 +16,10 @@ }, "devDependencies": { "@boneskull/typedoc-plugin-mermaid": "^0.2.0", - "@modelcontextprotocol/client": "2.0.0", - "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", + "@modelcontextprotocol/core": "2.1.0", "@modelcontextprotocol/sdk": "1.30.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "@playwright/test": "1.57.0", "@types/bun": "^1.3.2", "@types/node": "20.19.27", @@ -54,9 +54,9 @@ "node": ">=20" }, "peerDependencies": { - "@modelcontextprotocol/client": "^2.0.0", - "@modelcontextprotocol/core": "^2.0.0", - "@modelcontextprotocol/server": "^2.0.0", + "@modelcontextprotocol/client": "^2.1.0", + "@modelcontextprotocol/core": "^2.1.0", + "@modelcontextprotocol/server": "^2.1.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^4.2.0" @@ -77,7 +77,7 @@ "name": "@modelcontextprotocol/ext-apps-basic-host", "version": "2.0.3", "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -120,11 +120,11 @@ "version": "2.0.3", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "preact": "^10.0.0", @@ -170,11 +170,11 @@ "version": "2.0.3", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", @@ -223,11 +223,11 @@ "version": "2.0.3", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "solid-js": "1.9.15", @@ -273,11 +273,11 @@ "version": "2.0.3", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "svelte": "^5.0.0", @@ -323,11 +323,11 @@ "version": "2.0.3", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "zod": "^4.2.0" @@ -371,11 +371,11 @@ "version": "2.0.3", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "vue": "^3.5.0", @@ -424,7 +424,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", @@ -472,7 +472,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", @@ -524,7 +524,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", @@ -572,7 +572,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "zod": "^4.2.0" }, "bin": { @@ -614,11 +614,11 @@ "examples/integration-server": { "version": "2.0.3", "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", @@ -665,7 +665,7 @@ "dependencies": { "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "jose": "^6.0.0", @@ -710,11 +710,11 @@ "version": "2.0.3", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "zod": "^4.2.0" @@ -759,11 +759,11 @@ "license": "MIT", "dependencies": { "@cantoo/pdf-lib": "2.6.5", - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "pdfjs-dist": "5.4.530", @@ -818,7 +818,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "zod": "^4.2.0" @@ -861,7 +861,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", @@ -914,7 +914,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "zod": "^4.2.0" @@ -961,7 +961,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "abcjs": "^6.4.4", "cors": "^2.8.5", "express": "^5.1.0", @@ -1009,7 +1009,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "chart.js": "^4.4.0", "cors": "^2.8.5", "express": "^5.1.0", @@ -1055,11 +1055,11 @@ "version": "2.0.3", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "react": "^19.2.0", @@ -1113,7 +1113,7 @@ "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "zod": "^4.2.0" @@ -1158,11 +1158,11 @@ "version": "2.0.3", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cors": "^2.8.5", "express": "^5.1.0", "zod": "^4.2.0" @@ -1206,11 +1206,11 @@ "version": "2.0.3", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", "@modelcontextprotocol/express": "2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.0", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "cheerio": "^1.0.0", "cors": "^2.8.5", "express": "^5.1.0", @@ -2743,12 +2743,12 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", - "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.1.0.tgz", + "integrity": "sha512-mDVhoy5WjDb0U+4dQPzLcciC2erSex/GRVQqnoZdiuMoE3YiTZdiS7ezNcFwX4XEOWOaj7jZr0kLYnILnL8orA==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/core": "2.1.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", @@ -2761,9 +2761,9 @@ } }, "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", - "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.1.0.tgz", + "integrity": "sha512-YdFj5gMRHr0gYNAhTbQDgjLHiEJdIAUYxomhKseUNye7ZGlISQmUoLh2sveFFCiQ7j2YMffT1kgSND0FSfIFjQ==", "license": "MIT", "dependencies": { "zod": "^4.2.0" @@ -2863,12 +2863,12 @@ } }, "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", - "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.1.0.tgz", + "integrity": "sha512-FdvwZU5N03O77Suh35JqjAqq5R1ZTEPFtwu6N4BgBq28qrQtrCnpbLL5U2pwYCuE194jAw0xnlOVmIqhfoYuUQ==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/core": "2.1.0", "zod": "^4.2.0" }, "engines": { diff --git a/package.json b/package.json index 9f802896e..44c08e11b 100644 --- a/package.json +++ b/package.json @@ -79,10 +79,10 @@ "//interop": "The @modelcontextprotocol/sdk 1.x and ext-apps-v1 devDependencies exist only for src/interop-v1.test.ts, which runs the published 1.7.5 package against this build. The library does not use them.", "devDependencies": { "@boneskull/typedoc-plugin-mermaid": "^0.2.0", - "@modelcontextprotocol/client": "2.0.0", - "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/client": "2.1.0", + "@modelcontextprotocol/core": "2.1.0", "@modelcontextprotocol/sdk": "1.30.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.1.0", "@playwright/test": "1.57.0", "@types/bun": "^1.3.2", "@types/node": "20.19.27", @@ -114,9 +114,9 @@ "zod": "^4.2.0" }, "peerDependencies": { - "@modelcontextprotocol/client": "^2.0.0", - "@modelcontextprotocol/core": "^2.0.0", - "@modelcontextprotocol/server": "^2.0.0", + "@modelcontextprotocol/client": "^2.1.0", + "@modelcontextprotocol/core": "^2.1.0", + "@modelcontextprotocol/server": "^2.1.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^4.2.0" diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 267370343..7b5fd80e5 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -252,7 +252,7 @@ describe("App <-> AppBridge integration", () => { }, ); - it("keeps metadata and cancellation bound to the bridge request", async () => { + it("keeps metadata and cancellation bound to request ID zero", async () => { bridge = new AppBridge(createMockClient() as Client, testHostInfo, { ...testHostCapabilities, elicitation: {}, @@ -270,6 +270,7 @@ describe("App <-> AppBridge integration", () => { expect(params._meta?.ui).toEqual({ resourceUri: "ui://delivery/choose.html", }); + expect(ctx.mcpReq.id).toBe(0); ctx.mcpReq.signal.addEventListener("abort", cancelled, { once: true }); received(); await aborted; @@ -277,9 +278,6 @@ describe("App <-> AppBridge integration", () => { }; await bridge.connect(bridgeTransport); await app.connect(appTransport); - // SDK 2.0.0 ignores cancellation for request ID 0. Use a nonzero - // request ID here to test propagation independently of that SDK bug. - await bridge.request({ method: "ping" }); const pending = bridge.requestElicitation( { message: "Choose", From 57771f4cc050b86c62b8a8c5d0d9d74375a7475f Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Sun, 25 Oct 2026 20:24:14 -0700 Subject: [PATCH 7/8] Clarify app elicitation response contract and bridge exchange --- specification/draft/apps.mdx | 54 ++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index 6236ff131..6ad7d85bd 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -2413,6 +2413,20 @@ 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. +#### App Responsibilities + +An app provides the user interface for the standard form elicitation. To +complete the elicitation, it MUST respond to the forwarded `elicitation/create` +request with a standard MCP `ElicitResult`. The result's `action` MUST be +`accept`, `decline`, or `cancel`. For `accept`, the app MUST return `content` +that conforms to the original `requestedSchema`. + +Custom rendering does not expand the permitted form schema or response types. +The app MUST comply with the core MCP form-elicitation restrictions, including +restrictions on collecting sensitive information. The server MUST supply a +complete standard form elicitation so the host can render it natively when +app rendering is unavailable. + #### Resolution Flow For each app-rendered elicitation in protocol revision `2026-07-28`, the @@ -2432,13 +2446,47 @@ client: 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 View↔Host request by returning the standard -result: +For example, after initialization the host sends this request over the +View↔Host channel. The method and parameters are unchanged; the JSON-RPC ID +belongs to this channel and is independent of the originating server request: ```json { "jsonrpc": "2.0", - "id": 12, + "id": "app-elicitation-1", + "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" + } + } + } +} +``` + +The app resolves that request by returning the standard result with the same +View↔Host request ID: + +```json +{ + "jsonrpc": "2.0", + "id": "app-elicitation-1", "result": { "action": "accept", "content": { From e7bef61b5e8208dc635fb5ba687a55345b42a58e Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Sun, 8 Nov 2026 21:23:03 -0800 Subject: [PATCH 8/8] Address review feedback on app-rendered elicitations --- examples/basic-host/README.md | 6 + examples/basic-host/serve.ts | 2 +- examples/basic-host/src/elicitation.ts | 267 +++++++++++++ examples/basic-host/src/implementation.ts | 20 +- .../integration-server/elicitation-app.html | 25 ++ examples/integration-server/main.ts | 33 +- examples/integration-server/package.json | 2 +- examples/integration-server/server.ts | 75 ++++ .../integration-server/src/elicitation-app.ts | 26 ++ scripts/check-dependency-isolation.mjs | 2 +- specification/draft/apps.mdx | 357 ++++++++--------- src/app-bridge.test.ts | 8 +- src/app-bridge.ts | 59 ++- src/app.ts | 33 +- src/elicitation.test.ts | 361 ++++++++++++++++++ src/elicitation.ts | 201 ++++++++++ src/server/index.test.ts | 2 +- src/server/index.ts | 192 +--------- tests/e2e/elicitation.spec.ts | 92 +++++ 19 files changed, 1322 insertions(+), 441 deletions(-) create mode 100644 examples/basic-host/src/elicitation.ts create mode 100644 examples/integration-server/elicitation-app.html create mode 100644 examples/integration-server/src/elicitation-app.ts create mode 100644 src/elicitation.test.ts create mode 100644 src/elicitation.ts create mode 100644 tests/e2e/elicitation.spec.ts diff --git a/examples/basic-host/README.md b/examples/basic-host/README.md index c64cda75d..1146e05b1 100644 --- a/examples/basic-host/README.md +++ b/examples/basic-host/README.md @@ -41,3 +41,9 @@ Host (port 8080) - Messages flow through the outer iframe which validates and relays them bidirectionally This architecture ensures that even if tool UI code is malicious, it cannot access the host application's DOM, cookies, or JavaScript context. + +### App-rendered form elicitation + +The host advertises form elicitation and the nested MCP Apps elicitation capability. Each request owns a dialog and bridge, validates the App result, and falls back to a native form on rendering failures. Decline and dismissal complete the elicitation without reopening it. The requesting server remains visible outside the sandboxed content. + +Run `EXAMPLE=integration-server npm start` from the repository root and select `schedule-delivery`. The `renderer` argument accepts `app`, `missing` to exercise native fallback, or `native`. The App fixture also includes an invalid answer button to demonstrate schema validation and fallback. These are testing controls, not additional protocol actions. diff --git a/examples/basic-host/serve.ts b/examples/basic-host/serve.ts index bc3fbff14..46fa6fd05 100644 --- a/examples/basic-host/serve.ts +++ b/examples/basic-host/serve.ts @@ -124,7 +124,7 @@ sandboxApp.get(["/", "/sandbox.html"], (req, res) => { res.setHeader("Pragma", "no-cache"); res.setHeader("Expires", "0"); - res.sendFile(join(DIRECTORY, "sandbox.html")); + res.sendFile("sandbox.html", { root: DIRECTORY }); }); sandboxApp.use((_req, res) => { diff --git a/examples/basic-host/src/elicitation.ts b/examples/basic-host/src/elicitation.ts new file mode 100644 index 000000000..f0ceb2bf6 --- /dev/null +++ b/examples/basic-host/src/elicitation.ts @@ -0,0 +1,267 @@ +import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/client"; +import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker"; +import { + AppBridge, + PostMessageTransport, + getElicitationUiResourceUri, + supportsAppElicitation, +} from "@modelcontextprotocol/ext-apps/app-bridge"; +import { + getUiResource, + loadSandboxProxy, + type ServerInfo, +} from "./implementation"; + +type FormParams = Extract< + ElicitRequest["params"], + { requestedSchema: unknown } +>; +const validator = new CfWorkerJsonSchemaValidator(); +export const ELICITATION_CLIENT_CAPABILITIES = { + elicitation: { form: {} }, + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: ["text/html;profile=mcp-app"], + elicitation: {}, + }, + }, +}; + +function panel(server: string, message: string) { + const dialog = document.createElement("dialog"); + dialog.setAttribute("aria-label", `Information requested by ${server}`); + const heading = document.createElement("h2"); + heading.textContent = `Information requested by ${server}`; + const description = document.createElement("p"); + description.textContent = message; + const dismiss = document.createElement("button"); + dismiss.textContent = "Cancel"; + dismiss.onclick = () => dialog.close(); + dialog.append(heading, description, dismiss); + document.body.append(dialog); + dialog.showModal(); + return dialog; +} + +/** Native fallback for the flat primitive/enum schemas permitted by core form mode. */ +async function renderNative( + server: string, + params: FormParams, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const validate = validator.getValidator(params.requestedSchema); + const dialog = panel(server, params.message); + const form = document.createElement("form"); + const error = document.createElement("p"); + error.setAttribute("role", "alert"); + const fields = Object.entries(params.requestedSchema.properties).map( + ([name, schema]) => { + const label = document.createElement("label"); + label.textContent = schema.title ?? name; + const choices = + "enum" in schema + ? schema.enum + : "oneOf" in schema + ? schema.oneOf.map((item) => item.const) + : schema.type === "array" + ? "enum" in schema.items + ? schema.items.enum + : schema.items.anyOf.map((item) => item.const) + : undefined; + let input: HTMLInputElement | HTMLSelectElement; + if (choices) { + input = document.createElement("select"); + input.multiple = schema.type === "array"; + for (const choice of choices) { + const option = document.createElement("option"); + option.value = String(choice); + option.textContent = String(choice); + input.append(option); + } + } else { + input = document.createElement("input"); + input.type = + schema.type === "boolean" + ? "checkbox" + : schema.type === "number" || schema.type === "integer" + ? "number" + : "text"; + if (input.type === "number") + input.step = schema.type === "integer" ? "1" : "any"; + } + input.name = name; + input.required = params.requestedSchema.required?.includes(name) ?? false; + if (schema.type === "boolean") input.required = false; // false is a valid answer + if (schema.default !== undefined) { + if (input instanceof HTMLInputElement && input.type === "checkbox") + input.checked = Boolean(schema.default); + else if ( + input instanceof HTMLSelectElement && + input.multiple && + Array.isArray(schema.default) + ) + for (const option of input.options) + option.selected = schema.default.includes(option.value); + else input.value = String(schema.default); + } + label.append(input); + form.append(label, document.createElement("br")); + return { name, schema, input }; + }, + ); + const submit = document.createElement("button"); + submit.textContent = "Submit"; + const decline = document.createElement("button"); + decline.type = "button"; + decline.textContent = "Decline"; + form.append(submit, decline, error); + dialog.append(form); + let onAbort: () => void = () => {}; + try { + return await new Promise((resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + dialog.addEventListener("close", () => resolve({ action: "cancel" }), { + once: true, + }); + decline.onclick = () => resolve({ action: "decline" }); + form.onsubmit = (event) => { + event.preventDefault(); + const content: NonNullable = {}; + for (const { name, schema, input } of fields) { + if (!input.required && !input.value && schema.type !== "boolean") + continue; + content[name] = + input instanceof HTMLSelectElement && input.multiple + ? Array.from(input.selectedOptions, (o) => o.value) + : input instanceof HTMLInputElement && input.type === "checkbox" + ? input.checked + : schema.type === "number" || schema.type === "integer" + ? Number(input.value) + : input.value; + } + const checked = validate(content); + if (!checked.valid) { + error.textContent = checked.errorMessage; + return; + } + resolve({ action: "accept", content }); + }; + }); + } finally { + signal.removeEventListener("abort", onAbort); + dialog.remove(); + } +} + +async function renderApp( + server: ServerInfo, + params: FormParams, + uri: string, + operationSignal: AbortSignal, +): Promise { + operationSignal.throwIfAborted(); + const dialog = panel(server.name, params.message); + const iframe = document.createElement("iframe"); + iframe.title = `Form provided by ${server.name}`; + iframe.style.cssText = "width:640px;max-width:80vw;height:360px;border:0"; + dialog.append(iframe); + const dismissed = new AbortController(); + const setup = new AbortController(); + const signal = AbortSignal.any([ + operationSignal, + dismissed.signal, + setup.signal, + ]); + dialog.addEventListener("close", () => + dismissed.abort("User dismissed elicitation"), + ); + // This self-contained form needs no server-tool forwarding permissions. + const bridge = new AppBridge( + null, + { name: "MCP Apps Host", version: "1" }, + { elicitation: {} }, + ); + let initialized = false; + const ready = new Promise((resolve) => + bridge.addEventListener("initialized", () => { + initialized = true; + resolve(); + }), + ); + const setupTimeout = setTimeout( + () => setup.abort("App initialization timed out"), + 15000, + ); + let onAbort: () => void = () => {}; + const abort = new Promise((_, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + await Promise.race([ + (async () => { + const resource = await getUiResource(server, uri, signal); + signal.throwIfAborted(); + await loadSandboxProxy( + iframe, + resource.csp, + resource.permissions, + signal, + ); + await bridge.connect( + new PostMessageTransport( + iframe.contentWindow!, + iframe.contentWindow!, + ), + ); + await bridge.sendSandboxResourceReady(resource); + await ready; + })(), + abort, + ]); + clearTimeout(setupTimeout); + const result = await Promise.race([ + bridge.requestElicitation(params, { signal }), + abort, + ]); + return result; + } catch (error) { + operationSignal.throwIfAborted(); + if (dismissed.signal.aborted) return { action: "cancel" }; + throw error; + } finally { + signal.removeEventListener("abort", onAbort); + clearTimeout(setupTimeout); + if (initialized) + await bridge.teardownResource({}, { timeout: 1000 }).catch(() => {}); + await bridge.close(); + dialog.remove(); + } +} + +/** Each call owns its renderer, bridge, fallback, and cleanup. */ +export async function renderElicitation( + server: ServerInfo, + params: ElicitRequest["params"], + signal: AbortSignal, +): Promise { + if (params.mode === "url") + throw new Error("This example host supports form mode only"); + try { + if ( + supportsAppElicitation( + ELICITATION_CLIENT_CAPABILITIES, + server.client.getServerCapabilities(), + ) + ) { + const uri = getElicitationUiResourceUri(params); + if (uri) return await renderApp(server, params, uri, signal); + } + } catch (error) { + signal.throwIfAborted(); + console.warn("[HOST] App rendering failed; using native form", error); + } + return renderNative(server.name, params, signal); +} diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index 5f680ffdc..faae6f984 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -1,3 +1,4 @@ +import { renderElicitation, ELICITATION_CLIENT_CAPABILITIES } from "./elicitation"; import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge"; import { Client, @@ -37,6 +38,8 @@ export async function connectToServer(serverUrl: URL): Promise { const name = client.getServerVersion()?.name ?? serverUrl.href; + const serverInfo: ServerInfo = { name, client, tools: new Map(), resources: new Map(), appHtmlCache: new Map() }; + client.setRequestHandler("elicitation/create", (request, ctx) => renderElicitation(serverInfo, request.params, ctx.mcpReq.signal)); const toolsList = await client.listTools(); const tools = new Map(toolsList.tools.map((tool) => [tool.name, tool])); log.info("Server tools:", Array.from(tools.keys())); @@ -46,13 +49,13 @@ export async function connectToServer(serverUrl: URL): Promise { const resources = new Map(resourcesList.resources.map((r) => [r.uri, r])); log.info("Server resources:", Array.from(resources.keys())); - return { name, client, tools, resources, appHtmlCache: new Map() }; + return Object.assign(serverInfo, { tools, resources }); } async function connectWithFallback(serverUrl: URL): Promise { // Try Streamable HTTP first (modern transport) try { - const client = new Client(IMPLEMENTATION); + const client = new Client(IMPLEMENTATION, { capabilities: ELICITATION_CLIENT_CAPABILITIES, versionNegotiation: { mode: "auto" } }); await client.connect(new StreamableHTTPClientTransport(serverUrl)); log.info("Connected via Streamable HTTP transport"); return client; @@ -62,7 +65,7 @@ async function connectWithFallback(serverUrl: URL): Promise { // Fall back to SSE (deprecated but needed for older servers) try { - const client = new Client(IMPLEMENTATION); + const client = new Client(IMPLEMENTATION, { capabilities: ELICITATION_CLIENT_CAPABILITIES }); await client.connect(new SSEClientTransport(serverUrl)); log.info("Connected via SSE transport"); return client; @@ -116,9 +119,9 @@ export function callTool( } -async function getUiResource(serverInfo: ServerInfo, uri: string): Promise { +export async function getUiResource(serverInfo: ServerInfo, uri: string, signal?: AbortSignal): Promise { log.info("Reading UI resource:", uri); - const resource = await serverInfo.client.readResource({ uri }); + const resource = await serverInfo.client.readResource({ uri }, { signal }); if (!resource) { throw new Error(`Resource not found: ${uri}`); @@ -164,6 +167,7 @@ export function loadSandboxProxy( iframe: HTMLIFrameElement, csp?: McpUiResourceCsp, permissions?: McpUiResourcePermissions, + signal?: AbortSignal, ): Promise { // Prevent reload if (iframe.src) return Promise.resolve(false); @@ -179,11 +183,15 @@ export function loadSandboxProxy( const readyNotification: McpUiSandboxProxyReadyNotification["method"] = "ui/notifications/sandbox-proxy-ready"; - const readyPromise = new Promise((resolve) => { + signal?.throwIfAborted(); + const readyPromise = new Promise((resolve, reject) => { + const abort = () => { window.removeEventListener("message", listener); reject(signal?.reason); }; + signal?.addEventListener("abort", abort, { once: true }); const listener = ({ source, data }: MessageEvent) => { if (source === iframe.contentWindow && data?.method === readyNotification) { log.info("Sandbox proxy loaded") window.removeEventListener("message", listener); + signal?.removeEventListener("abort", abort); resolve(true); } }; diff --git a/examples/integration-server/elicitation-app.html b/examples/integration-server/elicitation-app.html new file mode 100644 index 000000000..04339957e --- /dev/null +++ b/examples/integration-server/elicitation-app.html @@ -0,0 +1,25 @@ + + + + + Delivery window + + +

Choose a delivery window

+
+
+ + + + +
+
+ + + diff --git a/examples/integration-server/main.ts b/examples/integration-server/main.ts index 9a42842df..65ef1056e 100644 --- a/examples/integration-server/main.ts +++ b/examples/integration-server/main.ts @@ -5,8 +5,8 @@ */ import { createMcpExpressApp } from "@modelcontextprotocol/express"; -import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; -import type { McpServer } from "@modelcontextprotocol/server"; +import { toNodeHandler } from "@modelcontextprotocol/node"; +import { createMcpHandler, type McpServer } from "@modelcontextprotocol/server"; import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import cors from "cors"; import type { Request, Response } from "express"; @@ -25,31 +25,10 @@ export async function startStreamableHTTPServer( const app = createMcpExpressApp({ host: "0.0.0.0" }); app.use(cors()); - app.all("/mcp", async (req: Request, res: Response) => { - const server = createServer(); - const transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - }); - - res.on("close", () => { - transport.close().catch(() => {}); - server.close().catch(() => {}); - }); - - try { - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error("MCP error:", error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: "2.0", - error: { code: -32603, message: "Internal server error" }, - id: null, - }); - } - } - }); + // Modern requests use the SDK's stateless MRTR handler. Its default legacy + // fallback retains the example's prior per-request 2025 behavior. + const handle = toNodeHandler(createMcpHandler(createServer)); + app.all("/mcp", (req: Request, res: Response) => handle(req, res, req.body)); const httpServer = app.listen(port, (err) => { if (err) { diff --git a/examples/integration-server/package.json b/examples/integration-server/package.json index cc7386e94..859b3b49c 100644 --- a/examples/integration-server/package.json +++ b/examples/integration-server/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && cross-env INPUT=elicitation-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", "watch": "cross-env INPUT=mcp-app.html vite build --watch", "serve:http": "bun --watch main.ts", "serve:stdio": "bun main.ts --stdio", diff --git a/examples/integration-server/server.ts b/examples/integration-server/server.ts index a41af15ba..d5e56f0ee 100644 --- a/examples/integration-server/server.ts +++ b/examples/integration-server/server.ts @@ -2,9 +2,15 @@ import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, + withElicitationUi, + supportsAppElicitation, + EXTENSION_ID, } from "@modelcontextprotocol/ext-apps/server"; import { McpServer, + inputRequired, + CLIENT_CAPABILITIES_META_KEY, + type ClientCapabilities, type CallToolResult, type ReadResourceResult, } from "@modelcontextprotocol/server"; @@ -31,6 +37,75 @@ export function createServer(): McpServer { version: "1.0.0", }); + const appsCapabilities = { + extensions: { [EXTENSION_ID]: { elicitation: {} } }, + }; + server.server.registerCapabilities(appsCapabilities); + const elicitationUri = "ui://delivery/choose-window.html"; + registerAppResource( + server, + "delivery-window", + elicitationUri, + {}, + async () => ({ + contents: [ + { + uri: elicitationUri, + mimeType: RESOURCE_MIME_TYPE, + text: await fs.readFile( + path.join(DIST_DIR, "elicitation-app.html"), + "utf8", + ), + }, + ], + }), + ); + server.registerTool( + "schedule-delivery", + { + description: "Choose a delivery window using an App or native form", + inputSchema: z.object({ + renderer: z.enum(["app", "missing", "native"]).default("app"), + }), + }, + async ({ renderer }, ctx) => { + const answer = ctx.mcpReq.inputResponses?.["delivery-window"]; + if (answer) + return { + content: [{ type: "text" as const, text: JSON.stringify(answer) }], + }; + const capabilities = + ((ctx.mcpReq.envelope as Record | undefined)?.[ + CLIENT_CAPABILITIES_META_KEY + ] as ClientCapabilities | undefined) ?? + server.server.getClientCapabilities(); + const params = { + mode: "form" as const, + message: "Choose a delivery window", + requestedSchema: { + type: "object" as const, + properties: { + window: { type: "string" as const, enum: ["morning", "afternoon"] }, + }, + required: ["window"], + }, + }; + const request = + renderer !== "native" && + supportsAppElicitation(capabilities, appsCapabilities) + ? withElicitationUi( + params, + renderer === "missing" + ? "ui://delivery/missing.html" + : elicitationUri, + ) + : params; + return inputRequired({ + inputRequests: { "delivery-window": inputRequired.elicit(request) }, + }); + }, + ); + registerAppTool( server, "get-time", diff --git a/examples/integration-server/src/elicitation-app.ts b/examples/integration-server/src/elicitation-app.ts new file mode 100644 index 000000000..eec3c45d5 --- /dev/null +++ b/examples/integration-server/src/elicitation-app.ts @@ -0,0 +1,26 @@ +import { App } from "@modelcontextprotocol/ext-apps"; +import type { ElicitResult } from "@modelcontextprotocol/client"; +const app = new App({ name: "Delivery window", version: "1" }, {}); +app.onelicitation = async (_params, context) => + new Promise((resolve) => { + const form = document.querySelector("form")!; + document.querySelector("fieldset")!.disabled = false; + form.onsubmit = (event) => { + event.preventDefault(); + resolve({ + action: "accept", + content: { window: new FormData(form).get("window") as string }, + }); + }; + document.querySelector("#decline")!.onclick = () => + resolve({ action: "decline", content: { unused: "must be dropped" } }); + document.querySelector("#invalid")!.onclick = () => + resolve({ action: "accept", content: { window: "night" } }); + context.mcpReq.signal.addEventListener( + "abort", + () => resolve({ action: "cancel" }), + { once: true }, + ); + }); +// Elicitation input arrives through onelicitation; no tool-input notification is needed. +void app.connect(); diff --git a/scripts/check-dependency-isolation.mjs b/scripts/check-dependency-isolation.mjs index 09176142b..b39833a49 100644 --- a/scripts/check-dependency-isolation.mjs +++ b/scripts/check-dependency-isolation.mjs @@ -99,7 +99,7 @@ try { // server must be neither installed (it is an optional peer) nor bundled. mustNotInstall: true, entry: - 'import { App } from "@modelcontextprotocol/ext-apps"; import { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; console.log(App, AppBridge, useApp);', + 'import { App } from "@modelcontextprotocol/ext-apps"; import { AppBridge, supportsAppElicitation, getUiServerCapability, getElicitationUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; console.log(App, AppBridge, useApp, supportsAppElicitation, getUiServerCapability, getElicitationUiResourceUri);', }, { // Server author: ext-apps + server. npm auto-installs client as a diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index 6ad7d85bd..d24f705e7 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -529,6 +529,10 @@ Note that `tools/call` and `tools/list` flow **bidirectionally**: - `resources/read` - Read resource content +**Elicitation:** + +- `elicitation/create` - Host forwards a standard form elicitation to the bound App, which returns a standard `ElicitResult`. Requires both host and App `elicitation` capabilities. See [App-Rendered Elicitation](#app-rendered-elicitation). + **Sampling:** - `sampling/createMessage` - Request an LLM completion from the host (uses the standard MCP [`CreateMessageRequest`](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) / `CreateMessageResult` types, including SEP-1577 `tools` / `toolChoice` / `tool_use` content blocks). The host has full discretion over model selection and SHOULD apply rate limiting, cost controls, and user approval (human-in-the-loop). Apps MUST check `hostCapabilities.sampling` before sending this request, and `hostCapabilities.sampling.tools` before including `tools` in the request params. @@ -548,6 +552,8 @@ When the View sends an `ui/initialize` request to the Host, it MUST include its ```typescript interface McpUiAppCapabilities { + /** App can render standard form elicitations. */ + elicitation?: {}; /** Experimental features keyed by identifier. */ experimental?: Record; /** App exposes MCP-style tools that the host can call. */ @@ -677,6 +683,8 @@ interface SupportedContentBlockModalities { } interface HostCapabilities { + /** Host can forward standard form elicitations to Apps. */ + elicitation?: {}; /** Experimental features keyed by identifier. */ experimental?: Record; /** Host supports opening external URLs. */ @@ -1238,6 +1246,8 @@ Host behavior: #### Requests (Host → App) +When both peers declare `elicitation`, the Host can send `elicitation/create` for a pending form elicitation. The App returns the standard `ElicitResult`. See [App-Rendered Elicitation](#app-rendered-elicitation) for the exchange and lifecycle requirements. + When Apps declare the `tools` capability, the Host can send standard MCP tool requests to the App: `tools/call` - Execute an App-registered tool @@ -1327,7 +1337,7 @@ interface Tool { #### Notifications (Host → View) -`ui/notifications/tool-input` - Host MUST send this notification with the complete tool arguments after the View's initialize request completes. +`ui/notifications/tool-input` - For a tool-bound View, the Host MUST send this notification with the complete tool arguments after the View's initialize request completes. An elicitation-bound View receives `elicitation/create` instead; it MUST NOT require tool-input or tool-result notifications to render the form. ```typescript { @@ -1513,7 +1523,7 @@ These messages facilitate the communication between the outer sandbox proxy ifra ### Lifecycle -The typical lifecycle for rendering a UI resource: +The following describes tool-bound Views. Elicitation-bound Views use the same sandbox initialization and teardown, with the input and completion rules in [App-Rendered Elicitation](#app-rendered-elicitation). #### 1. Connection & Discovery @@ -2178,16 +2188,11 @@ 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 -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`: +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 { @@ -2219,14 +2224,12 @@ is `io.modelcontextprotocol/ui`: } ``` -For protocol revisions that use the `initialize` handshake, clients place the -same capability object in `InitializeRequest.params.capabilities`. +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"]`) -- `elicitation`: Client can use an MCP App to render and resolve form-mode - `elicitation/create` requests that identify a UI resource +- `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: @@ -2235,8 +2238,7 @@ 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 `server/discover` result: +A server that may attach an MCP App to a form-mode elicitation advertises the same setting in its `server/discover` result: ```json { @@ -2256,75 +2258,128 @@ same setting in its `server/discover` result: } ``` -For protocol revisions that use the `initialize` handshake, servers place the -same setting in `InitializeResult.capabilities`. +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: +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`. +1. The client supports core form elicitation, either through `elicitation.form` or the backwards-compatible empty core capability `elicitation: {}`. A URL-only capability does not imply form support. +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. +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 the effective client capabilities before attaching MCP -Apps metadata. In `2026-07-28`, those capabilities are request-scoped: +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(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" }, - }; +import { EXTENSION_ID, supportsAppElicitation, withElicitationUi } from "@modelcontextprotocol/ext-apps/server"; + +// Advertise this capability through server/discover or legacy initialization. +const serverCapabilities = { + extensions: { [EXTENSION_ID]: { elicitation: {} } }, +}; + +if (supportsAppElicitation(requestContext.clientCapabilities, serverCapabilities)) { + elicitation = withElicitationUi(elicitation, "ui://delivery/choose-window.html"); } ``` -For protocol revisions that use an `initialize` handshake, -`requestContext.clientCapabilities` is the initialized session capability -object. +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 select different result metadata based on effective host - capabilities +- Servers MAY select different result metadata based on effective host capabilities + +#### 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: + +```json +{ + "method": "ui/initialize", + "params": { + "appInfo": { + "name": "TicTacToe", + "version": "1.0.0" + }, + "appCapabilities": { + "tools": { + "listChanged": true + } + } + } +} +``` + +The host responds with its own capabilities, including support for proxying server tools: + +```json +{ + "result": { + "hostInfo": { + "name": "claude-desktop", + "version": "1.0.0" + }, + "hostCapabilities": { + "serverTools": { + "listChanged": true + }, + "openLinks": {}, + "logging": {} + } + } +} +``` + +**App Capability: `tools`** + +When present, the app can register tools that the host and agent can call. + +- `listChanged` (boolean, optional): If `true`, the app will send `notifications/tools/list_changed` when tools are added, removed, or modified + +**Host Capability: `serverTools`** + +When present, the host can proxy calls from the app to MCP server tools. + +- `listChanged` (boolean, optional): If `true`, the host will send `notifications/tools/list_changed` when server tools change + +These capabilities are independent - an app can have one, both, or neither. + +**TypeScript Types:** + +```typescript +interface McpUiAppCapabilities { + /** App can render standard form elicitations. */ + elicitation?: {}; + tools?: { + listChanged?: boolean; + }; +} + +interface McpUiHostCapabilities { + /** Host can forward standard form elicitations to Apps. */ + elicitation?: {}; + serverTools?: { + listChanged?: boolean; + }; + openLinks?: {}; + logging?: {}; +} +``` ### App-Rendered Elicitation -When app-rendered elicitation is negotiated, a server can associate an MCP App -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. +When app-rendered elicitation is negotiated, a server can associate an MCP App 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. -This feature is specified entirely by MCP Apps. It uses core capability -extension points and request metadata without changing the core MCP protocol. -The MCP Apps bridge version is negotiated independently of the core version; -app rendering also works with initialized sessions that deliver elicitations as -direct server-to-client requests. +This feature is specified entirely by MCP Apps. It uses core capability extension points and request metadata without changing the core MCP protocol. The MCP Apps bridge version is negotiated independently of the core version; app rendering also works with initialized sessions that deliver elicitations as direct server-to-client requests. #### Associating an App with an Elicitation -The server attaches the app resource URI at `_meta.ui.resourceUri` on the -embedded elicitation request: +The server attaches the app resource URI at `_meta.ui.resourceUri` on the embedded elicitation request: ```json { @@ -2364,17 +2419,13 @@ embedded elicitation request: } ``` -`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. +`resourceUri` MUST be an absolute `ui://` URI with a nonempty authority and a lowercase scheme, in canonical URL form. Implementations MUST reject malformed values, including whitespace or control characters removed by URL parsing, rather than silently rewriting the resource identifier. 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. +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: +The host and the selected app independently confirm that their View↔Host channel supports elicitation: ```json { @@ -2408,47 +2459,43 @@ channel supports elicitation: } ``` -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. +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. #### App Responsibilities -An app provides the user interface for the standard form elicitation. To -complete the elicitation, it MUST respond to the forwarded `elicitation/create` -request with a standard MCP `ElicitResult`. The result's `action` MUST be -`accept`, `decline`, or `cancel`. For `accept`, the app MUST return `content` -that conforms to the original `requestedSchema`. +An app provides the user interface for the standard form elicitation. To complete the elicitation, it MUST respond to the forwarded `elicitation/create` request with a standard MCP `ElicitResult`. The result's `action` MUST be `accept`, `decline`, or `cancel`. For `accept`, the app MUST return `content` that conforms to the original `requestedSchema`. + +Custom rendering does not expand the permitted form schema or response types. The app MUST comply with the core MCP form-elicitation restrictions, including restrictions on collecting sensitive information. The server MUST supply a complete standard form elicitation so the host can render it natively when app rendering is unavailable. + +#### Elicitation-Bound View Lifecycle + +App-rendered elicitation changes the presentation of a standard form elicitation. It MUST preserve core request, response, user-interaction, and cancellation semantics. In particular, the client MUST identify the requesting server, offer clear decline and cancel controls, and let users review and modify form responses before submission. -Custom rendering does not expand the permitted form schema or response types. -The app MUST comply with the core MCP form-elicitation restrictions, including -restrictions on collecting sensitive information. The server MUST supply a -complete standard form elicitation so the host can render it natively when -app rendering is unavailable. +Hosts MAY create a new View or reuse an existing View. In either case, they MUST bind each pending elicitation to the originating server connection, request, resource URI, and selected View. Reusing a tool-bound View MUST NOT replace its tool state or route an unrelated elicitation to it merely because it is currently active. Concurrent elicitations MUST retain independent request/response associations. + +After the Apps handshake, the host sends `elicitation/create` as the input to an elicitation-bound View. No tool invocation is required. The host MUST NOT invent tool arguments or send synthetic tool-input or tool-result notifications solely to satisfy this lifecycle. The App MUST be able to render the elicitation from its request parameters without those notifications. + +The elicitation completes when its response is returned to the host. `accept` means the user explicitly submitted an answer, `decline` means the user explicitly refused, and `cancel` means the user dismissed the interaction. A completed request MUST NOT accept further answers. The host MAY retain a summary or reuse the View for a subsequent request; neither reopens a completed elicitation. Iframe retention and presentation are host choices, not persistent server-side elicitation sessions. + +User dismissal is distinct from cancellation of the originating operation. Dismissal resolves the pending elicitation with `action: "cancel"`; the server can handle that result normally. If the originating operation is cancelled, the host SHOULD propagate cancellation to the pending bridge request, stop waiting, and ignore late answers. It MUST NOT open a native fallback form for an operation that is no longer pending. + +Hosts SHOULD use configurable, bounded timeouts suitable for human input and coordinate them with the originating operation's deadline. Core MCP does not prescribe a duration. The SDK's `requestElicitation` defaults to ten minutes; hosts MAY override that default and SHOULD pass the originating operation's abort signal. Progress notifications are not required while a user fills in the form. + +When a host removes an initialized View after completion, fallback, dismissal, operation cancellation, or disconnection, it MUST follow the existing `ui/resource-teardown` procedure when communication is still available. Hosts SHOULD release pending handlers and connection resources. If a View is retained for a related retry, its state MAY be preserved, but implementations MUST NOT assume that connection-bound state survives MRTR rounds. #### Resolution Flow -For each app-rendered elicitation in protocol revision `2026-07-28`, the -client: +For each app-rendered elicitation in protocol revision `2026-07-28`, the client: -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`. +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. -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. +4. Creates or selects an app instance that is bound to this exact request and resource URI, completing the `ui/initialize` handshake if it has not already initialized. +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`. -7. Places the result under the same key in `inputResponses` and retries the - originating request with the server-provided `requestState`, if any. +7. Places the result under the same key in `inputResponses` and retries the originating request with the server-provided `requestState`, if any. -For example, after initialization the host sends this request over the -View↔Host channel. The method and parameters are unchanged; the JSON-RPC ID -belongs to this channel and is independent of the originating server request: +For example, after initialization the host sends this request over the View↔Host channel. The method and parameters are unchanged; the JSON-RPC ID belongs to this channel and is independent of the originating server request: ```json { @@ -2480,8 +2527,7 @@ belongs to this channel and is independent of the originating server request: } ``` -The app resolves that request by returning the standard result with the same -View↔Host request ID: +The app resolves that request by returning the standard result with the same View↔Host request ID: ```json { @@ -2533,114 +2579,21 @@ The host then resumes the originating MCP operation using the core MRTR shape: } ``` -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 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. +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 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 -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 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 - -Apps advertise their capabilities in the `ui/initialize` request to the host. When an app supports tool registration, it includes the `tools` capability: - -```json -{ - "method": "ui/initialize", - "params": { - "appInfo": { - "name": "TicTacToe", - "version": "1.0.0" - }, - "appCapabilities": { - "tools": { - "listChanged": true - } - } - } -} -``` - -The host responds with its own capabilities, including support for proxying server tools: - -```json -{ - "result": { - "hostInfo": { - "name": "claude-desktop", - "version": "1.0.0" - }, - "hostCapabilities": { - "serverTools": { - "listChanged": true - }, - "openLinks": {}, - "logging": {} - } - } -} -``` - -**App Capability: `tools`** +The host MUST validate every App response against the standard `ElicitResult` shape. For `accept`, it MUST also validate `content` against the original `requestedSchema` before returning it to the server or adding it to `inputResponses`. The SDK bridge performs these checks and rejects invalid results using its configured JSON Schema validator. Validation does not prove that a user interacted with the View. -When present, the app can register tools that the host and agent can call. - -- `listChanged` (boolean, optional): If `true`, the app will send `notifications/tools/list_changed` when tools are added, removed, or modified - -**Host Capability: `serverTools`** +If either peer lacks the required elicitation capability, the host MUST use native form rendering. If resource loading, initialization, bridge execution, or result validation fails while the operation is still pending, the host SHOULD fall back to native form rendering, preserving the original message and requested schema. Hosts MAY reject the interaction under their security or resource policy; they MUST NOT report that policy decision as a user acceptance. Malformed UI metadata is a rendering failure, not a reason to discard the complete standard form. A host using a metadata helper that throws MUST catch that error within this fallback path. -When present, the host can proxy calls from the app to MCP server tools. +An App result with `action: "decline"` or `action: "cancel"` is a completed resolution. The host MUST preserve that action and MUST NOT trigger native fallback. It MUST omit `content` from these non-accept results before returning them to the server. This extension restricts forwarding of unused content; it does not introduce a new result type. -- `listChanged` (boolean, optional): If `true`, the host will send `notifications/tools/list_changed` when server tools change +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. -These capabilities are independent - an app can have one, both, or neither. - -**TypeScript Types:** - -```typescript -interface McpUiAppCapabilities { - tools?: { - listChanged?: boolean; - }; -} - -interface McpUiHostCapabilities { - serverTools?: { - listChanged?: boolean; - }; - openLinks?: {}; - logging?: {}; -} -``` +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. ### Extensibility @@ -2966,6 +2919,14 @@ App tools MUST be tied to the app's lifecycle: - Hosts MUST NOT persist app tool registrations across sessions - Calling a tool from a closed app MUST return an error +#### 6. App-Rendered Elicitation + +An elicitation View is supplied by the requesting server and is not trusted host UI. A malicious or compromised View may fabricate an `accept`, imitate host controls, or request information prohibited in form mode. The same server can also omit elicitation entirely; an App response is not an independent security boundary against that server. Nevertheless, conforming Apps MUST preserve core user-interaction semantics: an `accept` represents explicit user submission, not an automated answer. + +Hosts MUST clearly attribute the requesting server and distinguish the sandboxed View from trusted host controls. They MUST retain the core user's ability to review, modify, decline, and cancel. Hosts MAY provide trusted submission controls or confirmation UI as required by their policy. A second confirmation step is not universally required. Hosts MUST NOT treat a View's `accept` as independent authorization to bypass their own tool, permission, or data-access controls. + +The sandbox, CSP, resource checks, and existing host permissions continue to apply. Hosts MUST validate response shapes and accepted content, and omit `content` for decline/cancel responses as specified above. These checks limit the result forwarded through MCP; they cannot prove human interaction, prevent all phishing, or prevent data transmission over other channels the host has allowed. Form mode restrictions on sensitive information remain unchanged; URL mode is not made app-renderable by this proposal. + ### Other risks - **Social engineering:** UI can still display misleading content. Hosts should clearly indicate sandboxed UI boundaries. diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 7b5fd80e5..e02dd37e1 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -306,14 +306,14 @@ describe("App <-> AppBridge integration", () => { app.onelicitation = async () => ({ action: "cancel" }); await bridge.connect(bridgeTransport); await app.connect(appTransport); - expect(() => + await expect( bridge.requestElicitation({ mode: "url", message: "Open", elicitationId: "test", url: "https://example.com", }), - ).toThrow("form-mode"); + ).rejects.toThrow("form-mode"); }); it("fails closed when the app did not advertise elicitation", async () => { @@ -325,12 +325,12 @@ describe("App <-> AppBridge integration", () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); - expect(() => + await expect( bridge.requestElicitation({ message: "Choose an option", requestedSchema: { type: "object", properties: {} }, }), - ).toThrow("App does not support elicitation"); + ).rejects.toThrow("App does not support elicitation"); }); }); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 897254cf8..dbb3f9b20 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -1,3 +1,15 @@ +import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker"; +import type { jsonSchemaValidator } from "@modelcontextprotocol/client"; +import { validateUiResourceUri } from "./elicitation.js"; +export { + EXTENSION_ID, + getUiCapability, + getUiServerCapability, + supportsAppElicitation, + getElicitationUiResourceUri, + validateUiResourceUri, +} from "./elicitation.js"; + import { Protocol, type BaseContext, @@ -112,7 +124,7 @@ export { PostMessageTransport } from "./message-transport.js"; * * @param tool - A tool object with optional `_meta` property * @returns The UI resource URI if valid, undefined if not present - * @throws Error if resourceUri is present but invalid (does not start with "ui://") + * @throws Error if resourceUri is present but is not a canonical absolute ui:// URI * * @example * ```typescript @@ -137,7 +149,12 @@ export function getToolUiResourceUri(tool: Partial): string | undefined { uri = tool._meta?.[RESOURCE_URI_META_KEY]; } - if (typeof uri === "string" && uri.startsWith("ui://")) { + if (typeof uri === "string") { + try { + validateUiResourceUri(uri); + } catch { + throw new Error(`Invalid UI resource URI: ${JSON.stringify(uri)}`); + } return uri; } else if (uri !== undefined) { throw new Error(`Invalid UI resource URI: ${JSON.stringify(uri)}`); @@ -213,6 +230,8 @@ export function buildAllowAttribute( */ export type HostOptions = Omit & { hostContext?: McpUiHostContext; + /** JSON Schema provider for validating accepted elicitation content. Defaults to CfWorker. */ + jsonSchemaValidator?: jsonSchemaValidator; }; /** @@ -310,6 +329,7 @@ export class AppBridge extends Protocol { private _hostContext: McpUiHostContext = {}; private _appInfo?: Implementation; private _initializedReceived = false; + private readonly _elicitationValidator: jsonSchemaValidator; private readonly _registeredEvents = new Set(); private readonly _events = new EventDispatcher(); private readonly _methods = new MethodRegistry(); @@ -537,6 +557,8 @@ export class AppBridge extends Protocol { options?: HostOptions, ) { super(options); + this._elicitationValidator = + options?.jsonSchemaValidator ?? new CfWorkerJsonSchemaValidator(); this._methods.replace("notifications/cancelled"); this._methods.replace("notifications/progress"); @@ -639,12 +661,16 @@ export class AppBridge extends Protocol { * * 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`. Before returning an accepted result to the - * server, hosts must validate its content against `params.requestedSchema`. - * Invalid results should fall back to native form rendering; explicit - * `decline` and `cancel` results must be preserved. + * standard MCP `ElicitResult`. Accepted content is validated against the + * requested schema using the configured JSON Schema provider. Invalid results + * reject; hosts should use native form rendering if the operation is still + * pending. Explicit `decline` and `cancel` results preserve the action and omit content. + * + * The default timeout is ten minutes for human input. Hosts can override it + * per request and should pass the originating operation's abort signal. + * Cancellation of that operation must not open a fallback form. */ - requestElicitation( + async requestElicitation( params: ElicitRequest["params"], options?: RequestOptions, ): Promise { @@ -660,11 +686,26 @@ export class AppBridge extends Protocol { if (params.mode !== undefined && params.mode !== "form") { throw new Error("MCP Apps only support form-mode elicitations"); } - return this.request( + // Compile before sending so invalid schemas cannot leave an orphaned form. + const validate = this._elicitationValidator.getValidator( + params.requestedSchema, + ); + const result = await this.request( { method: "elicitation/create", params }, ElicitResultSchema, - options, + { ...options, timeout: options?.timeout ?? 10 * 60 * 1000 }, ); + if (result.action !== "accept") { + const { content: _content, ...withoutContent } = result; + return withoutContent; + } + const checked = validate(result.content); + if (!checked.valid) { + throw new Error( + `App elicitation content does not match requestedSchema: ${checked.errorMessage}`, + ); + } + return result; } /** diff --git a/src/app.ts b/src/app.ts index 83c9e409d..ca86e266b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1114,6 +1114,7 @@ export class App extends Protocol { * Register this handler before {@link connect `connect`}; doing so advertises * the app's `elicitation` capability during `ui/initialize`. */ + private _autoElicitationCapability = false; private _onelicitation?: ( params: ElicitRequest["params"], extra: RequestHandlerExtra, @@ -1129,28 +1130,40 @@ export class App extends Protocol { ) => ElicitResult | Promise) | undefined, ) { - this.warnIfRequestHandlerReplaced( - "onelicitation", - this._onelicitation, - callback, - ); - this._onelicitation = callback; - if (callback && !this._appCapabilities.elicitation && !this.transport) { - this._appCapabilities.elicitation = {}; + if (!callback) { + if (this._onelicitation) this.removeRequestHandler("elicitation/create"); + this._onelicitation = undefined; + // Capabilities already advertised during initialization cannot be withdrawn. + if (this._autoElicitationCapability && !this.transport) { + const { elicitation: _elicitation, ...remaining } = + this._appCapabilities; + this._appCapabilities = remaining; + this._autoElicitationCapability = false; + } + return; + } + if (!this._appCapabilities.elicitation) { + this.registerAppCapabilities({ elicitation: {} }); + this._autoElicitationCapability = true; } this.replaceRequestHandler( "elicitation/create", { params: ElicitRequestParamsSchema, result: ElicitResultSchema }, (params, extra) => { - if (!this._onelicitation) { + if (!this._onelicitation) throw new Error("No onelicitation handler set"); - } if (params.mode !== undefined && params.mode !== "form") { throw new Error("MCP Apps only support form-mode elicitations"); } return this._onelicitation(params, extra); }, ); + this.warnIfRequestHandlerReplaced( + "onelicitation", + this._onelicitation, + callback, + ); + this._onelicitation = callback; } /** diff --git a/src/elicitation.test.ts b/src/elicitation.test.ts new file mode 100644 index 000000000..31dbb161b --- /dev/null +++ b/src/elicitation.test.ts @@ -0,0 +1,361 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import { + InMemoryTransport, + type ElicitRequestFormParams, + type ElicitResult, +} from "@modelcontextprotocol/client"; +import { ElicitResultSchema } from "@modelcontextprotocol/core"; +import { App } from "./app.js"; +import { + AppBridge, + getElicitationUiResourceUri, + getToolUiResourceUri, + supportsAppElicitation, + validateUiResourceUri, + type HostOptions, +} from "./app-bridge.js"; + +const form: ElicitRequestFormParams = { + message: "Choose a delivery window", + requestedSchema: { + type: "object", + properties: { window: { type: "string", enum: ["morning", "afternoon"] } }, + required: ["window"], + }, +}; +const clientCaps = { + elicitation: { form: {} }, + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: ["text/html;profile=mcp-app"], + elicitation: {}, + }, + }, +}; +const serverCaps = { + extensions: { "io.modelcontextprotocol/ui": { elicitation: {} } }, +}; +const channels: Array = []; +afterEach(async () => { + await Promise.all(channels.splice(0).map((c) => c.close())); +}); +async function connect( + app = new App({ name: "form", version: "1" }, {}, { autoResize: false }), + hostSupports = true, + options?: HostOptions, +) { + const bridge = new AppBridge( + null, + { name: "host", version: "1" }, + hostSupports ? { elicitation: {} } : {}, + options, + ); + channels.push(app, bridge); + const [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + return { app, bridge }; +} + +describe("elicitation capability negotiation", () => { + it("accepts the core empty-object form capability but not URL-only support", () => { + expect( + supportsAppElicitation({ ...clientCaps, elicitation: {} }, serverCaps), + ).toBe(true); + expect( + supportsAppElicitation( + { ...clientCaps, elicitation: { url: {} } }, + serverCaps, + ), + ).toBe(false); + expect( + supportsAppElicitation( + { ...clientCaps, elicitation: undefined }, + serverCaps, + ), + ).toBe(false); + }); + it.each(["mime", "opt-in", "missing-mime"])( + "requires client setting %s", + (mode) => { + const capabilities = structuredClone(clientCaps); + const ui: Record = + capabilities.extensions["io.modelcontextprotocol/ui"]; + if (mode === "mime") ui.mimeTypes = []; + if (mode === "opt-in") delete ui.elicitation; + if (mode === "missing-mime") delete ui.mimeTypes; + expect(supportsAppElicitation(capabilities, serverCaps)).toBe(false); + }, + ); +}); + +describe("UI URI validation", () => { + it.each([ + "ui:relative", + "ui:///x", + "UI://example/x", + "ui://example/x\n", + "ui://exam\tple/x", + " ui://example/x", + "https://example/x", + ])("rejects %j consistently for tools and elicitations", (uri) => { + expect(() => validateUiResourceUri(uri)).toThrow(); + expect(() => + getToolUiResourceUri({ _meta: { ui: { resourceUri: uri } } }), + ).toThrow(); + expect(() => + getElicitationUiResourceUri({ + ...form, + _meta: { ui: { resourceUri: uri } }, + }), + ).toThrow(); + }); + it("does not route URL mode even when it carries UI metadata", () => { + expect( + getElicitationUiResourceUri({ + mode: "url", + message: "Open", + url: "https://example.com", + elicitationId: "one", + _meta: { ui: { resourceUri: "ui://example/x" } }, + }), + ).toBeUndefined(); + }); + it.each([ + { ui: null }, + { ui: [] }, + { ui: "bad" }, + { ui: { resourceUri: 3 } }, + ])("rejects malformed metadata %j so hosts can fall back", ({ ui }) => { + expect(() => + getElicitationUiResourceUri({ ...form, _meta: { ui } }), + ).toThrow(); + }); +}); + +describe("elicitation handlers", () => { + it("can clear an unset handler and does not mutate shared constructor capabilities", async () => { + const capabilities = {}; + const app = new App({ name: "one", version: "1" }, capabilities, { + autoResize: false, + }); + expect(() => { + app.onelicitation = undefined; + }).not.toThrow(); + app.onelicitation = async () => ({ action: "cancel" }); + expect(capabilities).toEqual({}); + app.onelicitation = undefined; + const { bridge } = await connect(app); + expect(bridge.getAppCapabilities()?.elicitation).toBeUndefined(); + }); + it("preserves explicitly declared capabilities when clearing a handler", async () => { + const app = new App( + { name: "one", version: "1" }, + { elicitation: {} }, + { autoResize: false }, + ); + app.onelicitation = async () => ({ action: "cancel" }); + app.onelicitation = undefined; + const { bridge } = await connect(app); + expect(bridge.getAppCapabilities()?.elicitation).toEqual({}); + await expect(bridge.requestElicitation(form)).rejects.toThrow(); + }); + it("rejects late capability registration without leaving a handler behind", async () => { + const { app } = await connect(); + expect(() => { + app.onelicitation = async () => ({ action: "cancel" }); + }).toThrow("Cannot register capabilities"); + expect(app.onelicitation).toBeUndefined(); + }); + it("allows late handlers for a declared capability, replacement, and clearing", async () => { + const { app, bridge } = await connect( + new App( + { name: "one", version: "1" }, + { elicitation: {} }, + { autoResize: false }, + ), + ); + app.onelicitation = async () => ({ action: "decline" }); + await expect(bridge.requestElicitation(form)).resolves.toEqual({ + action: "decline", + }); + app.onelicitation = async () => ({ action: "cancel" }); + await expect(bridge.requestElicitation(form)).resolves.toEqual({ + action: "cancel", + }); + app.onelicitation = undefined; + expect(app.onelicitation).toBeUndefined(); + await expect(bridge.requestElicitation(form)).rejects.toThrow(); + }); + it("rejects URL mode at the App even when bypassing the bridge helper", async () => { + const app = new App( + { name: "one", version: "1" }, + {}, + { autoResize: false }, + ); + app.onelicitation = async () => ({ action: "cancel" }); + const { bridge } = await connect(app); + await expect( + bridge.request( + { + method: "elicitation/create", + params: { + mode: "url", + message: "Open", + url: "https://example.com", + elicitationId: "one", + }, + }, + ElicitResultSchema, + ), + ).rejects.toThrow("form-mode"); + }); +}); + +describe("bridge validation and errors", () => { + it("uses Promise rejection for missing initialization and host support", async () => { + const bridge = new AppBridge(null, { name: "host", version: "1" }, {}); + await expect(bridge.requestElicitation(form)).rejects.toThrow( + "ui/initialize", + ); + const { bridge: initialized } = await connect(undefined, false); + await expect(initialized.requestElicitation(form)).rejects.toThrow( + "Host does not support", + ); + }); + it.each([{}, { window: "night" }, { window: 5 }])( + "rejects schema-invalid accepted content %j", + async (content) => { + const app = new App( + { name: "one", version: "1" }, + {}, + { autoResize: false }, + ); + app.onelicitation = async () => ({ + action: "accept", + content: content as NonNullable, + }); + const { bridge } = await connect(app); + await expect(bridge.requestElicitation(form)).rejects.toThrow( + "requestedSchema", + ); + }, + ); + it.each(["decline", "cancel"] as const)( + "drops content from %s while preserving its action", + async (action) => { + const app = new App( + { name: "one", version: "1" }, + {}, + { autoResize: false }, + ); + app.onelicitation = async () => ({ + action, + content: { unwanted: "payload" }, + }); + const { bridge } = await connect(app); + await expect(bridge.requestElicitation(form)).resolves.toEqual({ + action, + }); + }, + ); + it("uses a supplied JSON Schema provider instead of bypassing validation", async () => { + let compilations = 0; + const { bridge } = await connect( + new App( + { name: "one", version: "1" }, + { elicitation: {} }, + { autoResize: false }, + ), + true, + { + jsonSchemaValidator: { + getValidator() { + compilations++; + return () => ({ + valid: false, + data: undefined, + errorMessage: "custom schema policy", + }); + }, + }, + }, + ); + const request = spyOn(bridge, "request").mockResolvedValue({ + action: "accept", + content: { window: "morning" }, + }); + await expect(bridge.requestElicitation(form)).rejects.toThrow( + "custom schema policy", + ); + expect(compilations).toBe(1); + request.mockRestore(); + }); + it("uses a configurable human-input timeout", async () => { + const { bridge } = await connect( + new App( + { name: "one", version: "1" }, + { elicitation: {} }, + { autoResize: false }, + ), + ); + const request = spyOn(bridge, "request").mockResolvedValue({ + action: "accept", + content: { window: "morning" }, + }); + await bridge.requestElicitation(form); + expect(request.mock.calls[0][2]).toMatchObject({ timeout: 600000 }); + const controller = new AbortController(); + await bridge.requestElicitation(form, { + timeout: 2000, + signal: controller.signal, + }); + expect(request.mock.calls[1][2]).toMatchObject({ + timeout: 2000, + signal: controller.signal, + }); + request.mockRestore(); + }); + it("keeps concurrent answers isolated between App instances using the same resource URI", async () => { + const releases: Array<() => void> = []; + const started: Array> = []; + const results = await Promise.all( + ["morning", "afternoon"].map(async (window) => { + const app = new App( + { name: window, version: "1" }, + {}, + { autoResize: false }, + ); + let begin!: () => void; + started.push( + new Promise((resolve) => { + begin = resolve; + }), + ); + app.onelicitation = async (params) => { + expect(params._meta?.ui).toEqual({ + resourceUri: "ui://delivery/choose", + }); + begin(); + await new Promise((resolve) => { + releases.push(resolve); + }); + return { action: "accept", content: { window } }; + }; + return connect(app); + }), + ); + const pending = results.map(({ bridge }) => + bridge.requestElicitation({ + ...form, + _meta: { ui: { resourceUri: "ui://delivery/choose" } }, + }), + ); + await Promise.all(started); + releases.reverse().forEach((release) => release()); + expect(await Promise.all(pending)).toEqual([ + { action: "accept", content: { window: "morning" } }, + { action: "accept", content: { window: "afternoon" } }, + ]); + }); +}); diff --git a/src/elicitation.ts b/src/elicitation.ts new file mode 100644 index 000000000..6c8d2443d --- /dev/null +++ b/src/elicitation.ts @@ -0,0 +1,201 @@ +import type { + ClientCapabilities, + ServerCapabilities, + ElicitRequest, + ElicitRequestFormParams, +} from "@modelcontextprotocol/client"; +import type { + McpUiClientCapabilities, + McpUiServerCapabilities, + McpUiElicitationMeta, +} from "./spec.types.js"; +import { RESOURCE_MIME_TYPE } from "./constants.js"; + +/** + * Extension identifier for MCP Apps capability negotiation. + * + * Used as the key in `extensions` to advertise MCP Apps support. + */ +export const EXTENSION_ID = "io.modelcontextprotocol/ui"; + +/** + * Get MCP Apps capability settings from client capabilities. + * + * This helper retrieves the capability object from the `extensions` field + * where MCP Apps advertises its support. + * + * @param clientCapabilities - Effective request-scoped capabilities or legacy initialized client capabilities + * @returns The MCP Apps capability settings, or `undefined` if not supported + * + * @example Check for MCP Apps support in server initialization + * ```ts source="./server/index.examples.ts#getUiCapability_checkSupport" + * server.server.oninitialized = () => { + * const clientCapabilities = server.server.getClientCapabilities(); + * const uiCap = getUiCapability(clientCapabilities); + * + * if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) { + * // App-enhanced tool + * registerAppTool( + * server, + * "weather", + * { + * description: "Get weather information with interactive dashboard", + * _meta: { ui: { resourceUri: "ui://weather/dashboard" } }, + * }, + * weatherHandler, + * ); + * } else { + * // Text-only fallback + * server.registerTool( + * "weather", + * { + * description: "Get weather information", + * }, + * textWeatherHandler, + * ); + * } + * }; + * ``` + */ +export function getUiCapability( + clientCapabilities: ClientCapabilities | null | undefined, +): McpUiClientCapabilities | undefined { + if (!clientCapabilities) { + return undefined; + } + + return clientCapabilities.extensions?.[EXTENSION_ID] as + | 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 && + (clientCapabilities.elicitation.form !== undefined || + Object.keys(clientCapabilities.elicitation).length === 0) && + clientUi?.mimeTypes?.includes(RESOURCE_MIME_TYPE) && + clientUi.elicitation && + serverUi?.elicitation, + ); +} + +const UI_RESOURCE_URI_ERROR = + "UI resourceUri must be an absolute ui:// URI in canonical form"; + +/** + * Validate a canonical, absolute UI resource URI without rewriting its identity. + * @throws {Error} If the URI is malformed, has no authority, or URL parsing changes it. + */ +export function validateUiResourceUri(resourceUri: string): void { + let parsed: URL; + try { + parsed = new URL(resourceUri); + } catch { + throw new Error(UI_RESOURCE_URI_ERROR); + } + + if ( + parsed.protocol !== "ui:" || + !resourceUri.startsWith("ui://") || + parsed.host.length === 0 || + parsed.href !== resourceUri + ) { + throw new Error(UI_RESOURCE_URI_ERROR); + } +} + +/** + * 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.mode !== undefined && params.mode !== "form") { + throw new Error("MCP Apps only support form-mode elicitations"); + } + + validateUiResourceUri(resourceUri); + + 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 form elicitation metadata. + * Returns `undefined` for URL mode or absent UI metadata. + * @throws {Error} If UI metadata or its URI is present but malformed. Hosts must + * catch this error and use native form rendering while the elicitation is pending. + */ +export function getElicitationUiResourceUri( + params: ElicitRequest["params"], +): string | undefined { + if (params.mode !== undefined && params.mode !== "form") return undefined; + const ui = params._meta?.ui; + if (ui === undefined) return undefined; + if (typeof ui !== "object" || ui === null || Array.isArray(ui)) { + throw new Error("Elicitation UI metadata must be an object"); + } + 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"); + } + + validateUiResourceUri(resourceUri); + return resourceUri; +} diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 228f15919..5233aa74b 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -512,7 +512,7 @@ describe("app-rendered elicitation negotiation", () => { }, serverCapabilities, ), - ).toBe(false); + ).toBe(true); }); it("reads the server setting from the existing MCP Apps extension", () => { diff --git a/src/server/index.ts b/src/server/index.ts index 99a8c9f36..fd5836383 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -35,17 +35,10 @@ import { McpUiResourceCsp, McpUiResourceMeta, McpUiToolMeta, - McpUiClientCapabilities, - McpUiServerCapabilities, - McpUiElicitationMeta, } from "../spec.types.js"; import { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "../constants.js"; import type { CallToolResult, - ClientCapabilities, - ElicitRequest, - ElicitRequestFormParams, - ServerCapabilities, InputRequiredResult, McpServer, ReadResourceResult, @@ -458,179 +451,12 @@ export function registerAppResource( ); } -/** - * Extension identifier for MCP Apps capability negotiation. - * - * Used as the key in `extensions` to advertise MCP Apps support. - */ -export const EXTENSION_ID = "io.modelcontextprotocol/ui"; - -/** - * Get MCP Apps capability settings from client capabilities. - * - * This helper retrieves the capability object from the `extensions` field - * where MCP Apps advertises its support. - * - * @param clientCapabilities - The client capabilities from the initialize response - * @returns The MCP Apps capability settings, or `undefined` if not supported - * - * @example Check for MCP Apps support in server initialization - * ```ts source="./index.examples.ts#getUiCapability_checkSupport" - * server.server.oninitialized = () => { - * const clientCapabilities = server.server.getClientCapabilities(); - * const uiCap = getUiCapability(clientCapabilities); - * - * if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) { - * // App-enhanced tool - * registerAppTool( - * server, - * "weather", - * { - * description: "Get weather information with interactive dashboard", - * _meta: { ui: { resourceUri: "ui://weather/dashboard" } }, - * }, - * weatherHandler, - * ); - * } else { - * // Text-only fallback - * server.registerTool( - * "weather", - * { - * description: "Get weather information", - * }, - * textWeatherHandler, - * ); - * } - * }; - * ``` - */ -export function getUiCapability( - clientCapabilities: ClientCapabilities | null | undefined, -): McpUiClientCapabilities | undefined { - if (!clientCapabilities) { - return undefined; - } - - return clientCapabilities.extensions?.[EXTENSION_ID] as - | 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, - ); -} - -const ELICITATION_UI_URI_ERROR = - "Elicitation UI resourceUri must be an absolute ui:// URI"; - -function validateElicitationUiResourceUri(resourceUri: string): void { - let parsed: URL; - try { - parsed = new URL(resourceUri); - } catch { - throw new Error(ELICITATION_UI_URI_ERROR); - } - - if ( - parsed.protocol !== "ui:" || - !/^ui:\/\//i.test(resourceUri) || - parsed.host.length === 0 - ) { - throw new Error(ELICITATION_UI_URI_ERROR); - } -} - -/** - * 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"); - } - - validateElicitationUiResourceUri(resourceUri); - - 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"); - } - - validateElicitationUiResourceUri(resourceUri); - return resourceUri; -} +export { + EXTENSION_ID, + getUiCapability, + getUiServerCapability, + supportsAppElicitation, + withElicitationUi, + getElicitationUiResourceUri, + validateUiResourceUri, +} from "../elicitation.js"; diff --git a/tests/e2e/elicitation.spec.ts b/tests/e2e/elicitation.spec.ts new file mode 100644 index 000000000..e1390b10f --- /dev/null +++ b/tests/e2e/elicitation.spec.ts @@ -0,0 +1,92 @@ +import { expect, test, type Page } from "@playwright/test"; + +async function request(page: Page, renderer = "app") { + await page.goto("/?theme=hide"); + await expect(page.locator("select").first()).toBeEnabled({ timeout: 30000 }); + await page + .locator("select") + .first() + .selectOption({ label: "Integration Test Server" }); + await page + .getByRole("combobox", { name: "Tool", exact: true }) + .selectOption("schedule-delivery"); + await page + .getByRole("textbox", { name: "Input", exact: true }) + .fill(JSON.stringify({ renderer })); + await page.getByRole("button", { name: "Call Tool", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + return dialog; +} +async function result(page: Page, action: string, window?: string) { + await expect(page.getByRole("dialog")).toHaveCount(0); + await page.getByText("📤 Tool Result", { exact: true }).click(); + const response = JSON.parse(await page.locator("pre").last().innerText()); + expect(JSON.parse(response.content[0].text)).toEqual( + window ? { action, content: { window } } : { action }, + ); + await expect(page.locator("iframe")).toHaveCount(0); +} + +test.describe("app-rendered form elicitation", () => { + test("returns the user's answer through MRTR without tool-input notifications", async ({ + page, + }) => { + const dialog = await request(page); + const app = dialog.frameLocator("iframe").frameLocator("iframe"); + await app.getByLabel("Delivery window").selectOption("afternoon"); + await app.getByRole("button", { name: "Submit", exact: true }).click(); + await result(page, "accept", "afternoon"); + }); + test("preserves decline and drops unused content without fallback", async ({ + page, + }) => { + const dialog = await request(page); + await dialog + .frameLocator("iframe") + .frameLocator("iframe") + .getByRole("button", { name: "Decline", exact: true }) + .click(); + await result(page, "decline"); + }); + test("dismissal returns cancel and removes the View without native fallback", async ({ + page, + }) => { + const dialog = await request(page); + await expect( + dialog + .frameLocator("iframe") + .frameLocator("iframe") + .getByRole("button", { name: "Submit", exact: true }), + ).toBeVisible(); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + await result(page, "cancel"); + }); + test("missing resources fall back to a complete native form", async ({ + page, + }) => { + const dialog = await request(page, "missing"); + await expect(dialog.locator("iframe")).toHaveCount(0); + await dialog + .getByRole("combobox", { name: "window", exact: true }) + .selectOption("afternoon"); + await dialog.getByRole("button", { name: "Submit", exact: true }).click(); + await result(page, "accept", "afternoon"); + }); + test("schema-invalid App answers fall back instead of reaching the server", async ({ + page, + }) => { + const dialog = await request(page); + await dialog + .frameLocator("iframe") + .frameLocator("iframe") + .getByRole("button", { name: "Send invalid answer" }) + .click(); + await expect(dialog.locator("iframe")).toHaveCount(0); + await dialog + .getByRole("combobox", { name: "window", exact: true }) + .selectOption("morning"); + await dialog.getByRole("button", { name: "Submit", exact: true }).click(); + await result(page, "accept", "morning"); + }); +});