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
11 changed files with 3718 additions and 93 deletions

View file

@ -61,7 +61,7 @@ const meta: Meta<typeof AppRenderer> = {
component: AppRenderer,
args: {
sandboxPath: PLACEHOLDER_SANDBOX,
tool: cohortTool,
source: { kind: "tool", tool: cohortTool },
onError: fn(),
},
parameters: {

View file

@ -85,7 +85,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -99,7 +99,10 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={{ name: "no_title", inputSchema: { type: "object" } }}
source={{
kind: "tool",
tool: { name: "no_title", inputSchema: { type: "object" } },
}}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -112,14 +115,14 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
await flushAsync();
expect(factory).toHaveBeenCalledTimes(1);
expect(factory.mock.calls[0]?.[0]).toBeInstanceOf(HTMLIFrameElement);
expect(factory.mock.calls[0]?.[1]).toBe(tool);
expect(factory.mock.calls[0]?.[1]).toEqual({ kind: "tool", tool });
});
it("forwards sendToolInput through the bridge once initialized", async () => {
@ -129,7 +132,7 @@ describe("AppRenderer", () => {
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -153,7 +156,7 @@ describe("AppRenderer", () => {
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -173,7 +176,7 @@ describe("AppRenderer", () => {
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -205,7 +208,7 @@ describe("AppRenderer", () => {
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -228,7 +231,7 @@ describe("AppRenderer", () => {
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -247,7 +250,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
onSizeChange={onSizeChange}
/>,
@ -264,7 +267,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -283,7 +286,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
displayMode="inline"
onRequestDisplayMode={onRequestDisplayMode}
@ -301,7 +304,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
displayMode="fullscreen"
/>,
@ -317,7 +320,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -334,7 +337,7 @@ describe("AppRenderer", () => {
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
partialInputs={[{ city: "N" }, { city: "New" }]}
/>,
@ -363,7 +366,7 @@ describe("AppRenderer", () => {
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -381,7 +384,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
onLog={onLog}
/>,
@ -398,7 +401,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -415,7 +418,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
onMessage={onMessage}
/>,
@ -434,7 +437,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -452,7 +455,7 @@ describe("AppRenderer", () => {
const { rerender } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
displayMode="inline"
/>,
@ -463,7 +466,7 @@ describe("AppRenderer", () => {
rerender(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
displayMode="fullscreen"
/>,
@ -480,7 +483,7 @@ describe("AppRenderer", () => {
const { rerender } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
displayMode="inline"
/>,
@ -491,7 +494,7 @@ describe("AppRenderer", () => {
rerender(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
displayMode="fullscreen"
/>,
@ -523,7 +526,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -564,7 +567,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -588,7 +591,7 @@ describe("AppRenderer", () => {
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -665,7 +668,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
containerRef={{ current: container }}
/>,
@ -684,7 +687,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
containerRef={{ current: container }}
/>,
@ -703,7 +706,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
containerRef={{ current: container }}
/>,
@ -721,7 +724,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
containerRef={{ current: container }}
/>,
@ -752,7 +755,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
containerRef={{ current: container }}
/>,
@ -766,7 +769,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -779,7 +782,7 @@ describe("AppRenderer", () => {
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -807,7 +810,7 @@ describe("AppRenderer", () => {
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>
</StrictMode>,
@ -837,7 +840,7 @@ describe("AppRenderer", () => {
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -864,7 +867,7 @@ describe("AppRenderer", () => {
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -901,7 +904,7 @@ describe("AppRenderer", () => {
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
@ -925,7 +928,7 @@ describe("AppRenderer", () => {
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -949,7 +952,7 @@ describe("AppRenderer", () => {
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
@ -972,7 +975,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
onAppStatusChange={onAppStatusChange}
/>,
@ -993,7 +996,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
onAppStatusChange={onAppStatusChange}
/>,
@ -1010,7 +1013,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
onAppStatusChange={onAppStatusChange}
/>,
@ -1027,7 +1030,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
onError={onError}
/>,
@ -1044,7 +1047,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
onError={onError}
/>,
@ -1059,7 +1062,7 @@ describe("AppRenderer", () => {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
@ -1078,7 +1081,7 @@ describe("AppRenderer", () => {
const { rerender } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox-a.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
@ -1087,7 +1090,7 @@ describe("AppRenderer", () => {
rerender(
<AppRenderer
sandboxPath="/sandbox-b.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
@ -1117,7 +1120,7 @@ describe("AppRenderer", () => {
const { rerender } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
@ -1126,14 +1129,17 @@ describe("AppRenderer", () => {
rerender(
<AppRenderer
sandboxPath="/sandbox.html"
tool={otherTool}
source={{ kind: "tool", tool: otherTool }}
bridgeFactory={factory}
/>,
);
await flushAsync();
expect(factory).toHaveBeenCalledTimes(2);
expect(factory.mock.calls[1]?.[1]).toBe(otherTool);
expect(factory.mock.calls[1]?.[1]).toEqual({
kind: "tool",
tool: otherTool,
});
expect(first.teardownResource).toHaveBeenCalledTimes(1);
expect(first.close).toHaveBeenCalledTimes(1);
});
@ -1144,7 +1150,7 @@ describe("AppRenderer", () => {
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -1164,7 +1170,7 @@ describe("AppRenderer", () => {
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
@ -1177,4 +1183,114 @@ describe("AppRenderer", () => {
expect(bridge.teardownResource).toHaveBeenCalledTimes(1);
expect(bridge.close).toHaveBeenCalledTimes(1);
});
describe("app-rendered elicitation (#1854)", () => {
/** A bridge that can answer an `elicitation/create` sent through it. */
function elicitationBridge(
result: unknown = { action: "accept", content: { choice: "a" } },
) {
const bridge = createMockBridge();
const withElicitation = Object.assign(bridge, {
getAppCapabilities: () => ({ elicitation: {} }),
request: vi.fn().mockResolvedValue(result),
});
return withElicitation;
}
it("renders an app named by resource URI, using the URI as the frame title", () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "resource", resourceUri: "ui://demo/pick.html" }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
expect(screen.getByTitle("ui://demo/pick.html")).toBeTruthy();
});
it("prefers an explicit title over the URI", () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{
kind: "resource",
resourceUri: "ui://demo/pick.html",
title: "Choose an option",
}}
bridgeFactory={() => asBridge(bridge)}
/>,
);
expect(screen.getByTitle("Choose an option")).toBeTruthy();
});
it("sends the request through the live bridge once the view is initialized", async () => {
const bridge = elicitationBridge();
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
source={{ kind: "resource", resourceUri: "ui://demo/pick.html" }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
act(() => bridge.emit("initialized"));
await expect(
ref.current!.requestElicitation({
message: "Choose",
requestedSchema: { type: "object", properties: {} },
}),
).resolves.toEqual({ action: "accept", content: { choice: "a" } });
});
it("rejects rather than buffering when the view is not ready", async () => {
// Unlike tool input/result there is a server waiting on this, so a
// caller that arrives early must learn now and fall back.
const bridge = elicitationBridge();
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
source={{ kind: "resource", resourceUri: "ui://demo/pick.html" }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await expect(
ref.current!.requestElicitation({
message: "Choose",
requestedSchema: { type: "object", properties: {} },
}),
).rejects.toThrow(/not ready/);
expect(bridge.request).not.toHaveBeenCalled();
});
it("keeps a live bridge when the source object is recreated with the same tool", async () => {
// A caller writing the source inline produces a fresh object every
// render; rebuilding on that double-loads the sandbox.
const bridge = createMockBridge();
const factory = vi.fn(() => asBridge(bridge));
const { rerender } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
await flushAsync();
rerender(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
await flushAsync();
expect(factory).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -15,9 +15,18 @@ import type {
} from "@modelcontextprotocol/ext-apps/app-bridge";
import type {
CallToolResult,
ElicitRequest,
ElicitResult,
LoggingMessageNotification,
Tool,
} from "@modelcontextprotocol/client";
import { requestAppElicitation } from "./requestAppElicitation";
import {
appSourceTitle,
sameAppSource,
type AppRenderSource,
} from "./appRenderSource";
export type { AppRenderSource } from "./appRenderSource";
import {
currentStyles,
currentTheme,
@ -32,13 +41,23 @@ import {
*/
export type BridgeFactory = (
iframe: HTMLIFrameElement,
tool: Tool,
source: AppRenderSource,
) => AppBridge | Promise<AppBridge>;
export interface AppRendererHandle {
sendToolInput(args: Record<string, unknown>): Promise<void>;
sendToolResult(result: CallToolResult): Promise<void>;
sendToolCancelled(reason: string): Promise<void>;
/**
* Forward a form-mode `elicitation/create` through THIS renderer's bridge and
* resolve with the app's standard `ElicitResult` (#1854).
*
* Rejects — rather than resolving with anything invented — when the app is not
* live, did not advertise `elicitation`, or fails the request, because the
* caller's contract is that a rejection means "fall back to the native UI"
* while a resolution is a real user decision.
*/
requestElicitation(params: ElicitRequest["params"]): Promise<ElicitResult>;
teardown(): Promise<void>;
}
@ -54,7 +73,7 @@ export type AppRendererStatus = "loading" | "ready" | "error";
export interface AppRendererProps {
sandboxPath: string;
tool: Tool;
source: AppRenderSource;
bridgeFactory: BridgeFactory;
onError?: (err: Error) => void;
/**
@ -135,7 +154,7 @@ async function disposeBridge(bridge: AppBridge): Promise<void> {
/**
* Bridge lifecycle (the interlocking refs below):
*
* mount ─▶ build (buildId++) ─▶ factory(iframe,tool) ─async─▶ bridgeRef set
* mount ─▶ build (buildId++) ─▶ factory(iframe,source) ─async─▶ bridgeRef set
* │ on "initialized"
* ▼ → flushPending
* cleanup ─▶ scheduleDispose() ──microtask──▶ dispose (unless cancelled)
@ -154,7 +173,7 @@ async function disposeBridge(bridge: AppBridge): Promise<void> {
*/
export function AppRenderer({
sandboxPath,
tool,
source,
bridgeFactory,
onError,
onAppStatusChange,
@ -182,7 +201,7 @@ export function AppRenderer({
const lastDepsRef = useRef<{
bridgeFactory: BridgeFactory;
sandboxPath: string;
tool: Tool;
source: AppRenderSource;
} | null>(null);
const onErrorRef = useRef(onError);
const onAppStatusChangeRef = useRef(onAppStatusChange);
@ -268,7 +287,7 @@ export function AppRenderer({
prev !== null &&
prev.bridgeFactory === bridgeFactory &&
prev.sandboxPath === sandboxPath &&
prev.tool === tool;
sameAppSource(prev.source, source);
// A disposal scheduled by the immediately-preceding cleanup means we are in
// a synchronous re-setup. If the inputs are identical (StrictMode's
@ -298,7 +317,7 @@ export function AppRenderer({
if (old) void disposeBridge(old);
}
lastDepsRef.current = { bridgeFactory, sandboxPath, tool };
lastDepsRef.current = { bridgeFactory, sandboxPath, source };
const buildId = ++buildIdRef.current;
teardownStartedRef.current = false;
initializedRef.current = false;
@ -311,7 +330,7 @@ export function AppRenderer({
let pending: Promise<AppBridge>;
try {
pending = Promise.resolve(bridgeFactory(iframe, tool));
pending = Promise.resolve(bridgeFactory(iframe, source));
} catch (err) {
onAppStatusChangeRef.current?.("error");
onErrorRef.current?.(toError(err));
@ -391,7 +410,7 @@ export function AppRenderer({
}, [
bridgeFactory,
sandboxPath,
tool,
source,
containerRef,
flushPending,
scheduleDispose,
@ -489,6 +508,18 @@ export function AppRenderer({
pendingResultRef.current = result;
flushPending();
},
async requestElicitation(params) {
const bridge = bridgeRef.current;
// Not "not ready yet, buffer it" like tool input/result: an elicitation
// has a server waiting on it, so a caller that arrives before the
// handshake must learn that now and fall back, not block.
if (!bridge || !initializedRef.current) {
throw new Error(
"MCP App is not ready to receive an elicitation request",
);
}
return requestAppElicitation(bridge, params);
},
async sendToolCancelled(reason) {
const bridge = bridgeRef.current;
if (!bridge) return;
@ -528,7 +559,7 @@ export function AppRenderer({
component="iframe"
ref={iframeRef}
src={sandboxPath}
title={tool.title ?? tool.name}
title={appSourceTitle(source)}
w="100%"
h="100%"
bd={0}

View file

@ -0,0 +1,94 @@
import { describe, it, expect, vi } from "vitest";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { JSONRPCMessage, Transport } from "@modelcontextprotocol/client";
import {
appAdvertisesElicitation,
observeAppCapabilities,
} from "./appCapabilities";
function makeBridge(parsed?: Record<string, unknown>): AppBridge {
return { getAppCapabilities: () => parsed } as unknown as AppBridge;
}
function makeTransport(): {
transport: Transport;
inner: ReturnType<typeof vi.fn>;
} {
const inner = vi.fn();
const transport = { onmessage: inner } as unknown as Transport;
return { transport, inner };
}
function initializeFrame(appCapabilities: unknown): JSONRPCMessage {
return {
jsonrpc: "2.0",
id: 1,
method: "ui/initialize",
params: { appCapabilities },
} as unknown as JSONRPCMessage;
}
describe("appCapabilities (#1854)", () => {
it("records the raw ui/initialize capabilities the bridge's schema strips", () => {
// The whole reason this module exists: ext-apps 1.7.5 parses away the
// `elicitation` key, so an app that DID advertise it reads as one that did
// not — and every negotiated elicitation silently becomes a fallback.
const bridge = makeBridge({});
const { transport, inner } = makeTransport();
observeAppCapabilities(bridge, transport);
expect(appAdvertisesElicitation(bridge)).toBe(false);
transport.onmessage?.(initializeFrame({ elicitation: {} }));
expect(appAdvertisesElicitation(bridge)).toBe(true);
// The bridge's own handler still runs — observing must not swallow.
expect(inner).toHaveBeenCalledTimes(1);
});
it("prefers the bridge's own value once ext-apps carries it", () => {
const bridge = makeBridge({ elicitation: {} });
expect(appAdvertisesElicitation(bridge)).toBe(true);
});
it("is false for an app that advertised no elicitation", () => {
const bridge = makeBridge({ availableDisplayModes: ["inline"] });
const { transport } = makeTransport();
observeAppCapabilities(bridge, transport);
transport.onmessage?.(initializeFrame({ availableDisplayModes: [] }));
expect(appAdvertisesElicitation(bridge)).toBe(false);
});
it("ignores other methods and non-object capabilities", () => {
const bridge = makeBridge(undefined);
const { transport } = makeTransport();
observeAppCapabilities(bridge, transport);
transport.onmessage?.({
jsonrpc: "2.0",
method: "ui/notifications/initialized",
} as JSONRPCMessage);
transport.onmessage?.(initializeFrame("not-an-object"));
transport.onmessage?.(initializeFrame(null));
expect(appAdvertisesElicitation(bridge)).toBe(false);
});
it("keeps bridges independent", () => {
const a = makeBridge({});
const b = makeBridge({});
const first = makeTransport();
const second = makeTransport();
observeAppCapabilities(a, first.transport);
observeAppCapabilities(b, second.transport);
first.transport.onmessage?.(initializeFrame({ elicitation: {} }));
expect(appAdvertisesElicitation(a)).toBe(true);
expect(appAdvertisesElicitation(b)).toBe(false);
});
it("tolerates a transport with no prior handler", () => {
const bridge = makeBridge({});
const transport = {} as unknown as Transport;
observeAppCapabilities(bridge, transport);
expect(() =>
transport.onmessage?.(initializeFrame({ elicitation: {} })),
).not.toThrow();
expect(appAdvertisesElicitation(bridge)).toBe(true);
});
});

View file

@ -0,0 +1,67 @@
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { Transport } from "@modelcontextprotocol/client";
/**
* The app capabilities exactly as the view sent them, per bridge.
*
* `AppBridge.getAppCapabilities()` cannot be used for `elicitation` (#1854):
* ext-apps 1.7.5 parses the view's `ui/initialize` params through
* `McpUiAppCapabilitiesSchema`, a plain Zod object, so any key that schema does
* not declare is **stripped before the bridge stores it**. `elicitation` is
* exactly such a key — it is what ext-apps#733 adds — so an app that correctly
* advertises it looks, through the bridge's own accessor, like an app that did
* not. That silently turns every negotiated elicitation into a native-UI
* fallback, which is indistinguishable from "the feature is off".
*
* So the raw frame is observed on the way in and recorded here. A WeakMap keeps
* this per-bridge (never global) and lets the entry die with the bridge.
*
* Delete this module when ext-apps ships #733: `getAppCapabilities()` will
* carry `elicitation` itself, and {@link appAdvertisesElicitation} already
* prefers that value.
*/
const rawAppCapabilities = new WeakMap<AppBridge, Record<string, unknown>>();
/** Shape of the `ui/initialize` frame this reads — nothing else is touched. */
interface UiInitializeFrame {
method?: unknown;
params?: { appCapabilities?: unknown };
}
/**
* Wrap a connected bridge's transport so the view's `ui/initialize` params are
* recorded before the bridge parses them.
*
* Call AFTER `bridge.connect(transport)` — `connect` installs the handler this
* wraps. That ordering is safe: the view cannot send `ui/initialize` until the
* host has pushed its HTML into the sandbox, which happens later still.
*/
export function observeAppCapabilities(
bridge: AppBridge,
transport: Transport,
): void {
const inner = transport.onmessage?.bind(transport);
transport.onmessage = (message, extra) => {
const frame = message as UiInitializeFrame;
if (frame.method === "ui/initialize") {
const advertised = frame.params?.appCapabilities;
if (typeof advertised === "object" && advertised !== null) {
rawAppCapabilities.set(bridge, advertised as Record<string, unknown>);
}
}
inner?.(message, extra);
};
}
/**
* Whether the app running on this bridge advertised the `elicitation`
* capability. Prefers the bridge's own accessor (correct once ext-apps#733
* ships) and falls back to the observed raw frame.
*/
export function appAdvertisesElicitation(bridge: AppBridge): boolean {
const parsed = bridge.getAppCapabilities() as
| Record<string, unknown>
| undefined;
if (parsed?.elicitation) return true;
return Boolean(rawAppCapabilities.get(bridge)?.elicitation);
}

View file

@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import type { Tool } from "@modelcontextprotocol/client";
import {
appSourceTitle,
sameAppSource,
type AppRenderSource,
} from "./appRenderSource";
const tool: Tool = {
name: "cohort_app",
title: "Cohort App",
inputSchema: { type: "object" },
};
const other: Tool = { name: "other_app", inputSchema: { type: "object" } };
const toolSource: AppRenderSource = { kind: "tool", tool };
const resourceSource: AppRenderSource = {
kind: "resource",
resourceUri: "ui://demo/pick.html",
};
describe("appRenderSource (#1854)", () => {
describe("sameAppSource", () => {
it("is true for the identical object", () => {
expect(sameAppSource(toolSource, toolSource)).toBe(true);
});
it("is true for a re-created wrapper around the same tool", () => {
// The reason the comparison is not `===`: a caller writing the source
// inline makes a new object each render, and rebuilding on that
// double-loads the sandbox.
expect(sameAppSource(toolSource, { kind: "tool", tool })).toBe(true);
});
it("is false when the Tool identity changes", () => {
// Preserved from before the union existed: a re-listed tool rebuilds.
expect(sameAppSource(toolSource, { kind: "tool", tool: other })).toBe(
false,
);
expect(
sameAppSource(toolSource, { kind: "tool", tool: { ...tool } }),
).toBe(false);
});
it("compares resource sources by URI and title", () => {
expect(
sameAppSource(resourceSource, {
kind: "resource",
resourceUri: "ui://demo/pick.html",
}),
).toBe(true);
expect(
sameAppSource(resourceSource, {
kind: "resource",
resourceUri: "ui://demo/other.html",
}),
).toBe(false);
expect(
sameAppSource(resourceSource, {
kind: "resource",
resourceUri: "ui://demo/pick.html",
title: "Pick one",
}),
).toBe(false);
});
it("is false across kinds", () => {
expect(sameAppSource(toolSource, resourceSource)).toBe(false);
expect(sameAppSource(resourceSource, toolSource)).toBe(false);
});
});
describe("appSourceTitle", () => {
it("prefers a tool's title, falling back to its name", () => {
expect(appSourceTitle(toolSource)).toBe("Cohort App");
expect(appSourceTitle({ kind: "tool", tool: other })).toBe("other_app");
});
it("prefers an explicit resource title, falling back to the URI", () => {
expect(appSourceTitle(resourceSource)).toBe("ui://demo/pick.html");
expect(
appSourceTitle({ ...resourceSource, title: "Choose an option" }),
).toBe("Choose an option");
});
});
});

View file

@ -0,0 +1,49 @@
import type { Tool } from "@modelcontextprotocol/client";
/**
* What the renderer loads into the sandbox.
*
* An App tool names its UI resource through `_meta.ui.resourceUri`, but an
* app-rendered elicitation (#1854) has no tool at all — the server names the
* resource on the `elicitation/create` request itself. The source is therefore
* a union rather than a `Tool`, so the same renderer, bridge factory and
* sandbox lifecycle serve both without either faking the other's shape.
*
* Lives beside `AppRenderer` rather than in it because a module that exports a
* component may export nothing else (the react-refresh rule).
*/
export type AppRenderSource =
| { readonly kind: "tool"; readonly tool: Tool }
| {
readonly kind: "resource";
/** Absolute `ui://` URI of the app to load. */
readonly resourceUri: string;
/** Frame title; falls back to the URI. */
readonly title?: string;
};
/**
* Whether two sources name the same app, so the renderer can keep a live bridge
* instead of rebuilding it.
*
* Identity alone is not enough: a caller that writes the source inline produces
* a fresh object every render, and rebuilding on that double-loads the sandbox
* and races the app's handshake (the failure AppRenderer's reuse dance exists to
* avoid). For a tool the comparison stays *identity of the Tool*, exactly as
* before this union existed, so a re-listed tool still rebuilds.
*/
export function sameAppSource(a: AppRenderSource, b: AppRenderSource): boolean {
if (a === b) return true;
if (a.kind === "tool" && b.kind === "tool") return a.tool === b.tool;
if (a.kind === "resource" && b.kind === "resource") {
return a.resourceUri === b.resourceUri && a.title === b.title;
}
return false;
}
/** The iframe's accessible name for a source. */
export function appSourceTitle(source: AppRenderSource): string {
return source.kind === "tool"
? (source.tool.title ?? source.tool.name)
: (source.title ?? source.resourceUri);
}

View file

@ -113,7 +113,7 @@ describe("createAppBridgeFactory", () => {
getClient: () => null,
readResource: vi.fn(),
});
await expect(factory(makeIframe(), tool)).rejects.toThrow(
await expect(factory(makeIframe(), { kind: "tool", tool })).rejects.toThrow(
/no connected MCP client/,
);
});
@ -123,7 +123,9 @@ describe("createAppBridgeFactory", () => {
getClient: () => fakeClient,
readResource: vi.fn(),
});
await expect(factory(makeIframe(false), tool)).rejects.toThrow(/no window/);
await expect(
factory(makeIframe(false), { kind: "tool", tool }),
).rejects.toThrow(/no window/);
});
it("constructs the bridge with the client, host info, capabilities and theme, then connects", async () => {
@ -134,7 +136,7 @@ describe("createAppBridgeFactory", () => {
getClient: () => fakeClient,
readResource: vi.fn().mockResolvedValue(uiResource("<h1>hi</h1>")),
});
await factory(makeIframe(), tool);
await factory(makeIframe(), { kind: "tool", tool });
expect(bridgeInstances).toHaveLength(1);
const bridge = bridgeInstances[0];
expect(bridge.ctorArgs[0]).toBe(fakeClient);
@ -167,7 +169,7 @@ describe("createAppBridgeFactory", () => {
getClient: () => fakeClient,
readResource,
});
await factory(makeIframe(), tool);
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
@ -215,7 +217,7 @@ describe("createAppBridgeFactory", () => {
getClient: () => fakeClient,
readResource,
});
await factory(makeIframe(), tool);
await factory(makeIframe(), { kind: "tool", tool });
bridgeInstances[0].emit("sandboxready");
await flush();
expect(HOST_CAPABILITIES.sandbox).toBeUndefined();
@ -235,7 +237,7 @@ describe("createAppBridgeFactory", () => {
getClient: () => fakeClient,
readResource,
});
await factory(makeIframe(), tool);
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
@ -256,8 +258,8 @@ describe("createAppBridgeFactory", () => {
readResource,
});
await factory(makeIframe(), {
name: "plain",
inputSchema: { type: "object" },
kind: "tool",
tool: { name: "plain", inputSchema: { type: "object" } },
});
bridgeInstances[0].emit("sandboxready");
await flush();
@ -274,7 +276,7 @@ describe("createAppBridgeFactory", () => {
readResource,
onResourceError,
});
await factory(makeIframe(), tool);
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
@ -295,7 +297,7 @@ describe("createAppBridgeFactory", () => {
readResource,
onResourceError,
});
await factory(makeIframe(), tool);
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
@ -317,7 +319,7 @@ describe("createAppBridgeFactory", () => {
readResource,
onResourceError,
});
await factory(makeIframe(), tool);
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
@ -335,7 +337,7 @@ describe("createAppBridgeFactory", () => {
getClient: () => fakeClient,
readResource,
});
await factory(makeIframe(), tool);
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
@ -352,7 +354,7 @@ describe("createAppBridgeFactory", () => {
getClient: () => fakeClient,
readResource: vi.fn().mockResolvedValue(uiResource("<h1>x</h1>")),
});
await factory(makeIframe(), tool);
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
await expect(
@ -376,7 +378,7 @@ describe("createAppBridgeFactory", () => {
getClient: () => fakeClient,
readResource: vi.fn().mockResolvedValue(uiResource("<h1>x</h1>")),
});
await factory(makeIframe(), tool);
await factory(makeIframe(), { kind: "tool", tool });
expect(bridgeInstances[0].ctorArgs[2]).toMatchObject({ downloadFile: {} });
});
@ -394,7 +396,7 @@ describe("createAppBridgeFactory", () => {
getClient: () => fakeClient,
readResource: vi.fn().mockResolvedValue(uiResource("<h1>x</h1>")),
});
await factory(makeIframe(), tool);
await factory(makeIframe(), { kind: "tool", tool });
return bridgeInstances[0];
}

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;
};
}

View file

@ -0,0 +1,91 @@
import { describe, it, expect, vi } from "vitest";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { ElicitRequest } from "@modelcontextprotocol/client";
import {
APP_ELICITATION_TIMEOUT_MS,
requestAppElicitation,
} from "./requestAppElicitation";
const params: ElicitRequest["params"] = {
message: "Choose an option",
requestedSchema: {
type: "object",
properties: { choice: { type: "string" } },
required: ["choice"],
},
};
function makeBridge(options: {
appCapabilities?: Record<string, unknown>;
request?: ReturnType<typeof vi.fn>;
}) {
return {
getAppCapabilities: () => options.appCapabilities,
request: options.request ?? vi.fn(),
} as unknown as AppBridge;
}
describe("requestAppElicitation (#1854)", () => {
it("sends the standard method and params through the given bridge", async () => {
const request = vi
.fn()
.mockResolvedValue({ action: "accept", content: { choice: "option-a" } });
const bridge = makeBridge({
appCapabilities: { elicitation: {} },
request,
});
await expect(requestAppElicitation(bridge, params)).resolves.toEqual({
action: "accept",
content: { choice: "option-a" },
});
// The method and params must reach the app UNCHANGED — the whole contract
// is that no custom method or result shape is introduced.
const [sent, , options] = request.mock.calls[0];
expect(sent).toEqual({ method: "elicitation/create", params });
expect(options).toEqual({ timeout: APP_ELICITATION_TIMEOUT_MS });
});
it("honors a caller-supplied timeout", async () => {
const request = vi.fn().mockResolvedValue({ action: "cancel" });
const bridge = makeBridge({
appCapabilities: { elicitation: {} },
request,
});
await requestAppElicitation(bridge, params, 1234);
expect(request.mock.calls[0][2]).toEqual({ timeout: 1234 });
});
it("fails closed when the app did not advertise elicitation", async () => {
const request = vi.fn();
const bridge = makeBridge({ appCapabilities: {}, request });
await expect(requestAppElicitation(bridge, params)).rejects.toThrow(
/does not support elicitation/,
);
// Not merely "returns an error" — nothing is sent at all, so a wedged app
// cannot hold the server's request open for the full timeout.
expect(request).not.toHaveBeenCalled();
});
it("fails closed when the app advertised nothing at all", async () => {
await expect(requestAppElicitation(makeBridge({}), params)).rejects.toThrow(
/does not support elicitation/,
);
});
it("propagates a bridge failure so the caller can fall back", async () => {
const bridge = makeBridge({
appCapabilities: { elicitation: {} },
request: vi.fn().mockRejectedValue(new Error("transport closed")),
});
await expect(requestAppElicitation(bridge, params)).rejects.toThrow(
/transport closed/,
);
});
it("keeps the answer timeout far above the SDK's request default", () => {
// The thing being waited on is a person, not a server. 60s (the SDK
// default) would abandon a user who paused to think.
expect(APP_ELICITATION_TIMEOUT_MS).toBeGreaterThan(60_000);
});
});

View file

@ -0,0 +1,59 @@
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;
}