feat(apps): negotiated app-rendered form elicitations

Let a server hand a form `elicitation/create` to an MCP App and return the
app's ordinary `ElicitResult`, with the native elicitation form as the
fallback on every failure.

No second extension, no custom method, and no custom result shape: the only
new wire surface is a nested `elicitation` flag on the existing
`io.modelcontextprotocol/ui` extension on each side, plus
`_meta.ui.resourceUri` on the request. App rendering is selected only when
all four gates hold — client `elicitation.form`, client MCP Apps MIME type,
the nested `elicitation` setting on BOTH peers, and a valid absolute `ui://`
URI on the request.

Only the web client advertises the nested client-side setting, and only
because it has a sandbox renderer: supplying
`InspectorClientOptions.appElicitation` is what opts a client in, so CLI and
TUI keep advertising the MIME type without ever claiming they can resolve an
elicitation through an app.

Routing lives in the one funnel both entry points already use
(`enqueuePendingElicitation`), so the inbound handler and the MRTR driver
cannot diverge. Ownership is request-scoped — a per-request id keys the
renderer, iframe and bridge — so two concurrent elicitations can never
resolve through each other's bridges. An explicit `decline`/`cancel` is a
completed elicitation and goes back to the server; everything else (absent or
malformed metadata, resource/sandbox/bridge failure, an app with no
elicitation capability, a timeout, an invalid result or one that fails the
requested schema) falls back.

The Inspector speaks the ext-apps#733 / SEP-3118 wire protocol but cannot yet
consume its helpers — the released `@modelcontextprotocol/ext-apps` (1.7.5)
predates that PR. `core/mcp/appElicitation.ts` and
`AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for
deletion once a release containing it ships. One consequence needed its own
seam: ext-apps 1.7.5 parses the view's `ui/initialize` through a schema that
strips `elicitation`, so an app that correctly advertises it would look like
one that did not, silently turning every negotiated elicitation into a
fallback. `AppRenderer/appCapabilities.ts` records the raw frame instead, and
prefers the bridge's own value once it carries the key.

`AppRenderer` now takes an `AppRenderSource` union rather than a `Tool`, so
the same renderer and bridge factory serve an App tool and an elicitation
without either faking the other's shape.

Adds the public fixture (`app_choose_option` + a self-contained
`ui://demo/choose-option.html` app covering accept/decline/cancel), its two
showcase configs, and `smoke:web:elicit`, which drives the negotiated path
end to end in headless Chromium and then the same tool against a server that
never advertised the capability, asserting the native form takes it.

Closes #1854

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 19:05:21 -04:00
commit 08fc20a22d
4 changed files with 3718 additions and 93 deletions

238
core/mcp/appElicitation.ts Normal file
View file

