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
23 changed files with 4824 additions and 139 deletions

View file

@ -85,6 +85,15 @@ The Apps screen exposes a small, stable set of `data-testid` / `data-*` attribut
| `data-testid="apps-messages"` | messages panel | `ui/message` submissions from the running view. |
| `data-testid="apps-logs"` | app-logs panel | `notifications/message` log entries (default-expanded). |
App-rendered **elicitations** (#1854) render through the same `AppRenderer` but outside the Apps screen — one modal per request, from `AppElicitationHost` — and carry their own pair:
| Attribute | Where | Meaning |
| --- | --- | --- |
| `data-testid="app-elicitation"` | the elicitation modal | One per in-flight app-rendered elicitation. **Absent** means the request was answered by the native elicitation form instead, which is what a driver asserts to prove the negotiation gate held. |
| `data-app-elicitation-status` | on `app-elicitation` | The same `AppRendererStatus` for that modal's app. `ready` is when the host forwards the `elicitation/create` through its bridge. |
`scripts/smoke-web-elicitation.mjs` drives both halves against the public fixture (`test-servers/configs/app-elicitation-http.json` and its `-native-` sibling).
The renderer lifecycle itself is `AppRendererStatus` (`loading` | `ready` | `error`) reported via `AppRenderer`'s `onAppStatusChange`; the screen maps it to `data-app-status`. Resource-read failures (malformed/404 UI resource) are surfaced as a toast via the bridge factory's `onResourceError`; because the app never reaches `ready` in that case, a driver times out on `data-app-status` and reads the toast.
## Deep-link auto-connect

View file

@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import {
Anchor,
Box,
@ -135,6 +142,11 @@ import {
import { clearScrollMemory } from "./hooks/useScrollMemory";
import type { AppRendererHandle } from "./components/elements/AppRenderer/AppRenderer";
import { createAppBridgeFactory } from "./components/elements/AppRenderer/createAppBridgeFactory";
import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost";
import {
AppElicitationController,
type AppElicitationSession,
} from "./lib/appElicitationController";
import type { LogEntryData } from "./components/elements/LogEntry/LogEntry";
import {
ServerConfigModal,
@ -744,6 +756,7 @@ function App() {
version: inspectorVersion,
secretStorage,
refresh: refreshInitialConfig,
loading: initialConfigLoading,
} = useInitialConfig({
baseUrl: configBaseUrl,
authToken: getAuthToken(),
@ -795,6 +808,101 @@ function App() {
[inspectorClient],
);
// App-rendered form elicitations (#1854). The controller is created once and
// handed to every InspectorClient at construction — its `render` is what opts
// this client into advertising the nested MCP Apps `elicitation` capability,
// which is why only the web client (the one with a sandbox) claims it.
const appElicitationControllerRef = useRef<AppElicitationController>(null);
appElicitationControllerRef.current ??= new AppElicitationController();
const appElicitationController = appElicitationControllerRef.current;
// The window onto the controller for the CURRENT client. Closing it when the
// client is replaced both rejects that connection's queued requests and
// refuses any it enqueues during its own (asynchronous) teardown — a late
// entry would otherwise be rendered by a factory bound to the replacement
// client, i.e. read and answered through a different server.
const appElicitationSessionRef = useRef<AppElicitationSession>(null);
// `setupClientForServer` is synchronous and memoized, so a caller that
// awaited the config would still resume with the `sandboxUrl` captured by the
// render it STARTED in — undefined, on the very load this matters for. The
// ref is written every render, so client construction reads the current value
// whichever entry point (connect, deep link, OAuth callback) reached it.
const sandboxUrlRef = useRef<string | undefined>(undefined);
sandboxUrlRef.current = sandboxUrl;
// Whether the sandbox exists is only known once `/api/config` resolves, and
// the answer is baked into the client at construction (it decides whether the
// nested MCP Apps `elicitation` capability is advertised). So a connect waits
// for it rather than guessing: guessing "available" over-claims a capability
// we may not have, and guessing "unavailable" strands the whole session on
// the native form despite having a sandbox. The wait is a local fetch already
// in flight since mount.
const initialConfigSettledRef = useRef<{
promise: Promise<void>;
resolve: () => void;
}>(null);
initialConfigSettledRef.current ??= (() => {
let resolve!: () => void;
const promise = new Promise<void>((r) => (resolve = r));
return { promise, resolve };
})();
useEffect(() => {
if (!initialConfigLoading) initialConfigSettledRef.current?.resolve();
}, [initialConfigLoading]);
const appElicitations = useSyncExternalStore(
appElicitationController.subscribe,
appElicitationController.getEntries,
);
// A SECOND factory, differing from `sandboxBridgeFactory` only in that it
// advertises `hostCapabilities.elicitation`. An App-tool frame is never handed
// an elicitation, so telling those apps otherwise would be a false claim.
const elicitationBridgeFactory = useMemo(
() =>
createAppBridgeFactory({
advertiseElicitation: true,
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
readResource: async (uri) => {
if (!inspectorClient) throw new Error("No MCP client connected.");
const invocation = await inspectorClient.readResource(uri);
return invocation.result;
},
// Unlike the Apps tab there is no persistent surface to show the
// failure on — the modal is about to be replaced by the native form —
// so the toast is the only place the user learns why.
onResourceError: (err) => {
notifications.show({
title: "Elicitation app failed to load",
message: err.message,
color: "red",
});
},
}),
[inspectorClient],
);
/**
* Close the previous client's session and open one for the client being
* constructed. Synchronous, and called at construction, so the swap itself is
* the moment ownership changes hands.
*/
const newAppElicitationSession = useCallback(() => {
appElicitationSessionRef.current?.close(
new Error("Connection replaced before the app answered"),
);
const session = appElicitationController.openSession();
appElicitationSessionRef.current = session;
return session;
}, [appElicitationController]);
const handleAppElicitationSettle = useCallback(
(requestId: string, result: ElicitResult) => {
appElicitationController.settle(requestId, result);
},
[appElicitationController],
);
const handleAppElicitationFail = useCallback(
(requestId: string, error: Error) => {
appElicitationController.fail(requestId, error);
},
[appElicitationController],
);
const [managedToolsState, setManagedToolsState] =
useState<ManagedToolsState | null>(null);
const [managedPromptsState, setManagedPromptsState] =
@ -2413,6 +2521,16 @@ function App() {
// Sampling / elicitation are on by default; keep the parameterized
// options off until the UI grows the surface to render them.
elicit: { form: true, url: true },
// Web only, and only when the sandbox renderer is actually available:
// supplying this advertises the nested MCP Apps `elicitation`
// capability, and a client that cannot host an app must not claim it
// (#1854). Callers await `initialConfigSettled` first, so `sandboxUrl`
// here means "confirmed absent" rather than "not known yet" — a
// connection that reaches this with no sandbox behaves like the
// CLI/TUI: native elicitation queue, no claim made to the server.
...(sandboxUrlRef.current && {
appElicitation: newAppElicitationSession().render,
}),
// Always advertise the roots capability (even with no configured
// roots) so the server can issue roots/list and receive
// roots/list_changed; the configured roots are the answer to
@ -2514,6 +2632,7 @@ function App() {
sessionStorageAdapter,
onBeforeOAuthRedirect,
clientConfig,
newAppElicitationSession,
],
);
@ -2633,6 +2752,9 @@ function App() {
}
void (async () => {
// Same reason as the connect path: whether this client may advertise
// app-rendered elicitation is fixed at construction.
await initialConfigSettledRef.current?.promise;
try {
await webOAuthStorage.load();
} catch (err) {
@ -2733,6 +2855,12 @@ function App() {
const onToggleConnection = useCallback(
async (id: string) => {
// Whether this client may advertise app-rendered elicitation is decided
// at construction and cannot be revised afterwards, so wait for the fact
// rather than guess it (see `initialConfigSettledRef`). Already resolved
// by the time any human clicks; this only orders a deep-link auto-connect
// that races the same page load.
await initialConfigSettledRef.current?.promise;
// Same server, already connected → disconnect.
if (
id === activeServerId &&
@ -4596,6 +4724,13 @@ function App() {
onRefreshApps={onRefreshTools}
/>
</Box>
<AppElicitationHost
entries={appElicitations}
sandboxPath={sandboxUrl}
bridgeFactory={elicitationBridgeFactory}
onSettle={handleAppElicitationSettle}
onFail={handleAppElicitationFail}
/>
<ServerConfigModal
opened={configModal !== null}
mode={configModal?.mode ?? "add"}

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

View file

@ -662,7 +662,7 @@ export function AppsScreen({
<AppRenderer
key={selectedTool.name}
sandboxPath={sandboxPath}
tool={selectedTool}
source={{ kind: "tool", tool: selectedTool }}
bridgeFactory={bridgeFactory}
onError={handleAppError}
onAppStatusChange={setAppStatus}

View file

@ -0,0 +1,197 @@
import { describe, it, expect, vi } from "vitest";
import type { ElicitRequest } from "@modelcontextprotocol/client";
import {
AppElicitationController,
type AppElicitationSession,
} from "./appElicitationController";
const params: ElicitRequest["params"] = {
message: "Choose an option",
requestedSchema: { type: "object", properties: {} },
};
/**
* Queue a request through a session. Defaults to one session per controller,
* which is what a single connection looks like; the session tests open their
* own to model a client swap.
*/
function request(
controller: AppElicitationController,
requestId: string,
resourceUri = "ui://demo/pick.html",
signal: AbortSignal = new AbortController().signal,
session: AppElicitationSession = defaultSession(controller),
) {
return session.render({ requestId, resourceUri, params, signal });
}
const sessions = new WeakMap<AppElicitationController, AppElicitationSession>();
function defaultSession(
controller: AppElicitationController,
): AppElicitationSession {
let session = sessions.get(controller);
if (!session) {
session = controller.openSession();
sessions.set(controller, session);
}
return session;
}
describe("AppElicitationController (#1854)", () => {
it("queues a request and notifies subscribers", () => {
const controller = new AppElicitationController();
const listener = vi.fn();
controller.subscribe(listener);
void request(controller, "a").catch(() => {});
expect(listener).toHaveBeenCalledTimes(1);
expect(controller.getEntries()).toHaveLength(1);
expect(controller.getEntries()[0]).toMatchObject({
requestId: "a",
resourceUri: "ui://demo/pick.html",
});
});
it("keeps the entries array identity stable between changes", () => {
// `useSyncExternalStore` re-renders on every getSnapshot identity change,
// so a fresh array per read would loop forever.
const controller = new AppElicitationController();
expect(controller.getEntries()).toBe(controller.getEntries());
});
it("settle resolves the render promise and drops the entry", async () => {
const controller = new AppElicitationController();
const pending = request(controller, "a");
controller.settle("a", { action: "accept", content: { choice: "x" } });
await expect(pending).resolves.toEqual({
action: "accept",
content: { choice: "x" },
});
expect(controller.getEntries()).toHaveLength(0);
});
it("fail rejects the render promise so the client falls back", async () => {
const controller = new AppElicitationController();
const pending = request(controller, "a");
controller.fail("a", new Error("sandbox unavailable"));
await expect(pending).rejects.toThrow(/sandbox unavailable/);
expect(controller.getEntries()).toHaveLength(0);
});
it("settling an unknown or already-settled id is a no-op", async () => {
const controller = new AppElicitationController();
const pending = request(controller, "a");
controller.settle("a", { action: "cancel" });
await expect(pending).resolves.toEqual({ action: "cancel" });
// A second settle must not throw — the modal's unmount and the client's
// abort can race, and both call in.
expect(() => controller.settle("a", { action: "decline" })).not.toThrow();
expect(() => controller.fail("nope", new Error("x"))).not.toThrow();
});
it("keeps concurrent requests independent", async () => {
const controller = new AppElicitationController();
const first = request(controller, "a", "ui://demo/first.html");
const second = request(controller, "b", "ui://demo/second.html");
expect(controller.getEntries().map((e) => e.requestId)).toEqual(["a", "b"]);
controller.settle("b", { action: "accept", content: { choice: "b" } });
expect(controller.getEntries().map((e) => e.requestId)).toEqual(["a"]);
await expect(second).resolves.toMatchObject({ content: { choice: "b" } });
controller.settle("a", { action: "accept", content: { choice: "a" } });
await expect(first).resolves.toMatchObject({ content: { choice: "a" } });
});
it("drops and rejects an entry when the originating request aborts", async () => {
const controller = new AppElicitationController();
const aborter = new AbortController();
const pending = request(
controller,
"a",
"ui://demo/pick.html",
aborter.signal,
);
expect(controller.getEntries()).toHaveLength(1);
aborter.abort();
await expect(pending).rejects.toThrow(/aborted/);
expect(controller.getEntries()).toHaveLength(0);
});
it("never queues a request whose signal already aborted", async () => {
const controller = new AppElicitationController();
const aborter = new AbortController();
aborter.abort();
await expect(
request(controller, "a", "ui://demo/pick.html", aborter.signal),
).rejects.toThrow(/aborted/);
expect(controller.getEntries()).toHaveLength(0);
});
it("closing a session drops the entries that session queued", async () => {
// The host closes the old session when the InspectorClient is replaced: the
// bridge factory resolves its client at call time, so an entry left queued
// could otherwise rebuild against the NEXT connection and answer through a
// different server.
const controller = new AppElicitationController();
const session = controller.openSession();
const listener = vi.fn();
const first = request(controller, "a", "ui://a", undefined, session);
const second = request(controller, "b", "ui://b", undefined, session);
controller.subscribe(listener);
session.close(new Error("connection replaced"));
await expect(first).rejects.toThrow(/connection replaced/);
await expect(second).rejects.toThrow(/connection replaced/);
expect(controller.getEntries()).toHaveLength(0);
expect(listener).toHaveBeenCalledTimes(1);
});
it("refuses a request a closed session makes during its own teardown", async () => {
// The half a one-shot sweep cannot provide: the replaced client
// disconnects asynchronously and can still enqueue, and that entry would be
// rendered by a factory bound to the REPLACEMENT client.
const controller = new AppElicitationController();
const session = controller.openSession();
session.close(new Error("connection replaced"));
await expect(
request(controller, "late", "ui://a", undefined, session),
).rejects.toThrow(/session is closed/);
expect(controller.getEntries()).toHaveLength(0);
});
it("leaves another session's entries alone", async () => {
const controller = new AppElicitationController();
const oldSession = controller.openSession();
const newSession = controller.openSession();
const stale = request(controller, "a", "ui://a", undefined, oldSession);
const live = request(controller, "b", "ui://b", undefined, newSession);
oldSession.close(new Error("connection replaced"));
await expect(stale).rejects.toThrow(/connection replaced/);
expect(controller.getEntries().map((e) => e.requestId)).toEqual(["b"]);
controller.settle("b", { action: "cancel" });
await expect(live).resolves.toEqual({ action: "cancel" });
});
it("closing a session with nothing queued notifies nobody", () => {
const controller = new AppElicitationController();
const session = controller.openSession();
const listener = vi.fn();
controller.subscribe(listener);
session.close(new Error("nothing to drop"));
expect(listener).not.toHaveBeenCalled();
});
it("stops notifying an unsubscribed listener", () => {
const controller = new AppElicitationController();
const listener = vi.fn();
const unsubscribe = controller.subscribe(listener);
unsubscribe();
void request(controller, "a").catch(() => {});
expect(listener).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,157 @@
import type { ElicitResult } from "@modelcontextprotocol/client";
import type {
AppElicitationRenderer,
AppElicitationRequest,
} from "@inspector/core/mcp/appElicitation.js";
/**
* One app-rendered elicitation awaiting an answer, plus the settle functions
* for the `InspectorClient` promise it belongs to (#1854).
*
* The entry — not a "currently active app" — is what owns the renderer, so two
* concurrent elicitations each drive their own iframe and bridge and cannot
* resolve through each other's.
*/
export interface AppElicitationEntry extends AppElicitationRequest {
/** The session (one InspectorClient) this request belongs to. */
sessionId: number;
/** Hands the app's standard `ElicitResult` back to the server. */
resolve: (result: ElicitResult) => void;
/** Asks `InspectorClient` to fall back to the native elicitation UI. */
reject: (error: Error) => void;
}
/**
* One client's window onto the controller.
*
* Requests are bound to the connection that made them. A closed session
* rejects immediately, which is what a one-shot sweep cannot do: an
* `InspectorClient` being replaced disconnects asynchronously and can still
* enqueue during its own teardown, and that late entry would otherwise be
* rendered by a factory bound to the *replacement* client — reading its
* resource, and answering, through a different server.
*/
export interface AppElicitationSession {
/** The renderer handed to this client's `InspectorClient`. */
render: AppElicitationRenderer;
/** Reject everything from this session and refuse anything later. */
close: (error: Error) => void;
}
/**
* Bridges `InspectorClient`'s renderer callback — supplied at construction,
* long before any React tree exists — to the React component that actually
* mounts the app.
*
* The client is given {@link render} once and for all; the UI subscribes and
* re-renders as entries come and go. Without this indirection the renderer
* would have to be rebuilt (and the client reconstructed) whenever the host
* component remounted.
*/
export class AppElicitationController {
private entries: AppElicitationEntry[] = [];
private listeners = new Set<() => void>();
private nextSessionId = 0;
/** Sessions still accepting requests. A closed one is removed. */
private openSessions = new Set<number>();
/** Current queue. Stable identity between changes, for `useSyncExternalStore`. */
getEntries = (): AppElicitationEntry[] => this.entries;
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
private emit(): void {
for (const listener of this.listeners) listener();
}
/**
* Open a window for one `InspectorClient`. Its `render` is what that client
* is constructed with; `close` ends it when the client is replaced.
*/
openSession(): AppElicitationSession {
const sessionId = this.nextSessionId++;
this.openSessions.add(sessionId);
return {
render: (request) => this.render(sessionId, request),
close: (error) => this.closeSession(sessionId, error),
};
}
/**
* Queue a request for the UI and resolve when {@link settle} or {@link fail}
* is called for it.
*
* An abort of the originating request (cancelled tool call, disconnect) drops
* the entry and rejects, so a modal can never outlive the request behind it.
* A request from a closed session is rejected without ever being queued.
*/
private render = (sessionId: number, request: AppElicitationRequest) =>
new Promise<ElicitResult>((resolve, reject) => {
if (!this.openSessions.has(sessionId)) {
reject(new Error("App elicitation session is closed"));
return;
}
const entry: AppElicitationEntry = {
...request,
sessionId,
resolve,
reject,
};
const onAbort = () => {
this.remove(entry.requestId);
reject(new Error("App-rendered elicitation aborted"));
};
if (request.signal.aborted) {
onAbort();
return;
}
request.signal.addEventListener("abort", onAbort, { once: true });
this.entries = [...this.entries, entry];
this.emit();
});
private take(requestId: string): AppElicitationEntry | undefined {
const entry = this.entries.find((e) => e.requestId === requestId);
if (entry) this.remove(requestId);
return entry;
}
private remove(requestId: string): void {
const next = this.entries.filter((e) => e.requestId !== requestId);
if (next.length === this.entries.length) return;
this.entries = next;
this.emit();
}
/**
* Complete an elicitation with the app's result. `decline` and `cancel` are
* completions too — they are returned to the server, not fallen back on.
*/
settle(requestId: string, result: ElicitResult): void {
this.take(requestId)?.resolve(result);
}
/** Give up on the app and let the native elicitation UI take the request. */
fail(requestId: string, error: Error): void {
this.take(requestId)?.reject(error);
}
/**
* End a session: reject everything it queued, and refuse anything it queues
* later. The refusal is the half a one-shot sweep cannot provide — see
* {@link AppElicitationSession}.
*/
private closeSession(sessionId: number, error: Error): void {
this.openSessions.delete(sessionId);
const dropped = this.entries.filter((e) => e.sessionId === sessionId);
if (dropped.length === 0) return;
this.entries = this.entries.filter((e) => e.sessionId !== sessionId);
this.emit();
for (const entry of dropped) entry.reject(error);
}
}

View file

@ -0,0 +1,286 @@
import { describe, it, expect } from "vitest";
import type {
ClientCapabilities,
ElicitRequest,
ElicitResult,
ServerCapabilities,
} from "@modelcontextprotocol/client";
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv";
import {
getElicitationUiResourceUri,
getUiClientCapability,
getUiServerCapability,
isFormElicitation,
supportsAppElicitation,
validateAppElicitResult,
} from "@inspector/core/mcp/appElicitation.js";
import {
MCP_APP_MIME_TYPE,
UI_EXTENSION_KEY,
} from "@inspector/core/mcp/extensions.js";
/** A client that satisfies all three client-side negotiation gates. */
function eligibleClient(): ClientCapabilities {
return {
elicitation: { form: {} },
extensions: {
[UI_EXTENSION_KEY]: {
mimeTypes: [MCP_APP_MIME_TYPE],
elicitation: {},
},
},
};
}
/** A server that advertises the nested MCP Apps elicitation setting. */
function eligibleServer(): ServerCapabilities {
return { extensions: { [UI_EXTENSION_KEY]: { elicitation: {} } } };
}
function formParams(
extra: Partial<ElicitRequest["params"]> = {},
): ElicitRequest["params"] {
return {
message: "Choose an option",
requestedSchema: {
type: "object",
properties: { choice: { type: "string" } },
required: ["choice"],
},
...extra,
} as ElicitRequest["params"];
}
describe("appElicitation negotiation (#1854)", () => {
describe("capability readers", () => {
it("reads the UI block from either side", () => {
expect(getUiClientCapability(eligibleClient())).toEqual({
mimeTypes: [MCP_APP_MIME_TYPE],
elicitation: {},
});
expect(getUiServerCapability(eligibleServer())).toEqual({
elicitation: {},
});
});
it("returns undefined for absent, null and non-object capabilities", () => {
expect(getUiClientCapability(undefined)).toBeUndefined();
expect(getUiClientCapability(null)).toBeUndefined();
expect(getUiClientCapability({})).toBeUndefined();
expect(getUiServerCapability({ extensions: {} })).toBeUndefined();
expect(
getUiClientCapability({
extensions: { [UI_EXTENSION_KEY]: null },
} as unknown as ClientCapabilities),
).toBeUndefined();
});
});
describe("supportsAppElicitation", () => {
it("is true only when all four gates pass", () => {
expect(supportsAppElicitation(eligibleClient(), eligibleServer())).toBe(
true,
);
});
it("is false without the core form elicitation capability", () => {
const client = eligibleClient();
delete client.elicitation;
expect(supportsAppElicitation(client, eligibleServer())).toBe(false);
});
it("is false when the client advertised only url-mode elicitation", () => {
const client = { ...eligibleClient(), elicitation: { url: {} } };
expect(supportsAppElicitation(client, eligibleServer())).toBe(false);
});
it("is false when the client does not accept the MCP App MIME type", () => {
const client: ClientCapabilities = {
elicitation: { form: {} },
extensions: {
[UI_EXTENSION_KEY]: { mimeTypes: ["text/html"], elicitation: {} },
},
};
expect(supportsAppElicitation(client, eligibleServer())).toBe(false);
});
it("is false when the client advertises the MIME type but not elicitation", () => {
// The specific "MIME type alone is not sufficient" case: this is exactly
// what a CLI/TUI client looks like, and it must not be offered an app.
const client: ClientCapabilities = {
elicitation: { form: {} },
extensions: {
[UI_EXTENSION_KEY]: { mimeTypes: [MCP_APP_MIME_TYPE] },
},
};
expect(supportsAppElicitation(client, eligibleServer())).toBe(false);
});
it("is false when the server did not advertise it", () => {
expect(supportsAppElicitation(eligibleClient(), {})).toBe(false);
expect(
supportsAppElicitation(eligibleClient(), {
extensions: { [UI_EXTENSION_KEY]: {} },
}),
).toBe(false);
expect(supportsAppElicitation(eligibleClient(), undefined)).toBe(false);
});
});
describe("getElicitationUiResourceUri", () => {
it("returns the URI from _meta.ui.resourceUri", () => {
expect(
getElicitationUiResourceUri(
formParams({ _meta: { ui: { resourceUri: "ui://demo/pick.html" } } }),
),
).toBe("ui://demo/pick.html");
});
it("returns undefined when no app is attached", () => {
expect(getElicitationUiResourceUri(formParams())).toBeUndefined();
expect(
getElicitationUiResourceUri(formParams({ _meta: {} })),
).toBeUndefined();
expect(
getElicitationUiResourceUri(formParams({ _meta: { ui: "nope" } })),
).toBeUndefined();
expect(
getElicitationUiResourceUri(formParams({ _meta: { ui: null } })),
).toBeUndefined();
expect(
getElicitationUiResourceUri(formParams({ _meta: { ui: {} } })),
).toBeUndefined();
});
it("throws on a non-string resourceUri", () => {
expect(() =>
getElicitationUiResourceUri(
formParams({ _meta: { ui: { resourceUri: 42 } } }),
),
).toThrow(/must be a string/);
});
it.each([
["relative", "demo/pick.html"],
["wrong scheme", "https://example.com/pick.html"],
["scheme with no host", "ui:///pick.html"],
["bare scheme", "ui:pick.html"],
["not a URL at all", " "],
])("throws on a %s URI", (_label, uri) => {
expect(() =>
getElicitationUiResourceUri(
formParams({ _meta: { ui: { resourceUri: uri } } }),
),
).toThrow(/absolute ui:\/\/ URI/);
});
});
describe("isFormElicitation", () => {
it("treats an omitted mode as form", () => {
expect(isFormElicitation(formParams())).toBe(true);
expect(isFormElicitation(formParams({ mode: "form" }))).toBe(true);
});
it("rejects url mode", () => {
expect(isFormElicitation(formParams({ mode: "url" }))).toBe(false);
});
});
describe("validateAppElicitResult", () => {
const provider = new AjvJsonSchemaValidator();
const params = formParams();
it("accepts a well-formed accept", () => {
expect(
validateAppElicitResult(provider, params, {
action: "accept",
content: { choice: "option-a" },
}),
).toBeUndefined();
});
it("accepts decline and cancel with no content", () => {
expect(
validateAppElicitResult(provider, params, { action: "decline" }),
).toBeUndefined();
expect(
validateAppElicitResult(provider, params, { action: "cancel" }),
).toBeUndefined();
});
it("rejects a non-object result", () => {
expect(
validateAppElicitResult(
provider,
params,
null as unknown as ElicitResult,
),
).toMatch(/invalid elicitation result/);
});
it("rejects an unknown action", () => {
expect(
validateAppElicitResult(provider, params, {
action: "maybe",
} as unknown as ElicitResult),
).toMatch(/invalid elicitation result/);
});
it("rejects a decline whose content the standard result forbids", () => {
// Checking `action` alone would wave this through: `ElicitResult` permits
// only primitives and string arrays in `content`, so a nested object
// would reach the server as a result it can legitimately reject.
expect(
validateAppElicitResult(provider, params, {
action: "decline",
content: { x: {} },
} as unknown as ElicitResult),
).toMatch(/invalid elicitation result/);
});
it("rejects an accept with no usable content", () => {
expect(
validateAppElicitResult(provider, params, {
action: "accept",
} as ElicitResult),
).toMatch(/without a content object/);
expect(
validateAppElicitResult(provider, params, {
action: "accept",
content: [] as unknown as Record<string, never>,
} as ElicitResult),
).toMatch(/invalid elicitation result|without a content object/);
});
it("rejects content that does not match the requested schema", () => {
expect(
validateAppElicitResult(provider, params, {
action: "accept",
content: { choice: 7 } as unknown as Record<string, never>,
} as ElicitResult),
).toMatch(/does not match the requested schema/);
});
it("skips validation when the request declared no usable schema", () => {
const noSchema = { message: "hi" } as ElicitRequest["params"];
expect(
validateAppElicitResult(provider, noSchema, {
action: "accept",
content: { anything: true },
}),
).toBeUndefined();
});
it("does not reject a result over a schema the validator cannot compile", () => {
const badSchema = formParams({
requestedSchema: { type: "object", properties: { a: { type: 9 } } },
} as unknown as Partial<ElicitRequest["params"]>);
expect(
validateAppElicitResult(provider, badSchema, {
action: "accept",
content: { a: 1 },
}),
).toBeUndefined();
});
});
});

Some files were not shown because too many files have changed in this diff Show more