Merge remote-tracking branch 'origin/v2/main' into v2/feat/1950-file-secret-store

Signed-off-by: cliffhall <cliff@futurescale.com>

# Conflicts:
#	README.md
#	clients/web/src/App.tsx
This commit is contained in:
cliffhall 2026-08-23 01:40:01 -04:00
commit a859093bd3
14 changed files with 4824 additions and 139 deletions

View file

@ -0,0 +1,105 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { ElicitRequest } from "@modelcontextprotocol/client";
import { expect, fn, within } from "storybook/test";
import type { AppElicitationEntry } from "../../../lib/appElicitationController";
import type { BridgeFactory } from "../AppRenderer/AppRenderer";
import { AppElicitationHost } from "./AppElicitationHost";
const PLACEHOLDER_SANDBOX = "data:text/html,<title>Mock%20Sandbox</title>";
const params: ElicitRequest["params"] = {
message: "Choose option A or B.",
requestedSchema: {
type: "object",
properties: { choice: { type: "string", enum: ["option-a", "option-b"] } },
required: ["choice"],
},
};
function entry(
requestId: string,
resourceUri = "ui://demo/choose-option.html",
): AppElicitationEntry {
return {
requestId,
sessionId: 0,
resourceUri,
params,
signal: new AbortController().signal,
resolve: fn(),
reject: fn(),
};
}
/**
* Partial mock: implements only the `AppBridge` members the renderer touches,
* plus the elicitation pair. The double cast bridges the deliberately
* incomplete shape, as in the AppRenderer stories.
*/
function createMockBridge(): AppBridge {
return {
getAppCapabilities: () => ({ elicitation: {} }),
request: async () => ({
action: "accept",
content: { choice: "option-a" },
}),
sendHostContextChange: async () => {},
addEventListener: () => {},
removeEventListener: () => {},
teardownResource: async () => ({}),
close: async () => {},
} as unknown as AppBridge;
}
const okFactory: BridgeFactory = () => createMockBridge();
const failingFactory: BridgeFactory = () =>
Promise.reject(new Error("Bridge connect failed: handshake timed out"));
const meta: Meta<typeof AppElicitationHost> = {
title: "Elements/AppElicitationHost",
component: AppElicitationHost,
args: {
sandboxPath: PLACEHOLDER_SANDBOX,
bridgeFactory: okFactory,
onSettle: fn(),
onFail: fn(),
},
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof AppElicitationHost>;
/** One server-attached app, waiting on the user inside the sandbox frame. */
export const SingleRequest: Story = {
args: { entries: [entry("req-1")] },
play: async ({ canvasElement }) => {
// Modals portal to document.body, so scope to the whole document.
const body = within(canvasElement.ownerDocument.body);
await expect(await body.findByText("Choose option A or B.")).toBeVisible();
},
};
/**
* Two elicitations in flight at once, each with its own frame and bridge — the
* request-scoped ownership the contract requires.
*/
export const ConcurrentRequests: Story = {
args: {
entries: [
entry("req-1", "ui://demo/first.html"),
entry("req-2", "ui://demo/second.html"),
],
},
};
/** The app could not be brought up; the host falls back to the native form. */
export const RenderFailure: Story = {
args: { entries: [entry("req-1")], bridgeFactory: failingFactory },
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(await body.findByText(/App failed to render/)).toBeVisible();
},
};

View file

@ -0,0 +1,447 @@
import { act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/client";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import type { AppElicitationEntry } from "../../../lib/appElicitationController";
import type { BridgeFactory } from "../AppRenderer/AppRenderer";
import {
APP_ELICITATION_INIT_TIMEOUT_MS,
AppElicitationHost,
} from "./AppElicitationHost";
const params: ElicitRequest["params"] = {
message: "Choose an option",
requestedSchema: {
type: "object",
properties: { choice: { type: "string" } },
required: ["choice"],
},
};
/**
* The renderer's bridge, reduced to what this component drives: the app's
* advertised capabilities and the elicitation round-trip. `emit` lets a test
* play the view's `initialized` signal, which is what triggers the send.
*/
function createMockBridge(options: {
elicitation?: boolean;
answer?: () => Promise<unknown>;
}) {
const listeners: Record<string, ((payload: unknown) => void)[]> = {};
const answer =
options.answer ?? (() => Promise.resolve({ action: "cancel" }));
const request = vi.fn<(...args: unknown[]) => Promise<unknown>>(() =>
answer(),
);
return {
bridge: {
getAppCapabilities: () =>
options.elicitation === false ? {} : { elicitation: {} },
request,
teardownResource: vi.fn().mockResolvedValue({}),
close: vi.fn().mockResolvedValue(undefined),
addEventListener: vi.fn(
(event: string, handler: (p: unknown) => void) => {
(listeners[event] ??= []).push(handler);
},
),
removeEventListener: vi.fn(),
} as unknown as AppBridge,
request,
emit: (event: string, payload?: unknown) => {
(listeners[event] ?? []).forEach((h) => h(payload));
},
};
}
function makeEntry(
requestId: string,
resourceUri = "ui://demo/choose-option.html",
): AppElicitationEntry {
return {
requestId,
sessionId: 0,
resourceUri,
params,
signal: new AbortController().signal,
resolve: vi.fn(),
reject: vi.fn(),
};
}
/** Two microtasks settle the renderer's bridge promise chain (see AppRenderer). */
async function flushAsync(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("AppElicitationHost (#1854)", () => {
const onSettle = vi.fn();
const onFail = vi.fn();
beforeEach(() => {
onSettle.mockReset();
onFail.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("renders nothing and fails every entry when there is no sandbox", () => {
renderWithMantine(
<AppElicitationHost
entries={[makeEntry("a"), makeEntry("b")]}
bridgeFactory={vi.fn() as unknown as BridgeFactory}
onSettle={onSettle}
onFail={onFail}
/>,
);
expect(screen.queryByTestId("app-elicitation")).toBeNull();
expect(onFail).toHaveBeenCalledTimes(2);
expect(onFail.mock.calls[0][1].message).toMatch(/sandbox is not available/);
});
it("forwards the request through the app's bridge once it is ready and settles with the result", async () => {
const mock = createMockBridge({
answer: () =>
Promise.resolve({ action: "accept", content: { choice: "option-a" } }),
});
const factory = vi.fn(() => mock.bridge) as unknown as BridgeFactory;
renderWithMantine(
<AppElicitationHost
entries={[makeEntry("req-1")]}
sandboxPath="/sandbox.html"
bridgeFactory={factory}
onSettle={onSettle}
onFail={onFail}
/>,
);
await flushAsync();
// Nothing is sent before the view says it is ready — an app that has not
// completed ui/initialize has no handler registered yet.
expect(mock.request).not.toHaveBeenCalled();
await act(async () => {
mock.emit("initialized");
await Promise.resolve();
});
expect(mock.request.mock.calls[0][0]).toEqual({
method: "elicitation/create",
params,
});
expect(onSettle).toHaveBeenCalledWith("req-1", {
action: "accept",
content: { choice: "option-a" },
});
expect(onFail).not.toHaveBeenCalled();
});
it("loads the app from the elicitation's own resource URI", async () => {
const mock = createMockBridge({});
const factory = vi.fn(() => mock.bridge) as unknown as BridgeFactory;
renderWithMantine(
<AppElicitationHost
entries={[makeEntry("req-1", "ui://demo/other.html")]}
sandboxPath="/sandbox.html"
bridgeFactory={factory}
onSettle={onSettle}
onFail={onFail}
/>,
);
await flushAsync();
expect(factory).toHaveBeenCalledWith(expect.anything(), {
kind: "resource",
resourceUri: "ui://demo/other.html",
title: params.message,
});
});
it("gives each concurrent request its own frame and bridge", async () => {
const first = createMockBridge({});
const second = createMockBridge({});
const bridges = [first.bridge, second.bridge];
const factory = vi.fn(() => bridges.shift()) as unknown as BridgeFactory;
renderWithMantine(
<AppElicitationHost
entries={[
makeEntry("req-1", "ui://demo/first.html"),
makeEntry("req-2", "ui://demo/second.html"),
]}
sandboxPath="/sandbox.html"
bridgeFactory={factory}
onSettle={onSettle}
onFail={onFail}
/>,
);
await flushAsync();
expect(factory).toHaveBeenCalledTimes(2);
// Only the second app answers; its result must be attributed to req-2.
await act(async () => {
second.emit("initialized");
await Promise.resolve();
});
expect(first.request).not.toHaveBeenCalled();
expect(onSettle).toHaveBeenCalledTimes(1);
expect(onSettle.mock.calls[0][0]).toBe("req-2");
});
it("gives the focus trap, Escape and the overlay to the top modal only", async () => {
// Every entry stays mounted (each app keeps its own bridge and handshake),
// but only one may own the keyboard — otherwise the traps fight and a
// single Escape can dismiss more than one pending request.
const first = createMockBridge({});
const second = createMockBridge({});
const bridges = [first.bridge, second.bridge];
const factory = vi.fn(() => bridges.shift()) as unknown as BridgeFactory;
const user = userEvent.setup();
renderWithMantine(
<AppElicitationHost
entries={[makeEntry("req-1"), makeEntry("req-2")]}
sandboxPath="/sandbox.html"
bridgeFactory={factory}
onSettle={onSettle}
onFail={onFail}
/>,
);
await flushAsync();
expect(screen.getAllByTestId("app-elicitation")).toHaveLength(2);
// The covered dialog is inert: out of the a11y tree and out of focus
// order, but still mounted — its app keeps its bridge and handshake.
const [lower, top] = screen.getAllByTestId("app-elicitation");
expect(lower.hasAttribute("inert")).toBe(true);
expect(top.hasAttribute("inert")).toBe(false);
await user.keyboard("{Escape}");
// Exactly one request is dismissed — the topmost, which is the last one.
expect(onFail).toHaveBeenCalledTimes(1);
expect(onFail.mock.calls[0][0]).toBe("req-2");
expect(onFail.mock.calls[0][1].message).toMatch(/dismissed/);
});
it("falls back when the app does not advertise elicitation", async () => {
const mock = createMockBridge({ elicitation: false });
renderWithMantine(
<AppElicitationHost
entries={[makeEntry("req-1")]}
sandboxPath="/sandbox.html"
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
onSettle={onSettle}
onFail={onFail}
/>,
);
await flushAsync();
await act(async () => {
mock.emit("initialized");
await Promise.resolve();
});
expect(onSettle).not.toHaveBeenCalled();
expect(onFail.mock.calls[0][1].message).toMatch(
/does not support elicitation/,
);
});
it("falls back when the bridge request fails", async () => {
const mock = createMockBridge({
answer: () => Promise.reject(new Error("bridge exploded")),
});
renderWithMantine(
<AppElicitationHost
entries={[makeEntry("req-1")]}
sandboxPath="/sandbox.html"
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
onSettle={onSettle}
onFail={onFail}
/>,
);
await flushAsync();
await act(async () => {
mock.emit("initialized");
await Promise.resolve();
await Promise.resolve();
});
expect(onFail).toHaveBeenCalledWith("req-1", expect.any(Error));
expect(onFail.mock.calls[0][1].message).toMatch(/bridge exploded/);
});
it("wraps a non-Error rejection so the fallback still gets an Error", async () => {
// The rejection crosses a sandbox boundary; an app or bridge can reject
// with anything, and `onFail` must still hand the caller an Error.
const mock = createMockBridge({
answer: () => Promise.reject("just a string"),
});
renderWithMantine(
<AppElicitationHost
entries={[makeEntry("req-1")]}
sandboxPath="/sandbox.html"
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
onSettle={onSettle}
onFail={onFail}
/>,
);
await flushAsync();
await act(async () => {
mock.emit("initialized");
await Promise.resolve();
await Promise.resolve();
});
expect(onFail.mock.calls[0][1]).toBeInstanceOf(Error);
expect(onFail.mock.calls[0][1].message).toBe("just a string");
});
it("does not fall back on the init deadline once the request is in flight", async () => {
// The deadline bounds the HANDSHAKE only. A user taking longer than 15s to
// answer must not have their app yanked away.
vi.useFakeTimers();
let answer: ((result: ElicitResult) => void) | undefined;
const mock = createMockBridge({
answer: () => new Promise<ElicitResult>((resolve) => (answer = resolve)),
});
renderWithMantine(
<AppElicitationHost
entries={[makeEntry("req-1")]}
sandboxPath="/sandbox.html"
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
onSettle={onSettle}
onFail={onFail}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
mock.emit("initialized");
await Promise.resolve();
});
expect(mock.request).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(APP_ELICITATION_INIT_TIMEOUT_MS * 2);
});
expect(onFail).not.toHaveBeenCalled();
await act(async () => {
answer?.({ action: "accept", content: { choice: "option-a" } });
await Promise.resolve();
});
expect(onSettle).toHaveBeenCalledWith("req-1", {
action: "accept",
content: { choice: "option-a" },
});
});
it("falls back when the renderer cannot build a bridge at all", async () => {
const factory = vi.fn(() => {
throw new Error("no connected MCP client");
}) as unknown as BridgeFactory;
renderWithMantine(
<AppElicitationHost
entries={[makeEntry("req-1")]}
sandboxPath="/sandbox.html"
bridgeFactory={factory}
onSettle={onSettle}
onFail={onFail}
/>,
);
await flushAsync();
expect(onFail).toHaveBeenCalled();
expect(await screen.findByText(/App failed to render/)).toBeTruthy();
});
it("falls back when the app never completes its handshake", async () => {
vi.useFakeTimers();
const mock = createMockBridge({});
renderWithMantine(
<AppElicitationHost
entries={[makeEntry("req-1")]}
sandboxPath="/sandbox.html"
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
onSettle={onSettle}
onFail={onFail}
/>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(APP_ELICITATION_INIT_TIMEOUT_MS + 1);
});
expect(mock.request).not.toHaveBeenCalled();
expect(onFail.mock.calls[0][1].message).toMatch(/did not initialize/);
});
it("falls back when the user dismisses the modal", async () => {
const user = userEvent.setup();
const mock = createMockBridge({});
renderWithMantine(
<AppElicitationHost
entries={[makeEntry("req-1")]}
sandboxPath="/sandbox.html"
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
onSettle={onSettle}
onFail={onFail}
/>,
);
await flushAsync();
await user.click(
screen.getByRole("button", { name: /close and use the built-in/i }),
);
// Dismissing is not an answer: the server still needs one, so this must be
// a fallback rather than a fabricated `cancel`.
expect(onSettle).not.toHaveBeenCalled();
expect(onFail.mock.calls[0][1].message).toMatch(/dismissed/);
});
it("sends only one request even if the view signals ready twice", async () => {
const mock = createMockBridge({});
renderWithMantine(
<AppElicitationHost
entries={[makeEntry("req-1")]}
sandboxPath="/sandbox.html"
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
onSettle={onSettle}
onFail={onFail}
/>,
);
await flushAsync();
await act(async () => {
mock.emit("initialized");
mock.emit("initialized");
await Promise.resolve();
});
expect(mock.request).toHaveBeenCalledTimes(1);
});
it("passes decline and cancel through as completed answers", async () => {
const results: ElicitResult[] = [
{ action: "decline" },
{ action: "cancel" },
];
for (const result of results) {
onSettle.mockReset();
const mock = createMockBridge({ answer: () => Promise.resolve(result) });
const { unmount } = renderWithMantine(
<AppElicitationHost
entries={[makeEntry("req-1")]}
sandboxPath="/sandbox.html"
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
onSettle={onSettle}
onFail={onFail}
/>,
);
await flushAsync();
await act(async () => {
mock.emit("initialized");
await Promise.resolve();
});
expect(onSettle).toHaveBeenCalledWith("req-1", result);
unmount();
}
});
});