@ -0,0 +1,238 @@
import type {
ClientCapabilities,
ElicitRequest,
ElicitResult,
JsonSchemaType,
jsonSchemaValidator,
ServerCapabilities,
} from "@modelcontextprotocol/client";
import { MCP_APP_MIME_TYPE, UI_EXTENSION_KEY } from "./extensions.js";
/**
* App-rendered form elicitations (#1854).
*
* A server may attach an MCP App resource to a standard `elicitation/create`
* request; a host that can run MCP Apps renders that app and returns the app's
* ordinary `ElicitResult` to the server. No second extension, no custom method,
* and no custom result shape are introduced — the only new wire surface is a
* nested `elicitation` flag on the existing `io.modelcontextprotocol/ui`
* extension on each side, plus `_meta.ui.resourceUri` on the request.
*
* The helpers here mirror the ext-apps draft (modelcontextprotocol/ext-apps#733,
* SEP-3118) so the Inspector speaks exactly the proposed protocol. They are
* declared locally only because the released `@modelcontextprotocol/ext-apps`
* (1.7.5) predates that PR and exports none of them; replace
* {@link supportsAppElicitation} / {@link getElicitationUiResourceUri} with the
* package's `/server` exports once a release containing #733 ships.
*/
/**
* MCP Apps extension settings advertised by a client, as far as app-rendered
* elicitation cares. Mirrors ext-apps' `McpUiClientCapabilities`.
*/
export interface UiClientCapabilities {
mimeTypes?: string[];
elicitation?: object;
}
/**
* MCP Apps extension settings advertised by a server. Mirrors ext-apps'
* `McpUiServerCapabilities` (introduced by #733): a server sets `elicitation`
* to declare it may attach an App resource to a form elicitation.
*/
export interface UiServerCapabilities {
elicitation?: object;
}
/** Reads the `io.modelcontextprotocol/ui` block out of either side's capabilities. */
function uiExtension(
capabilities: { extensions?: Record<string, unknown> } | null | undefined,
): Record<string, unknown> | undefined {
const ext = capabilities?.extensions?.[UI_EXTENSION_KEY];
return typeof ext === "object" && ext !== null
? (ext as Record<string, unknown>)
: undefined;
}
/** The MCP Apps settings a client advertised, or `undefined`. */
export function getUiClientCapability(
capabilities: ClientCapabilities | null | undefined,
): UiClientCapabilities | undefined {
return uiExtension(capabilities) as UiClientCapabilities | undefined;
}
/** The MCP Apps settings a server advertised, or `undefined`. */
export function getUiServerCapability(
capabilities: ServerCapabilities | null | undefined,
): UiServerCapabilities | undefined {
return uiExtension(capabilities) as UiServerCapabilities | undefined;
}
/**
* Whether both peers negotiated app-rendered form elicitation. All of the
* protocol's conditions except the per-request `_meta` are checked here:
*
* 1. the client advertised core form elicitation (`elicitation.form`);
* 2. the client advertised the MCP Apps MIME type;
* 3. the client advertised the nested MCP Apps `elicitation` setting;
* 4. the server advertised the nested MCP Apps `elicitation` setting.
*
* A MIME-type match alone is deliberately not sufficient — a client that can
* render App *tools* cannot necessarily resolve an elicitation through a bridge.
*/
export function supportsAppElicitation(
clientCapabilities: ClientCapabilities | null | undefined,
serverCapabilities: ServerCapabilities | null | undefined,
): boolean {
const clientUi = getUiClientCapability(clientCapabilities);
const serverUi = getUiServerCapability(serverCapabilities);
return Boolean(
clientCapabilities?.elicitation?.form &&
clientUi?.mimeTypes?.includes(MCP_APP_MIME_TYPE) &&
clientUi.elicitation &&
serverUi?.elicitation,
);
}
const ELICITATION_UI_URI_ERROR =
"Elicitation UI resourceUri must be an absolute ui:// URI";
/**
* Rejects anything that is not an absolute `ui://host/...` URI. Matches the
* ext-apps#733 validator: a bare scheme, a relative reference, or a non-`ui`
* scheme are all unusable and must not reach the renderer.
*/
function validateElicitationUiResourceUri(resourceUri: string): void {
let parsed: URL;
try {
parsed = new URL(resourceUri);
} catch {
throw new Error(ELICITATION_UI_URI_ERROR);
}
if (
parsed.protocol !== "ui:" ||
!/^ui:\/\//i.test(resourceUri) ||
parsed.host.length === 0
) {
throw new Error(ELICITATION_UI_URI_ERROR);
}
}
/**
* Reads `_meta.ui.resourceUri` off an `elicitation/create` request.
*
* Returns `undefined` when the server attached no App (the ordinary case — the
* native elicitation UI handles it). Throws when the metadata is present but
* unusable, so the caller can log the server bug and still fall back rather
* than rendering something arbitrary.
*/
export function getElicitationUiResourceUri(
params: ElicitRequest["params"],
): string | undefined {
const ui = (params._meta as { ui?: unknown } | undefined)?.ui;
if (typeof ui !== "object" || ui === null) return undefined;
const resourceUri = (ui as Record<string, unknown>).resourceUri;
if (resourceUri === undefined) return undefined;
if (typeof resourceUri !== "string") {
throw new Error("Elicitation UI resourceUri must be a string");
}
validateElicitationUiResourceUri(resourceUri);
return resourceUri;
}
/**
* Only `form` mode is app-renderable; an omitted mode IS form (the mode field
* post-dates form elicitation). `url` mode keeps its existing path.
*/
export function isFormElicitation(params: ElicitRequest["params"]): boolean {
const mode = (params as { mode?: unknown }).mode;
return mode === undefined || mode === "form";
}
/** The three actions a completed elicitation may report. */
const ELICIT_ACTIONS = ["accept", "decline", "cancel"] as const;
/**
* Validates what an app returned before it is handed back to the server.
*
* Returns a human-readable reason when the value is not a usable
* `ElicitResult` — an unknown action, an `accept` with no content object, or
* content that fails the request's own `requestedSchema` — and `undefined` when
* it is fine. The value is untrusted (it came from sandboxed app code), so the
* runtime checks stand regardless of the declared type.
*
* A failure here is a fallback trigger, not a protocol error: the host drops to
* the native elicitation UI rather than sending the server something that does
* not match what it asked for.
*/
export function validateAppElicitResult(
provider: jsonSchemaValidator,
params: ElicitRequest["params"],
result: ElicitResult,
): string | undefined {
if (typeof result !== "object" || result === null) {
return "App returned a non-object elicitation result";
}
const action = (result as { action?: unknown }).action;
if (!ELICIT_ACTIONS.includes(action as (typeof ELICIT_ACTIONS)[number])) {
return `App returned an unknown elicitation action: ${JSON.stringify(action)}`;
}
// decline / cancel carry no content — they are complete as they stand.
if (action !== "accept") return undefined;
const content = (result as { content?: unknown }).content;
if (
typeof content !== "object" ||
content === null ||
Array.isArray(content)
) {
return "App accepted the elicitation without a content object";
}
const schema = (params as { requestedSchema?: unknown }).requestedSchema;
if (typeof schema !== "object" || schema === null) return undefined;
try {
// `requestedSchema` is the SDK's own object-schema shape and `JsonSchemaType`
// the validator provider's third-party JSON Schema interface — structurally
// compatible, nominally unrelated, exactly as in `validateToolOutput`.
const validate = provider.getValidator(schema as JsonSchemaType);
const validation = validate(content);
return validation.valid
? undefined
: `App content does not match the requested schema: ${validation.errorMessage}`;
} catch {
// A schema the validator cannot compile is the server's problem, not the
// app's; don't reject a result over it (mirrors `validateToolOutput`).
return undefined;
}
}
/**
* One app-rendered elicitation, scoped to the originating request.
*
* `requestId` is what makes the association request-scoped: the host keys its
* renderer/bridge by it, so two concurrent elicitations for different resource
* URIs can never resolve through each other's bridges.
*/
export interface AppElicitationRequest {
/** Unique per originating `elicitation/create` request. */
requestId: string;
/** The validated absolute `ui://` URI the app is loaded from. */
resourceUri: string;
/** The original request params, forwarded through the bridge unchanged. */
params: ElicitRequest["params"];
/** Aborts when the originating request is cancelled or the client disconnects. */
signal: AbortSignal;
}
/**
* Host-supplied renderer for {@link AppElicitationRequest}s. Only a client that
* can actually host MCP Apps (today: the web client, when its sandbox renderer
* is available) provides one — providing it is what opts the client into
* advertising the nested MCP Apps `elicitation` capability.
*
* Resolves with the app's standard `ElicitResult`. Rejecting is a request to
* fall back to the native elicitation UI; it must not be used to signal a user
* decision, since `decline` and `cancel` are themselves completed elicitations.
*/
export type AppElicitationRenderer = (
request: AppElicitationRequest,
) => Promise<ElicitResult>;

