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
3 changed files with 3718 additions and 93 deletions
|
|
@ -0,0 +1,104 @@
|
|||
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,
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
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) => (
|
||||
<AppElicitationFrame
|
||||
key={entry.requestId}
|
||||
entry={entry}
|
||||
sandboxPath={sandboxPath}
|
||||
bridgeFactory={bridgeFactory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppElicitationFrameProps {
|
||||
entry: AppElicitationEntry;
|
||||
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,
|
||||
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 onClose={dismiss}>
|
||||
<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: two concurrent elicitations can carry
|
||||
the same message, and two identically-named dialogs are what
|
||||
`landmark-unique` rejects. */}
|
||||
<Modal.Content
|
||||
aria-label={`Elicitation 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue