inspector/clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts
cliffhall 08fc20a22d 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>
2026-08-22 19:05:33 -04:00

59 lines
2.9 KiB
TypeScript

import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/client";
import { ElicitResultSchema } from "@modelcontextprotocol/core";
import { appAdvertisesElicitation } from "./appCapabilities";
/**
* How long the host waits for an app to answer an elicitation before giving up
* and falling back to the native UI.
*
* Deliberately generous: unlike a tool call, the thing being waited on is a
* *person* filling in a form, and the SDK's 60s request default would abandon
* a user who paused to think. Ten minutes bounds a bridge that will never
* answer (a wedged app, a closed tab) without ever racing a real user.
*/
export const APP_ELICITATION_TIMEOUT_MS = 10 * 60 * 1000;
/**
* Forward a form-mode `elicitation/create` to one specific running MCP App and
* return the app's standard `ElicitResult` (#1854).
*
* This is ext-apps' own `AppBridge.requestElicitation` from
* modelcontextprotocol/ext-apps#733 — same method, same params, same result —
* implemented against the bridge's generic `request()` because the released
* package (1.7.5) predates that PR. Replace the body with a call to
* `bridge.requestElicitation(params)` once a release containing #733 ships;
* nothing on the wire changes when that happens.
*
* Throwing is meaningful to every caller: it is the signal to fall back to the
* native elicitation UI. A user's `decline` or `cancel` is a *resolved* result,
* never a throw.
*/
export async function requestAppElicitation(
bridge: AppBridge,
params: ElicitRequest["params"],
timeoutMs: number = APP_ELICITATION_TIMEOUT_MS,
): Promise<ElicitResult> {
// Fail closed on the app's own advertisement rather than discovering it as a
// "-32601 method not found" ten minutes later: an app that never registered
// an elicitation handler is a fallback case, not an error case.
// NOT `bridge.getAppCapabilities()` directly: ext-apps 1.7.5 strips the
// `elicitation` key when it parses `ui/initialize`. See appCapabilities.ts.
if (!appAdvertisesElicitation(bridge)) {
throw new Error("App does not support elicitation");
}
// ext-apps 1.7.5's send union (`AppRequest`) has no `ElicitRequest` member —
// that is precisely what #733 adds — so TypeScript sees no overlap with the
// existing members and a single `as` is rejected. The double cast is the
// documented-gap case: the runtime is a plain JSON-RPC send of the standard
// method with its standard params, verified against the app-side handler in
// the fixture and the bridge tests. Confined to this one line and removed
// with the ext-apps bump, when `bridge.requestElicitation(params)` replaces it.
const request = {
method: "elicitation/create",
params,
} as unknown as Parameters<AppBridge["request"]>[0];
return (await bridge.request(request, ElicitResultSchema, {
timeout: timeoutMs,
})) as ElicitResult;
}