View file

@ -98,6 +98,19 @@ export interface BuildClientExtensionsInput {
* over the registry's `defaultAdvertised`; an absent key falls back to it.
*/
advertised?: Record<string, boolean>;
/**
* True when this client can render an MCP App and resolve an
* `elicitation/create` request through its bridge (#1854). Adds the nested
* `elicitation` setting to the UI extension's advertisement, which is half of
* the negotiation a server checks before attaching an App to an elicitation.
*
* Deliberately an input rather than a registry default: the shared
* `InspectorClient` knowing the MCP Apps MIME type says nothing about whether
* the *client* has a sandbox renderer, so CLI and TUI must never advertise it.
* Ignored when the UI extension itself is not advertised — a nested setting on
* an extension we did not declare would be meaningless.
*/
appElicitation?: boolean;
}
/**
@ -126,6 +139,14 @@ export function buildClientExtensions(
: {};
}
}
// Nested app-rendered-elicitation opt-in (#1854), layered onto the UI
// extension's own advertisement rather than added as a second extension.
// Guarded on the UI entry actually being present so turning the Apps
// extension off in Server Settings also turns this off.
const uiAdvertisement = map[UI_EXTENSION_KEY];
if (input.appElicitation && uiAdvertisement) {
map[UI_EXTENSION_KEY] = { ...uiAdvertisement, elicitation: {} };
}
if (input.enterpriseManaged) {
map[EMA_EXTENSION_KEY] = {};
}

View file

@ -132,6 +132,13 @@ import {
type ModernDetailedTask,
} from "./modernTaskSchemas.js";
import { buildClientExtensions } from "./extensions.js";
import {
getElicitationUiResourceUri,
isFormElicitation,
supportsAppElicitation,
validateAppElicitResult,
type AppElicitationRenderer,
} from "./appElicitation.js";
import {
EmptyResultSchema,
CallToolResultSchema,
@ -562,6 +569,27 @@ export class InspectorClient extends InspectorClientEventTarget {
// Per-extension advertise overrides (#1738); undefined key falls back to the
// registry default in ADVERTISABLE_EXTENSIONS.
private readonly advertisedExtensions?: Record<string, boolean>;
/**
* Host-supplied renderer for app-rendered form elicitations (#1854), or
* undefined on a client that cannot host MCP Apps. Its presence is what
* advertises the nested MCP Apps `elicitation` capability, so this is the one
* fact both the advertisement and the routing gate read.
*/
private readonly appElicitationRenderer?: AppElicitationRenderer;
/**
* Monotonic counter behind the request-scoped id handed to the renderer. The
* association MUST be per request (a map keyed by this id owns the resource
* URI, renderer instance and promise on the host side) so two concurrent
* elicitations cannot resolve through each other's bridges.
*/
private appElicitationSeq = 0;
/**
* Abort controllers for app-rendered elicitations still awaiting an answer.
* Aborted alongside the native pending queue on disconnect, so a rendered app
* cannot outlive the connection that asked for it.
*/
private activeAppElicitations = new Set<AbortController>();
private receiverTaskTtlMs: number | (() => number);
private receiverTaskRecords: Map<string, ReceiverTaskRecord> = new Map();
// OAuth support (config owned by oauthManager; client delegates and uses !!oauthManager for "is OAuth configured")
@ -616,6 +644,7 @@ export class InspectorClient extends InspectorClientEventTarget {
this.elicit = options.elicit ?? true;
this.receiverTasks = options.receiverTasks ?? false;
this.advertisedExtensions = options.advertisedExtensions;
this.appElicitationRenderer = options.appElicitation;
this.receiverTaskTtlMs = options.receiverTaskTtlMs ?? 60_000;
this.progress = options.progress ?? true;
this.resetTimeoutOnProgress = options.resetTimeoutOnProgress ?? true;
@ -785,6 +814,14 @@ export class InspectorClient extends InspectorClientEventTarget {
const advertisedExtensions = buildClientExtensions({
enterpriseManaged: options.oauth?.enterpriseManaged ?? false,
advertised: this.advertisedExtensions,
// Read off the built `capabilities.elicitation.form` rather than
// re-deriving from `options.elicit`: the nested MCP Apps `elicitation`
// setting must never be advertised without the core form capability it
// extends, and two derivations of the same fact can drift. Disabling form
// elicitation therefore drops both, as the contract requires. (#1854)
appElicitation:
this.appElicitationRenderer !== undefined &&
capabilities.elicitation?.form !== undefined,
});
if (Object.keys(advertisedExtensions).length > 0) {
capabilities.extensions = {
@ -1688,6 +1725,13 @@ export class InspectorClient extends InspectorClientEventTarget {
elicitation.cancel();
}
this.pendingElicitations = [];
// App-rendered elicitations (#1854) are not in the queue above — they live
// in the host's renderer — so abort them here on the same teardown paths.
// `tryAppElicitation` removes each controller in its own `finally`.
for (const controller of this.activeAppElicitations) {
controller.abort();
}
this.activeAppElicitations.clear();
}
/**
@ -2820,11 +2864,17 @@ export class InspectorClient extends InspectorClientEventTarget {
* corresponding `ElicitResult` (echoed to the server on retry); only a
* genuine failure or a `signal` abort rejects.
*/
private enqueuePendingElicitation(
private async enqueuePendingElicitation(
request: ElicitRequest,
origin: PendingRequestOrigin,
signal?: AbortSignal,
): Promise<ElicitResult> {
// App-rendered form elicitation (#1854) is offered first and falls back to
// the native queue below on every failure. Both entry points — the inbound
// `elicitation/create` handler and the MRTR driver's embedded requests —
// funnel through here, so neither can miss the routing.
const appResult = await this.tryAppElicitation(request, signal);
if (appResult) return appResult;
// See {@link enqueuePendingSample} — Promise settle is idempotent.
return new Promise<ElicitResult>((resolvePromise, rejectPromise) => {
const elicitation = new ElicitationCreateMessage(
@ -2842,6 +2892,92 @@ export class InspectorClient extends InspectorClientEventTarget {
});
}
/**
* Attempt to resolve an `elicitation/create` request by rendering the MCP App
* the server attached to it (#1854), returning the app's standard
* `ElicitResult`.
*
* Returns `null` for "not app-rendered — use the native UI", which covers
* every negotiation gate and every failure mode the contract lists: either
* peer did not negotiate the capability, the metadata is absent or unusable,
* the mode is not `form`, the renderer failed (resource read, sandbox/bridge
* init, missing app capability, timeout), or the app returned something that
* is not a valid result for this request. An explicit `decline` or `cancel`
* is a *completed* elicitation and is returned, not fallen back on.
*
* The one case that does not fall back is an abort of the originating
* request: the caller is cancelling the whole elicitation, so re-opening it
* in the native queue would resurrect work the user just abandoned.
*/
private async tryAppElicitation(
request: ElicitRequest,
signal?: AbortSignal,
): Promise<ElicitResult | null> {
const renderer = this.appElicitationRenderer;
if (!renderer) return null;
// Gate 1-3 (client) + 4 (server), from the negotiated capabilities of this
// connection — `this.capabilities` is populated at initialize.
if (!supportsAppElicitation(this.clientCapabilities, this.capabilities)) {
return null;
}
// Only form mode is app-renderable; `url` keeps its existing path.
if (!isFormElicitation(request.params)) return null;
let resourceUri: string | undefined;
try {
resourceUri = getElicitationUiResourceUri(request.params);
} catch (error) {
this.logger.warn(
{ error },
"Elicitation carried unusable _meta.ui.resourceUri; using the native elicitation UI",
);
return null;
}
if (!resourceUri) return null;
// Request-scoped abort: forwards the caller's signal (MRTR cancellation)
// and is aborted by `settleAndDropPendingPeerRequests` on disconnect, so a
// rendered app cannot outlive the connection that asked for it.
const controller = new AbortController();
const forwardAbort = () => controller.abort(signal?.reason);
if (signal) {
if (signal.aborted) forwardAbort();
else signal.addEventListener("abort", forwardAbort, { once: true });
}
this.activeAppElicitations.add(controller);
try {
const result = await renderer({
requestId: `app-elicitation-${++this.appElicitationSeq}`,
resourceUri,
params: request.params,
signal: controller.signal,
});
this.outputValidator ??= new AjvJsonSchemaValidator();
const invalid = validateAppElicitResult(
this.outputValidator,
request.params,
result,
);
if (invalid) {
this.logger.warn(
{ resourceUri, reason: invalid },
"App-rendered elicitation returned an invalid result; using the native elicitation UI",
);
return null;
}
return result;
} catch (error) {
if (controller.signal.aborted) throw createPendingAbortError();
this.logger.warn(
{ error, resourceUri },
"App-rendered elicitation failed; using the native elicitation UI",
);
return null;
} finally {
this.activeAppElicitations.delete(controller);
signal?.removeEventListener("abort", forwardAbort);
}
}
/**
* Reject a still-pending request when `signal` aborts (e.g. the user cancels
* the tool call while its MRTR round is awaiting an answer). No-op when

View file

@ -23,6 +23,7 @@ import type { Client } from "@modelcontextprotocol/client";
import type { OAuthClientProvider } from "@modelcontextprotocol/client";
import type { Transport } from "@modelcontextprotocol/client";
import type { InspectorLogger } from "../logging/logger.js";
import type { AppElicitationRenderer } from "./appElicitation.js";
import type { JsonValue } from "../json/jsonUtils.js";
import type {
ClientConfig,
@ -1025,6 +1026,21 @@ export interface InspectorClientOptions {
*/
advertisedExtensions?: Record<string, boolean>;
/**
* Renders an app-rendered form elicitation (#1854) and resolves with the
* app's standard `ElicitResult`.
*
* Supplying this is what opts a client into advertising the nested MCP Apps
* `elicitation` capability — so only a client that can actually host an MCP
* App and drive its bridge should pass one (today: the web client, when the
* sandbox renderer is available). CLI and TUI pass nothing and therefore
* never claim the capability, even though they share this client.
*
* A rejection means "fall back to the native elicitation UI"; a resolved
* `decline`/`cancel` is a completed elicitation and is returned to the server.
*/
appElicitation?: AppElicitationRenderer;
/**
* Whether to enable listChanged notification handlers (default: true)
* If enabled, InspectorClient will subscribe to list_changed notifications and fire