feat(apps): negotiated app-rendered form elicitations
Let a server hand a form `elicitation/create` to an MCP App and return the app's ordinary `ElicitResult`, with the native elicitation form as the fallback on every failure. No second extension, no custom method, and no custom result shape: the only new wire surface is a nested `elicitation` flag on the existing `io.modelcontextprotocol/ui` extension on each side, plus `_meta.ui.resourceUri` on the request. App rendering is selected only when all four gates hold — client `elicitation.form`, client MCP Apps MIME type, the nested `elicitation` setting on BOTH peers, and a valid absolute `ui://` URI on the request. Only the web client advertises the nested client-side setting, and only because it has a sandbox renderer: supplying `InspectorClientOptions.appElicitation` is what opts a client in, so CLI and TUI keep advertising the MIME type without ever claiming they can resolve an elicitation through an app. Routing lives in the one funnel both entry points already use (`enqueuePendingElicitation`), so the inbound handler and the MRTR driver cannot diverge. Ownership is request-scoped — a per-request id keys the renderer, iframe and bridge — so two concurrent elicitations can never resolve through each other's bridges. An explicit `decline`/`cancel` is a completed elicitation and goes back to the server; everything else (absent or malformed metadata, resource/sandbox/bridge failure, an app with no elicitation capability, a timeout, an invalid result or one that fails the requested schema) falls back. The Inspector speaks the ext-apps#733 / SEP-3118 wire protocol but cannot yet consume its helpers — the released `@modelcontextprotocol/ext-apps` (1.7.5) predates that PR. `core/mcp/appElicitation.ts` and `AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for deletion once a release containing it ships. One consequence needed its own seam: ext-apps 1.7.5 parses the view's `ui/initialize` through a schema that strips `elicitation`, so an app that correctly advertises it would look like one that did not, silently turning every negotiated elicitation into a fallback. `AppRenderer/appCapabilities.ts` records the raw frame instead, and prefers the bridge's own value once it carries the key. `AppRenderer` now takes an `AppRenderSource` union rather than a `Tool`, so the same renderer and bridge factory serve an App tool and an elicitation without either faking the other's shape. Adds the public fixture (`app_choose_option` + a self-contained `ui://demo/choose-option.html` app covering accept/decline/cancel), its two showcase configs, and `smoke:web:elicit`, which drives the negotiated path end to end in headless Chromium and then the same tool against a server that never advertised the capability, asserting the native form takes it. Closes #1854 Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
43525d9ea2
commit
08fc20a22d
1 changed files with 3718 additions and 93 deletions
|
|
@ -0,0 +1,411 @@
|
|||
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,
|
||||
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("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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue