fix(apps): address review of app-rendered elicitations

Eight findings from the Copilot review, all accepted.

Ownership across connections was the substantive one, in three parts. A
mid-session transport close reaches teardown only through
`clearAndAnnouncePendingPeerRequests`, whose emptiness check looked at the
native queues alone — so a lone app elicitation survived a dropped connection
with its modal still open; the check now counts the active set too. The web
bridge factory resolves its client at call time, so an entry queued by a
replaced `InspectorClient` could rebuild against the *next* one and answer
through a different server; the host now drops every entry synchronously on
the swap (`AppElicitationController.failAll`) rather than racing core's
awaited teardown. And the request id was only monotonic within one client, so
each replacement client's first request was `app-elicitation-1` — settling
that id could resolve the wrong server's request. It now carries a
per-instance prefix.

Result validation now parses the whole value with `ElicitResultSchema` before
the requested-schema check, instead of inspecting `action` by hand: a
`{ action: "decline", content: { x: {} } }` used to pass, though the standard
result permits only primitives and string arrays in `content`.

The dialog's accessible name now includes the request id and the prompt — two
concurrent elicitations can be for the same app URI, and the previous name
made those indistinguishable, which is what the comment beside it claimed to
prevent.

`observeAppCapabilities` takes a minimal structural `onmessage` contract
(generic over the message type, since a handler typed for a narrower message
is not assignable to one typed for `unknown`), removing the double cast at the
call site.

`smoke:web:elicit` now fails on an uncaught page error after a successful
drive, and captures the async half of that class off the console channel, as
its two sibling smokes do — it could otherwise print OK over a broken bundle.

Adds the tests each fix needs, including the two the review asked for
directly: that `advertiseElicitation` reaches the `AppBridge` constructor, and
that it is absent by default.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 19:43:44 -04:00
commit 7c77c2b15f
5 changed files with 286 additions and 38 deletions

View file

@ -226,11 +226,12 @@ function AppElicitationFrame({
{/* `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: two concurrent elicitations can carry
the same message, and two identically-named dialogs are what
`landmark-unique` rejects. */}
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
aria-label={`Elicitation rendered by ${entry.resourceUri}`}
aria-label={`Elicitation ${entry.requestId} — ${entry.params.message} — rendered by ${entry.resourceUri}`}
data-app-elicitation-status={status}
data-testid="app-elicitation"
>

View file

@ -70,6 +70,30 @@ describe("appCapabilities (#1854)", () => {
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("keeps bridges independent", () => {
const a = makeBridge({});
const b = makeBridge({});

View file

@ -1,5 +1,4 @@
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { Transport } from "@modelcontextprotocol/client";
/**
* The app capabilities exactly as the view sent them, per bridge.
@ -22,10 +21,41 @@ import type { Transport } from "@modelcontextprotocol/client";
*/
const rawAppCapabilities = new WeakMap<AppBridge, Record<string, unknown>>();
/** Shape of the `ui/initialize` frame this reads — nothing else is touched. */
interface UiInitializeFrame {
method?: unknown;
params?: { appCapabilities?: unknown };
/**
* 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;
}
/**
* Record `params.appCapabilities` if `message` is a `ui/initialize` frame.
* Narrows from `unknown` rather than asserting a shape: the frame is whatever
* the sandboxed view posted.
*/
function recordAdvertisedCapabilities(
bridge: AppBridge,
message: unknown,
): void {
if (typeof message !== "object" || message === null) return;
const frame = message as { method?: unknown; params?: unknown };
if (frame.method !== "ui/initialize") return;
if (typeof frame.params !== "object" || frame.params === null) return;
const advertised = (frame.params as { appCapabilities?: unknown })
.appCapabilities;
if (typeof advertised === "object" && advertised !== null) {
rawAppCapabilities.set(bridge, advertised as Record<string, unknown>);
}
}
/**
@ -36,20 +66,14 @@ interface UiInitializeFrame {
* 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(
export function observeAppCapabilities<TMessage>(
bridge: AppBridge,
transport: Transport,
transport: MessageObservable<TMessage>,
): void {
const inner = transport.onmessage?.bind(transport);
transport.onmessage = (message, extra) => {
const frame = message as UiInitializeFrame;
if (frame.method === "ui/initialize") {
const advertised = frame.params?.appCapabilities;
if (typeof advertised === "object" && advertised !== null) {
rawAppCapabilities.set(bridge, advertised as Record<string, unknown>);
}
}
inner?.(message, extra);
transport.onmessage = (message: TMessage, ...rest: never[]) => {
recordAdvertisedCapabilities(bridge, message);
inner?.(message, ...rest);
};
}

View file

@ -158,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>", {

View file

@ -8,7 +8,7 @@ import type {
McpUiHostCapabilities,
McpUiResourceMeta,
} from "@modelcontextprotocol/ext-apps/app-bridge";
import type { Client, Transport } from "@modelcontextprotocol/client";
import type { Client } from "@modelcontextprotocol/client";
import type {
EmbeddedResource,
Implementation,
@ -373,7 +373,7 @@ export function createAppBridgeFactory(
// Record the view's raw `ui/initialize` capabilities before the bridge's
// own schema strips the keys it predates (#1854). Must follow `connect`,
// which is what installs the handler this wraps.
observeAppCapabilities(bridge, transport as unknown as Transport);
observeAppCapabilities(bridge, transport);
return bridge;
};
}