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

View file

@ -8,7 +8,7 @@ import type {
McpUiHostCapabilities,
McpUiResourceMeta,
} from "@modelcontextprotocol/ext-apps/app-bridge";
import type { Client } from "@modelcontextprotocol/client";
import type { Client, Transport } from "@modelcontextprotocol/client";
import type {
EmbeddedResource,
Implementation,
@ -26,7 +26,21 @@ import {
isHttpUrl,
} from "../../../lib/downloadFile";
import { snapshotHostContext } from "./hostContext";
import type { BridgeFactory } from "./AppRenderer";
import { observeAppCapabilities } from "./appCapabilities";
import type { AppRenderSource, BridgeFactory } from "./AppRenderer";
/**
* The `ui://` resource a render source loads, or `undefined` for an App tool
* whose `_meta` names none (in which case there is nothing to push into the
* sandbox and the frame stays empty).
*/
function resolveSourceUri(source: AppRenderSource): string | undefined {
return source.kind === "resource"
? source.resourceUri
: getToolUiResourceUri(
source.tool as Parameters<typeof getToolUiResourceUri>[0],
);
}
/**
* Host identity advertised to MCP Apps during the bridge handshake. Static —
@ -74,6 +88,16 @@ export interface AppBridgeFactoryDeps {
* frame; the error is also always console.error'd.
*/
onResourceError?: (err: Error) => void;
/**
* Advertise `hostCapabilities.elicitation` — "this host can forward a
* form-mode `elicitation/create` to the app and return its result to the
* server" (#1854).
*
* Off by default, and deliberately per-factory rather than global: an App
* *tool* frame is never handed an elicitation, so claiming the capability
* there would tell the app something untrue about what its host will do.
*/
advertiseElicitation?: boolean;
}
/** First text content block of a UI resource, plus its `_meta` (sandbox hints). */
@ -198,7 +222,7 @@ function downloadResourceItem(item: EmbeddedResource | ResourceLink): boolean {
export function createAppBridgeFactory(
deps: AppBridgeFactoryDeps,
): BridgeFactory {
return async (iframe, tool) => {
return async (iframe, source) => {
const client = deps.getClient();
if (!client) {
throw new Error("Cannot render MCP App: no connected MCP client.");
@ -211,7 +235,17 @@ export function createAppBridgeFactory(
// Per-app copy so the approved-sandbox echo (set on sandboxready below)
// never mutates the shared HOST_CAPABILITIES constant — each app may
// declare its own csp/permissions.
const hostCapabilities: McpUiHostCapabilities = { ...HOST_CAPABILITIES };
const hostCapabilities: McpUiHostCapabilities = {
...HOST_CAPABILITIES,
// `elicitation` is not part of ext-apps 1.7.5's `McpUiHostCapabilities`
// (ext-apps#733 adds it), so it is spread in as an extra key. The bridge
// forwards the capabilities object verbatim in its `ui/initialize`
// response, which is exactly what the app reads. TODO: drop the cast when
// a release containing #733 ships.
...(deps.advertiseElicitation
? ({ elicitation: {} } as Partial<McpUiHostCapabilities>)
: {}),
};
// ext-apps' `AppBridge` peers on SDK v1's `Client`/`Implementation`; both
// are runtime-compatible with v2's. Cast at this single construction
// boundary. TODO: drop when ext-apps#702 ships a v2 peer release.
@ -234,9 +268,7 @@ export function createAppBridgeFactory(
bridge.addEventListener("sandboxready", () => {
void (async () => {
try {
const uri = getToolUiResourceUri(
tool as Parameters<typeof getToolUiResourceUri>[0],
);
const uri = resolveSourceUri(source);
if (!uri) return;
const result = await deps.readResource(uri);
const { html, meta } = extractHtmlAndMeta(result);
@ -338,6 +370,10 @@ export function createAppBridgeFactory(
const transport = new PostMessageTransport(targetWindow, targetWindow);
await bridge.connect(transport);
// Record the view's raw `ui/initialize` capabilities before the bridge's
// own schema strips the keys it predates (#1854). Must follow `connect`,
// which is what installs the handler this wraps.
observeAppCapabilities(bridge, transport as unknown as Transport);
return bridge;
};
}