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
38 changed files with 3718 additions and 93 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -112,7 +112,17 @@ v2/main/
|
|||
│ │ # listSalvage.ts: per-item salvage for list results —
|
||||
│ │ # keeps the valid entries when one is non-conforming
|
||||
│ │ # instead of losing the whole list, and owns the
|
||||
│ │ # shared isClientDecodeRejection predicate — #1909)
|
||||
│ │ # shared isClientDecodeRejection predicate — #1909;
|
||||
│ │ # appElicitation.ts: app-rendered form elicitation
|
||||
│ │ # (#1854) — the four negotiation gates, the
|
||||
│ │ # `_meta.ui.resourceUri` reader/validator, and the
|
||||
│ │ # result validator. Mirrors ext-apps#733 / SEP-3118
|
||||
│ │ # because the released ext-apps (1.7.5) predates it;
|
||||
│ │ # the host-side renderer is supplied by the CLIENT
|
||||
│ │ # (`InspectorClientOptions.appElicitation`), and
|
||||
│ │ # supplying one is what advertises the nested
|
||||
│ │ # `elicitation` setting — so web opts in and
|
||||
│ │ # cli/tui, which cannot host an App, do not)
|
||||
│ │ ├── import/ # Config import strategies (#1348): client-config parsers
|
||||
│ │ │ # (Claude Desktop/Cursor/Cline/VS Code), registry
|
||||
│ │ │ # server.json parser, strategy registry + well-known
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -140,6 +140,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
|
|||
| Config | Demonstrates | Issue |
|
||||
| ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `mcp-app-http.json` **(legacy era)** | An MCP App (UI resource + app tool) in the Apps tab | [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) |
|
||||
| `app-elicitation-http.json` **(legacy era)** | An MCP App rendering a form elicitation | [#1854](https://github.com/modelcontextprotocol/inspector/issues/1854) |
|
||||
| `modern-mrtr-http.json` | A single MRTR round-trip | — |
|
||||
| `mrtr-showcase-http.json` | Every MRTR preset in one server | [#1860](https://github.com/modelcontextprotocol/inspector/issues/1860) |
|
||||
| `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) |
|
||||
|
|
@ -162,6 +163,25 @@ Open the Apps tab, select `mcp_app_demo`, give it a title and click **Open App**
|
|||
|
||||
For the scripted version of the same flow (`--app-info` probe → deep link → rendered widget), see [Reviewing an MCP App](./docs/mcp-app-review.md).
|
||||
|
||||
#### App-rendered form elicitations
|
||||
|
||||
`app-elicitation-http.json` serves `app_choose_option` alongside the `choose_option_app` UI resource (`ui://demo/choose-option.html`). The tool sends a completely ordinary form `elicitation/create` — the only thing added is `_meta.ui.resourceUri` naming that app. Plain streamable-HTTP; connect with the **default (legacy)** protocol era.
|
||||
|
||||
Run `app_choose_option` from the Tools tab. The server's app renders in a modal instead of the built-in elicitation form, and clicking **Option A**, **Decline**, or **Cancel** returns the standard `ElicitResult` straight to the server, which echoes it into the tool result.
|
||||
|
||||
App rendering is selected only when **all four** conditions hold ([#1854](https://github.com/modelcontextprotocol/inspector/issues/1854), per [ext-apps#733](https://github.com/modelcontextprotocol/ext-apps/pull/733) / SEP-3118):
|
||||
|
||||
1. the client advertises `elicitation.form`;
|
||||
2. the client advertises `extensions["io.modelcontextprotocol/ui"].mimeTypes` including `text/html;profile=mcp-app`;
|
||||
3. **both** the client and the server advertise the nested `elicitation` setting on that same extension;
|
||||
4. the request carries a valid absolute `ui://` URI in `_meta.ui.resourceUri`.
|
||||
|
||||
Only the **web** client advertises the nested client-side setting, and only because it has a sandbox renderer to back it; the CLI and TUI advertise the MIME type (they know what an App is) but never claim they can resolve an elicitation through one, so the same server falls back to their native prompts. Turning **Server Settings → Advertised Extensions → MCP Apps UI** off, or turning form elicitation off, removes the claim on web too.
|
||||
|
||||
Everything else falls back to the built-in elicitation form, by design: metadata that is absent or not an absolute `ui://` URI, a resource that fails to load, a sandbox or bridge that fails to initialize, an app that did not advertise `elicitation`, a request that times out, and any result that is not a valid `ElicitResult` for the requested schema. An explicit `decline` or `cancel` is **not** a fallback — it is a completed elicitation and goes back to the server as-is.
|
||||
|
||||
> The Inspector speaks the ext-apps#733 wire protocol but does not yet consume its helpers: the released `@modelcontextprotocol/ext-apps` (1.7.5) predates that PR. `core/mcp/appElicitation.ts` and `clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for deletion in favour of the package's own exports once a release containing it ships.
|
||||
|
||||
#### MRTR
|
||||
|
||||
`modern-mrtr-http.json` serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg. Its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real round-trip: `input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`.
|
||||
|
|
@ -343,7 +363,7 @@ Each client self-validates from its own folder; the root scripts chain them. The
|
|||
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency reaching one `tsc` program from two installs skews across them) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. |
|
||||
| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). |
|
||||
| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. |
|
||||
| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. A third (`smoke:web:elicit`) drives an **app-rendered elicitation** end to end — call the tool, answer inside the sandboxed app, see the app's `ElicitResult` reach the server — and then the same tool against a server that never advertised the capability, which must fall back to the native elicitation form. |
|
||||
| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. |
|
||||
| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. |
|
||||
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus `scripts/lib/resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
|
|
@ -134,6 +141,8 @@ 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 } from "./lib/appElicitationController";
|
||||
import type { LogEntryData } from "./components/elements/LogEntry/LogEntry";
|
||||
import {
|
||||
ServerConfigModal,
|
||||
|
|
@ -792,6 +801,56 @@ 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;
|
||||
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],
|
||||
);
|
||||
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] =
|
||||
|
|
@ -2410,6 +2469,11 @@ 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: hands form elicitations that name a `ui://` resource to the
|
||||
// MCP App the server chose, and advertises the nested MCP Apps
|
||||
// `elicitation` capability that makes a server willing to send one
|
||||
// (#1854). The native elicitation queue stays the fallback.
|
||||
appElicitation: appElicitationController.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
|
||||
|
|
@ -2511,6 +2575,7 @@ function App() {
|
|||
sessionStorageAdapter,
|
||||
onBeforeOAuthRedirect,
|
||||
clientConfig,
|
||||
appElicitationController.render,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -4571,6 +4636,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"}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
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): JSONRPCMessage {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "ui/initialize",
|
||||
params: { appCapabilities },
|
||||
} 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 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("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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
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.
|
||||
*
|
||||
* `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>>();
|
||||
|
||||
/** Shape of the `ui/initialize` frame this reads — nothing else is touched. */
|
||||
interface UiInitializeFrame {
|
||||
method?: unknown;
|
||||
params?: { appCapabilities?: unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
bridge: AppBridge,
|
||||
transport: Transport,
|
||||
): 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);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
if (parsed?.elicitation) return true;
|
||||
return Boolean(rawAppCapabilities.get(bridge)?.elicitation);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -167,7 +169,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 +217,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 +237,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 +258,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 +276,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 +297,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 +319,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 +337,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 +354,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 +378,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 +396,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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type {
|
|||
McpUiHostCapabilities,
|
||||
McpUiResourceMeta,
|
||||
} from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { Client } from "@modelcontextprotocol/client";
|
||||
import type { Client, Transport } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
EmbeddedResource,
|
||||
Implementation,
|
||||
|
|
@ -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 as unknown as Transport);
|
||||
return bridge;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
119
clients/web/src/lib/appElicitationController.test.ts
Normal file
119
clients/web/src/lib/appElicitationController.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ElicitRequest } from "@modelcontextprotocol/client";
|
||||
import { AppElicitationController } from "./appElicitationController";
|
||||
|
||||
const params: ElicitRequest["params"] = {
|
||||
message: "Choose an option",
|
||||
requestedSchema: { type: "object", properties: {} },
|
||||
};
|
||||
|
||||
function request(
|
||||
controller: AppElicitationController,
|
||||
requestId: string,
|
||||
resourceUri = "ui://demo/pick.html",
|
||||
signal: AbortSignal = new AbortController().signal,
|
||||
) {
|
||||
return controller.render({ requestId, resourceUri, params, signal });
|
||||
}
|
||||
|
||||
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("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();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue