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/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-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/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/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 d9061345f..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", @@ -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/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/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/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 f2523f25e..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,73 +2188,111 @@ 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 -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": "2024-11-05", - "capabilities": { - "extensions": { - "io.modelcontextprotocol/ui": { - "mimeTypes": ["text/html;profile=mcp-app"] + "name": "schedule_delivery", + "arguments": {}, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "example-client", + "version": "1.0.0" + }, + "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"]`) +- `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 `server/discover` result: + +```json +{ + "jsonrpc": "2.0", + "id": "discover-1", + "result": { + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": { + "extensions": { + "io.modelcontextprotocol/ui": { + "elicitation": {} + } + } + } + } +} +``` + +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: + +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. + #### 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 - }); +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. + **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 (Guest UI) Capabilities @@ -2305,12 +2353,16 @@ These capabilities are independent - an app can have one, both, or neither. ```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; }; @@ -2319,6 +2371,230 @@ interface McpUiHostCapabilities { } ``` +### 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. + +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: + +```json +{ + "jsonrpc": "2.0", + "id": 12, + "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" + } + } + } + } + }, + "requestState": "schedule-delivery:v1" + } +} +``` + +`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. + +#### 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": "2026-01-26", + "appInfo": { + "name": "delivery-window", + "version": "1.0.0" + }, + "appCapabilities": { + "elicitation": {} + } + } +} +``` + +```json +{ + "result": { + "protocolVersion": "2026-01-26", + "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. + +#### 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. + +#### 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. + +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: + +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, 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. + +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": "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": { + "window": "morning" + } + } +} +``` + +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. + +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 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. + +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. + +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. + +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. + ### Extensibility This specification defines the Minimum Viable Product (MVP) for MCP Apps. @@ -2643,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 a87b0e7db..e02dd37e1 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -196,6 +196,144 @@ 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.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 request ID zero", 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", + }); + expect(ctx.mcpReq.id).toBe(0); + ctx.mcpReq.signal.addEventListener("abort", cancelled, { once: true }); + received(); + await aborted; + return { action: "cancel" }; + }; + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + 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); + await expect( + bridge.requestElicitation({ + mode: "url", + message: "Open", + elicitationId: "test", + url: "https://example.com", + }), + ).rejects.toThrow("form-mode"); + }); + + 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); + + await expect( + bridge.requestElicitation({ + message: "Choose an option", + requestedSchema: { type: "object", properties: {} }, + }), + ).rejects.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..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, @@ -9,6 +21,8 @@ import { type CreateMessageResult, type CreateMessageResultWithTools, type EmptyResult, + type ElicitRequest, + type ElicitResult, type Implementation, type JSONRPCRequest, type ListPromptsRequest, @@ -32,6 +46,7 @@ import { } from "@modelcontextprotocol/client"; import { EmptyResultSchema, + ElicitResultSchema, LoggingMessageNotificationSchema, } from "@modelcontextprotocol/core"; import { EventDispatcher, MethodRegistry } from "./events.js"; @@ -109,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 @@ -134,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)}`); @@ -210,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; }; /** @@ -307,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(); @@ -534,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"); @@ -579,8 +604,15 @@ 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 +656,58 @@ 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`. 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. + */ + async 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"); + } + // 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, 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; + } + /** * Get the view's implementation info discovered during initialization. * diff --git a/src/app.ts b/src/app.ts index 89c6c35e7..ca86e266b 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 { 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 { @@ -1100,6 +1106,66 @@ 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 _autoElicitationCapability = false; + private _onelicitation?: ( + params: ElicitRequest["params"], + extra: RequestHandlerExtra, + ) => ElicitResult | Promise; + get onelicitation() { + return this._onelicitation; + } + set onelicitation( + callback: + | (( + params: ElicitRequest["params"], + extra: RequestHandlerExtra, + ) => ElicitResult | Promise) + | undefined, + ) { + 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) + 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; + } + /** * Convenience handler for tool call requests from the host. * @@ -1252,6 +1318,13 @@ 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/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/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..5233aa74b 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,107 @@ 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(true); + }); + + 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 hierarchical ui:// resource URI", () => { + const enhanced = withElicitationUi(params, "ui://weather/view.html"); + expect(enhanced.requestedSchema).toEqual(params.requestedSchema); + expect(getElicitationUiResourceUri(enhanced)).toBe( + "ui://weather/view.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://", + ); + }); + + 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 5fad055a0..fd5836383 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -35,12 +35,10 @@ import { McpUiResourceCsp, McpUiResourceMeta, McpUiToolMeta, - McpUiClientCapabilities, } from "../spec.types.js"; import { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "../constants.js"; import type { CallToolResult, - ClientCapabilities, InputRequiredResult, McpServer, ReadResourceResult, @@ -453,60 +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; -} +export { + EXTENSION_ID, + getUiCapability, + getUiServerCapability, + supportsAppElicitation, + withElicitationUi, + getElicitationUiResourceUri, + validateUiResourceUri, +} from "../elicitation.js"; 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; 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"); + }); +});