View file

@ -0,0 +1,300 @@
import { Alert, CloseButton, Group, Modal, Stack, Text } from "@mantine/core";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ElicitResult } from "@modelcontextprotocol/client";
import type { AppElicitationEntry } from "../../../lib/appElicitationController";
import {
AppRenderer,
type AppRenderSource,
type AppRendererHandle,
type AppRendererStatus,
type BridgeFactory,
} from "../AppRenderer/AppRenderer";
/**
* How long an app has to complete `ui/initialize` before the host gives up and
* lets the native elicitation UI take the request.
*
* Short on purpose, and unrelated to {@link APP_ELICITATION_TIMEOUT_MS} (which
* bounds the *answer*): nothing here waits on a human, so a sandbox that has
* not handshaken in this window is broken rather than slow, and the user is
* better served by the native form than by a spinner.
*/
export const APP_ELICITATION_INIT_TIMEOUT_MS = 15_000;
/**
* Modal shell for one app-rendered elicitation.
*
* Deliberately headerless (`withCloseButton: false`, no `title`): concurrent
* elicitations mean two of these are open at once, and Mantine's modal header
* is a `<header>` — a second banner landmark, which axe flags as both
* `landmark-no-duplicate-banner` and `landmark-unique`. The title and the close
* affordance live in the body instead, and each dialog is named by its own
* `aria-label` so the two remain distinguishable.
*/
const ElicitationModal = Modal.Root.withProps({
centered: true,
size: "lg",
closeOnClickOutside: false,
});
const ElicitationOverlay = Modal.Overlay.withProps({
backgroundOpacity: 0.55,
blur: 2,
});
/** Title row: the server's prompt plus the dismiss control. */
const TitleRow = Group.withProps({
justify: "space-between",
align: "flex-start",
wrap: "nowrap",
gap: "sm",
});
const TitleText = Text.withProps({
fw: 600,
size: "md",
});
/** Fixed-height frame the app renders into; apps size themselves within it. */
const FrameBox = Stack.withProps({
h: 360,
gap: 0,
});
const PromptText = Text.withProps({
size: "sm",
c: "dimmed",
});
function toError(err: unknown): Error {
return err instanceof Error ? err : new Error(String(err));
}
export interface AppElicitationHostProps {
/** Elicitations currently awaiting an app answer, oldest first. */
entries: AppElicitationEntry[];
/**
* The inspector's sandbox-proxy URL. Absent means the sandbox controller is
* not running, so nothing can be rendered — every entry falls back at once.
*/
sandboxPath?: string;
/** Builds the per-app bridge. Must advertise `hostCapabilities.elicitation`. */
bridgeFactory: BridgeFactory;
/** The app answered: hand the standard result back to the server. */
onSettle: (requestId: string, result: ElicitResult) => void;
/** The app could not answer: fall back to the native elicitation UI. */
onFail: (requestId: string, error: Error) => void;
}
/**
* Renders every pending app-rendered elicitation (#1854), one modal and one
* bridge per request.
*
* Keyed by `requestId` rather than by resource URI so that two concurrent
* requests — even for the SAME app — get distinct React subtrees, distinct
* iframes and distinct bridges. That is what makes the ownership request-scoped
* in practice, not just on paper.
*/
export function AppElicitationHost({
entries,
sandboxPath,
bridgeFactory,
onSettle,
onFail,
}: AppElicitationHostProps) {
// No sandbox → nothing can render. Fail every entry immediately rather than
// showing an empty modal the user cannot act on.
useEffect(() => {
if (sandboxPath) return;
for (const entry of entries) {
onFail(
entry.requestId,
new Error("MCP App sandbox is not available in this session"),
);
}
}, [entries, sandboxPath, onFail]);
if (!sandboxPath) return null;
return (
<>
{entries.map((entry, index) => (
<AppElicitationFrame
key={entry.requestId}
entry={entry}
// Only the last-rendered modal is on top, so only it may own the
// focus trap, the Escape key and the overlay — see the prop's doc.
isTop={index === entries.length - 1}
sandboxPath={sandboxPath}
bridgeFactory={bridgeFactory}
onSettle={onSettle}
onFail={onFail}
/>
))}
</>
);
}
interface AppElicitationFrameProps {
entry: AppElicitationEntry;
/**
* Whether this modal is the topmost of the open set.
*
* Concurrent elicitations mean several are open at once, and each would
* otherwise install its own focus trap, Escape handler and overlay — which
* fight over the keyboard and let one Escape dismiss more than one pending
* request. Only the top one owns those; the rest stay mounted (their apps
* keep their bridges and their handshakes) but inert to the keyboard.
*/
isTop: boolean;
sandboxPath: string;
bridgeFactory: BridgeFactory;
onSettle: (requestId: string, result: ElicitResult) => void;
onFail: (requestId: string, error: Error) => void;
}
/**
* One request: mount the app, and the moment its view reports `ready`, forward
* the original `elicitation/create` through THAT app's bridge.
*
* Every failure route ends in `onFail`, which is the fallback signal — an
* unreachable sandbox, a view that never handshakes, an app with no elicitation
* capability, a bridge error, or the user dismissing the modal.
*/
function AppElicitationFrame({
entry,
isTop,
sandboxPath,
bridgeFactory,
onSettle,
onFail,
}: AppElicitationFrameProps) {
const rendererRef = useRef<AppRendererHandle>(null);
// Guards the one-shot send: `ready` can fire again after a bridge rebuild,
// and a second `elicitation/create` for the same server request would be a
// duplicate the server never asked for.
const sentRef = useRef(false);
const [status, setStatus] = useState<AppRendererStatus>("loading");
const source = useMemo<AppRenderSource>(
() => ({
kind: "resource",
resourceUri: entry.resourceUri,
title: entry.params.message,
}),
[entry.resourceUri, entry.params.message],
);
const fail = useCallback(
(error: Error) => {
onFail(entry.requestId, error);
},
[entry.requestId, onFail],
);
// Dismissing is not an answer: the server is still waiting, so hand the
// request to the native elicitation UI rather than inventing a `cancel`.
const dismiss = useCallback(
() => fail(new Error("App-rendered elicitation dismissed")),
[fail],
);
// Initialization deadline. Cleared as soon as the request goes out, so it
// only ever bounds the handshake and never the user's answer.
useEffect(() => {
const timer = window.setTimeout(() => {
if (sentRef.current) return;
fail(new Error("MCP App did not initialize in time"));
}, APP_ELICITATION_INIT_TIMEOUT_MS);
return () => window.clearTimeout(timer);
}, [fail]);
const handleStatus = useCallback(
(next: AppRendererStatus) => {
setStatus(next);
if (next === "error") {
fail(new Error("MCP App failed to render"));
return;
}
if (next !== "ready" || sentRef.current) return;
sentRef.current = true;
const handle = rendererRef.current;
/* v8 ignore next 4 -- defensive: `ready` is dispatched from the renderer's
own bridge callback, so its imperative handle is always attached by the
time this runs. */
if (!handle) {
fail(new Error("MCP App renderer is unavailable"));
return;
}
handle
.requestElicitation(entry.params)
.then((result) => onSettle(entry.requestId, result))
.catch((err: unknown) => fail(toError(err)));
},
[entry.params, entry.requestId, fail, onSettle],
);
return (
<ElicitationModal
// `opened` stays at the call site: it is REQUIRED by `ModalRootProps`,
// and `.withProps()` supplies defaults without relaxing a required prop,
// so baking it in leaves every call site failing TS2741.
opened
onClose={dismiss}
trapFocus={isTop}
closeOnEscape={isTop}
>
{isTop && <ElicitationOverlay />}
{/* `aria-label` and the `data-*` attributes go on the CONTENT (the
role="dialog" element), not the Root — the Root is only a portal
wrapper, so a name placed there never reaches the dialog. Named per
request rather than per prompt or per app: two concurrent
elicitations can be for the SAME app URI with the SAME message, and
only the request id tells those two dialogs apart — for
`landmark-unique` and for anyone navigating by screen reader. */}
<Modal.Content
// A lower dialog is visually covered but would otherwise stay an
// exposed `aria-modal` dialog with reachable controls and an iframe —
// so browse navigation could land in a dialog that itself claims the
// rest of the page is inert. `inert` takes it out of the a11y tree and
// out of focus order WITHOUT unmounting, so its app keeps its bridge
// and its handshake and is live the moment it becomes top.
inert={!isTop}
aria-label={`Elicitation ${entry.requestId} — ${entry.params.message} — rendered by ${entry.resourceUri}`}
data-app-elicitation-status={status}
data-testid="app-elicitation"
>
<Modal.Body>
<Stack gap="xs">
<TitleRow>
<TitleText>{entry.params.message}</TitleText>
<CloseButton
onClick={dismiss}
aria-label="Close and use the built-in elicitation form"
/>
</TitleRow>
<PromptText>
Answering through the server-provided MCP App.
</PromptText>
{status === "error" ? (
<Alert color="red" title="App failed to render">
Falling back to the built-in elicitation form.
</Alert>
) : (
<FrameBox>
<AppRenderer
ref={rendererRef}
sandboxPath={sandboxPath}
source={source}
bridgeFactory={bridgeFactory}
onAppStatusChange={handleStatus}
onError={fail}
/>
</FrameBox>
)}
</Stack>
</Modal.Body>
</Modal.Content>
</ElicitationModal>
);
}

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,238 @@
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,
overrides: Record<string, unknown> = {},
): JSONRPCMessage {
return {
jsonrpc: "2.0",
id: 1,
method: "ui/initialize",
params: {
protocolVersion: "2026-01-26",
appInfo: { name: "test-app", version: "1.0.0" },
appCapabilities,
...overrides,
},
} 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 when the bridge advertises nothing at all", () => {
expect(appAdvertisesElicitation(makeBridge(undefined))).toBe(false);
});
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("ignores frames that are not shaped like ui/initialize at all", () => {
// The frame comes from sandboxed view code, so nothing about its shape is
// guaranteed — a non-object, or an initialize with no params, must not
// throw on the way through to the bridge's own handler.
const bridge = makeBridge({});
const { transport, inner } = makeTransport();
observeAppCapabilities(bridge, transport);
transport.onmessage?.("not-a-frame" as unknown as JSONRPCMessage);
transport.onmessage?.(null as unknown as JSONRPCMessage);
transport.onmessage?.({
jsonrpc: "2.0",
id: 1,
method: "ui/initialize",
} as unknown as JSONRPCMessage);
transport.onmessage?.({
jsonrpc: "2.0",
id: 2,
method: "ui/initialize",
params: null,
} as unknown as JSONRPCMessage);
expect(appAdvertisesElicitation(bridge)).toBe(false);
expect(inner).toHaveBeenCalledTimes(4);
});
it("ignores a malformed initialize the bridge would reject", () => {
// Fail-closed: accepting one would let a view flip `elicitation` on with a
// frame that never negotiated anything, and the host would then forward a
// request the bridge does not consider negotiated.
const bridge = makeBridge({});
const { transport } = makeTransport();
observeAppCapabilities(bridge, transport);
// No protocolVersion.
transport.onmessage?.({
jsonrpc: "2.0",
id: 1,
method: "ui/initialize",
params: { appInfo: { name: "a" }, appCapabilities: { elicitation: {} } },
} as unknown as JSONRPCMessage);
// No appInfo.
transport.onmessage?.({
jsonrpc: "2.0",
id: 2,
method: "ui/initialize",
params: {
protocolVersion: "2026-01-26",
appCapabilities: { elicitation: {} },
},
} as unknown as JSONRPCMessage);
// A notification, not the handshake request.
transport.onmessage?.({
jsonrpc: "2.0",
method: "ui/initialize",
params: {
protocolVersion: "2026-01-26",
appInfo: { name: "a" },
appCapabilities: { elicitation: {} },
},
} as unknown as JSONRPCMessage);
expect(appAdvertisesElicitation(bridge)).toBe(false);
});
it("takes the latest accepted handshake, in both directions", () => {
// Verified against ext-apps 1.7.5: a second `ui/initialize` is accepted —
// the bridge warns about the double-mount and the latest appInfo and
// capabilities REPLACE the previous ones. Freezing this at the first frame
// would leave the gate reporting capabilities the bridge no longer holds.
const bridge = makeBridge({});
const { transport } = makeTransport();
observeAppCapabilities(bridge, transport);
transport.onmessage?.(initializeFrame({ availableDisplayModes: [] }));
expect(appAdvertisesElicitation(bridge)).toBe(false);
transport.onmessage?.(initializeFrame({ elicitation: {} }));
expect(appAdvertisesElicitation(bridge)).toBe(true);
// …and a re-handshake that drops the capability turns it back off.
transport.onmessage?.(initializeFrame({}));
expect(appAdvertisesElicitation(bridge)).toBe(false);
});
it("leaves a recorded capability alone when a later frame is malformed", () => {
// A rejected frame is not a route to changing the gate — in either
// direction: it cannot set `elicitation`, and it cannot clear one the
// bridge still holds.
const bridge = makeBridge({});
const { transport } = makeTransport();
observeAppCapabilities(bridge, transport);
transport.onmessage?.(initializeFrame({ elicitation: {} }));
transport.onmessage?.({
jsonrpc: "2.0",
id: 9,
method: "ui/initialize",
params: { appCapabilities: {} },
} as unknown as JSONRPCMessage);
expect(appAdvertisesElicitation(bridge)).toBe(true);
});
it("rejects a frame the bridge's own schema rejects", () => {
// `appInfo: {}` is missing the implementation name/version that
// `McpUiInitializeRequestSchema` requires, so the bridge answers an error
// and stores nothing — this gate must not be the laxer of the two.
const bridge = makeBridge({});
const { transport } = makeTransport();
observeAppCapabilities(bridge, transport);
transport.onmessage?.({
jsonrpc: "2.0",
id: 1,
method: "ui/initialize",
params: {
protocolVersion: "2026-01-26",
appInfo: {},
appCapabilities: { elicitation: {} },
},
} as unknown as JSONRPCMessage);
expect(appAdvertisesElicitation(bridge)).toBe(false);
});
it("does not treat a non-object elicitation value as an advertisement", () => {
// The draft declares `elicitation` as an object; `true` is a value it does
// not define, and truthiness would accept it.
const bridge = makeBridge({});
const { transport } = makeTransport();
observeAppCapabilities(bridge, transport);
transport.onmessage?.(initializeFrame({ elicitation: true }));
expect(appAdvertisesElicitation(bridge)).toBe(false);
transport.onmessage?.(initializeFrame({ elicitation: [] }));
expect(appAdvertisesElicitation(bridge)).toBe(false);
transport.onmessage?.(initializeFrame({ elicitation: {} }));
expect(appAdvertisesElicitation(bridge)).toBe(true);
});
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,138 @@
import { McpUiInitializeRequestSchema } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
/**
* 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>>();
/**
* The only part of a transport this needs: the inbound-message callback.
*
* Structural, and generic over the message type, on purpose. ext-apps'
* `PostMessageTransport` implements the SDK *v1* `Transport` — a different
* nominal type from the v2 client's, though runtime-identical — so naming
* either would force a cast at the call site. Generic rather than
* `(message: unknown)` because a handler typed for a narrower message is not
* assignable to one typed for `unknown` (contravariance), which would put the
* cast back; `rest` is `never[]` for the same reason, accepting any trailing
* parameter list without claiming to know it. The message is only ever read
* through {@link recordAdvertisedCapabilities}, which narrows from `unknown`.
*/
export interface MessageObservable<TMessage = unknown> {
onmessage?: (message: TMessage, ...rest: never[]) => void;
}
/**
* The capabilities a view advertised in an `ui/initialize` the bridge will
* ACCEPT, or `undefined` for any other frame.
*
* Acceptance is decided by the bridge's own `McpUiInitializeRequestSchema`
* rather than a hand-rolled approximation of it — an approximation drifts, and
* anything it waves through that the bridge rejects becomes a second, laxer
* route to setting `elicitation` on a frame that negotiated nothing. The one
* check the schema cannot make is that this is a request at all: a JSON-RPC
* notification carries the same `method`/`params`, so the id is checked here.
*
* The capabilities are then read from the ORIGINAL frame, not the parse output,
* because that schema is exactly what strips `elicitation` (see the WeakMap's
* doc comment) — validating with it and reading through it would defeat the
* purpose of this module.
*/
function acceptedInitializeCapabilities(
message: unknown,
): Record<string, unknown> | undefined {
if (typeof message !== "object" || message === null) return undefined;
const frame = message as { id?: unknown; params?: unknown };
// A notification (no id) is not the handshake request.
if (frame.id === undefined || frame.id === null) return undefined;
if (!McpUiInitializeRequestSchema.safeParse(message).success) {
return undefined;
}
const advertised = (frame.params as { appCapabilities?: unknown })
.appCapabilities;
return typeof advertised === "object" && advertised !== null
? (advertised as Record<string, unknown>)
: undefined;
}
/**
* Record `params.appCapabilities` from the view's handshake.
*
* Every *accepted* `ui/initialize` replaces the recorded value, because that is
* what the bridge does — on a second handshake (a view double-mounting under
* React StrictMode, or reconnecting) it warns and takes the latest appInfo and
* capabilities. Freezing this at the first frame would leave the gate reporting
* capabilities the bridge no longer holds, in both directions.
*
* A frame the bridge would *reject* records nothing and leaves the previous
* value alone: it is a route to changing this gate in neither direction.
*/
function recordAdvertisedCapabilities(
bridge: AppBridge,
message: unknown,
): void {
const advertised = acceptedInitializeCapabilities(message);
if (advertised) rawAppCapabilities.set(bridge, advertised);
}
/**
* 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<TMessage>(
bridge: AppBridge,
transport: MessageObservable<TMessage>,
): void {
const inner = transport.onmessage?.bind(transport);
transport.onmessage = (message: TMessage, ...rest: never[]) => {
recordAdvertisedCapabilities(bridge, message);
inner?.(message, ...rest);
};
}
/**
* 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;
return (
isCapabilityObject(parsed?.elicitation) ||
isCapabilityObject(rawAppCapabilities.get(bridge)?.elicitation)
);
}
/**
* A declared capability is an OBJECT (`{}` today, room for sub-options later),
* which is what ext-apps#733 specifies. Truthiness is not the same test: an
* `elicitation: true` is a value the draft does not define, and treating it as
* an advertisement would accept something the bridge's own schema will reject
* the moment it carries the key.
*/
function isCapabilityObject(value: unknown): boolean {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

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);
@ -156,6 +158,33 @@ describe("createAppBridgeFactory", () => {
}
});
it("does not advertise hostCapabilities.elicitation by default (#1854)", async () => {
// An App-tool frame is never handed an elicitation, so claiming the host
// capability there would tell those apps something untrue about the host.
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource: vi.fn(),
});
await factory(makeIframe(), { kind: "tool", tool });
expect(bridgeInstances[0].ctorArgs[2]).not.toHaveProperty("elicitation");
});
it("advertises hostCapabilities.elicitation when opted in (#1854)", async () => {
// The value reaches the AppBridge CONSTRUCTOR, which is what the bridge
// echoes in its `ui/initialize` response — an app reads it there to decide
// whether this host will forward an elicitation to it at all.
const factory = createAppBridgeFactory({
advertiseElicitation: true,
getClient: () => fakeClient,
readResource: vi.fn(),
});
await factory(makeIframe(), {
kind: "resource",
resourceUri: "ui://demo/pick.html",
});
expect(bridgeInstances[0].ctorArgs[2]).toMatchObject({ elicitation: {} });
});
it("on sandboxready, reads the UI resource, wraps the html with the per-app CSP, and echoes the approved sandbox config", async () => {
const readResource = vi.fn().mockResolvedValue(
uiResource("<h1>weather</h1>", {
@ -167,7 +196,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 +244,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 +264,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 +285,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 +303,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 +324,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 +346,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 +364,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 +381,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 +405,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 +423,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

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