Merge remote-tracking branch 'origin/v2/main' into v2/feat/1950-file-secret-store
Signed-off-by: cliffhall <cliff@futurescale.com> # Conflicts: # README.md # clients/web/src/App.tsx
This commit is contained in:
commit
a859093bd3
22 changed files with 4824 additions and 139 deletions
|
|
@ -1,4 +1,11 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
|
|
@ -135,6 +142,11 @@ import {
|
|||
import { clearScrollMemory } from "./hooks/useScrollMemory";
|
||||
import type { AppRendererHandle } from "./components/elements/AppRenderer/AppRenderer";
|
||||
import { createAppBridgeFactory } from "./components/elements/AppRenderer/createAppBridgeFactory";
|
||||
import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost";
|
||||
import {
|
||||
AppElicitationController,
|
||||
type AppElicitationSession,
|
||||
} from "./lib/appElicitationController";
|
||||
import type { LogEntryData } from "./components/elements/LogEntry/LogEntry";
|
||||
import {
|
||||
ServerConfigModal,
|
||||
|
|
@ -744,6 +756,7 @@ function App() {
|
|||
version: inspectorVersion,
|
||||
secretStorage,
|
||||
refresh: refreshInitialConfig,
|
||||
loading: initialConfigLoading,
|
||||
} = useInitialConfig({
|
||||
baseUrl: configBaseUrl,
|
||||
authToken: getAuthToken(),
|
||||
|
|
@ -795,6 +808,101 @@ function App() {
|
|||
[inspectorClient],
|
||||
);
|
||||
|
||||
// App-rendered form elicitations (#1854). The controller is created once and
|
||||
// handed to every InspectorClient at construction — its `render` is what opts
|
||||
// this client into advertising the nested MCP Apps `elicitation` capability,
|
||||
// which is why only the web client (the one with a sandbox) claims it.
|
||||
const appElicitationControllerRef = useRef<AppElicitationController>(null);
|
||||
appElicitationControllerRef.current ??= new AppElicitationController();
|
||||
const appElicitationController = appElicitationControllerRef.current;
|
||||
// The window onto the controller for the CURRENT client. Closing it when the
|
||||
// client is replaced both rejects that connection's queued requests and
|
||||
// refuses any it enqueues during its own (asynchronous) teardown — a late
|
||||
// entry would otherwise be rendered by a factory bound to the replacement
|
||||
// client, i.e. read and answered through a different server.
|
||||
const appElicitationSessionRef = useRef<AppElicitationSession>(null);
|
||||
// `setupClientForServer` is synchronous and memoized, so a caller that
|
||||
// awaited the config would still resume with the `sandboxUrl` captured by the
|
||||
// render it STARTED in — undefined, on the very load this matters for. The
|
||||
// ref is written every render, so client construction reads the current value
|
||||
// whichever entry point (connect, deep link, OAuth callback) reached it.
|
||||
const sandboxUrlRef = useRef<string | undefined>(undefined);
|
||||
sandboxUrlRef.current = sandboxUrl;
|
||||
// Whether the sandbox exists is only known once `/api/config` resolves, and
|
||||
// the answer is baked into the client at construction (it decides whether the
|
||||
// nested MCP Apps `elicitation` capability is advertised). So a connect waits
|
||||
// for it rather than guessing: guessing "available" over-claims a capability
|
||||
// we may not have, and guessing "unavailable" strands the whole session on
|
||||
// the native form despite having a sandbox. The wait is a local fetch already
|
||||
// in flight since mount.
|
||||
const initialConfigSettledRef = useRef<{
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
}>(null);
|
||||
initialConfigSettledRef.current ??= (() => {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
})();
|
||||
useEffect(() => {
|
||||
if (!initialConfigLoading) initialConfigSettledRef.current?.resolve();
|
||||
}, [initialConfigLoading]);
|
||||
const appElicitations = useSyncExternalStore(
|
||||
appElicitationController.subscribe,
|
||||
appElicitationController.getEntries,
|
||||
);
|
||||
// A SECOND factory, differing from `sandboxBridgeFactory` only in that it
|
||||
// advertises `hostCapabilities.elicitation`. An App-tool frame is never handed
|
||||
// an elicitation, so telling those apps otherwise would be a false claim.
|
||||
const elicitationBridgeFactory = useMemo(
|
||||
() =>
|
||||
createAppBridgeFactory({
|
||||
advertiseElicitation: true,
|
||||
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
|
||||
readResource: async (uri) => {
|
||||
if (!inspectorClient) throw new Error("No MCP client connected.");
|
||||
const invocation = await inspectorClient.readResource(uri);
|
||||
return invocation.result;
|
||||
},
|
||||
// Unlike the Apps tab there is no persistent surface to show the
|
||||
// failure on — the modal is about to be replaced by the native form —
|
||||
// so the toast is the only place the user learns why.
|
||||
onResourceError: (err) => {
|
||||
notifications.show({
|
||||
title: "Elicitation app failed to load",
|
||||
message: err.message,
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
}),
|
||||
[inspectorClient],
|
||||
);
|
||||
/**
|
||||
* Close the previous client's session and open one for the client being
|
||||
* constructed. Synchronous, and called at construction, so the swap itself is
|
||||
* the moment ownership changes hands.
|
||||
*/
|
||||
const newAppElicitationSession = useCallback(() => {
|
||||
appElicitationSessionRef.current?.close(
|
||||
new Error("Connection replaced before the app answered"),
|
||||
);
|
||||
const session = appElicitationController.openSession();
|
||||
appElicitationSessionRef.current = session;
|
||||
return session;
|
||||
}, [appElicitationController]);
|
||||
const handleAppElicitationSettle = useCallback(
|
||||
(requestId: string, result: ElicitResult) => {
|
||||
appElicitationController.settle(requestId, result);
|
||||
},
|
||||
[appElicitationController],
|
||||
);
|
||||
const handleAppElicitationFail = useCallback(
|
||||
(requestId: string, error: Error) => {
|
||||
appElicitationController.fail(requestId, error);
|
||||
},
|
||||
[appElicitationController],
|
||||
);
|
||||
|
||||
const [managedToolsState, setManagedToolsState] =
|
||||
useState<ManagedToolsState | null>(null);
|
||||
const [managedPromptsState, setManagedPromptsState] =
|
||||
|
|
@ -2413,6 +2521,16 @@ function App() {
|
|||
// Sampling / elicitation are on by default; keep the parameterized
|
||||
// options off until the UI grows the surface to render them.
|
||||
elicit: { form: true, url: true },
|
||||
// Web only, and only when the sandbox renderer is actually available:
|
||||
// supplying this advertises the nested MCP Apps `elicitation`
|
||||
// capability, and a client that cannot host an app must not claim it
|
||||
// (#1854). Callers await `initialConfigSettled` first, so `sandboxUrl`
|
||||
// here means "confirmed absent" rather than "not known yet" — a
|
||||
// connection that reaches this with no sandbox behaves like the
|
||||
// CLI/TUI: native elicitation queue, no claim made to the server.
|
||||
...(sandboxUrlRef.current && {
|
||||
appElicitation: newAppElicitationSession().render,
|
||||
}),
|
||||
// Always advertise the roots capability (even with no configured
|
||||
// roots) so the server can issue roots/list and receive
|
||||
// roots/list_changed; the configured roots are the answer to
|
||||
|
|
@ -2514,6 +2632,7 @@ function App() {
|
|||
sessionStorageAdapter,
|
||||
onBeforeOAuthRedirect,
|
||||
clientConfig,
|
||||
newAppElicitationSession,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -2633,6 +2752,9 @@ function App() {
|
|||
}
|
||||
|
||||
void (async () => {
|
||||
// Same reason as the connect path: whether this client may advertise
|
||||
// app-rendered elicitation is fixed at construction.
|
||||
await initialConfigSettledRef.current?.promise;
|
||||
try {
|
||||
await webOAuthStorage.load();
|
||||
} catch (err) {
|
||||
|
|
@ -2733,6 +2855,12 @@ function App() {
|
|||
|
||||
const onToggleConnection = useCallback(
|
||||
async (id: string) => {
|
||||
// Whether this client may advertise app-rendered elicitation is decided
|
||||
// at construction and cannot be revised afterwards, so wait for the fact
|
||||
// rather than guess it (see `initialConfigSettledRef`). Already resolved
|
||||
// by the time any human clicks; this only orders a deep-link auto-connect
|
||||
// that races the same page load.
|
||||
await initialConfigSettledRef.current?.promise;
|
||||
// Same server, already connected → disconnect.
|
||||
if (
|
||||
id === activeServerId &&
|
||||
|
|
@ -4596,6 +4724,13 @@ function App() {
|
|||
onRefreshApps={onRefreshTools}
|
||||
/>
|
||||
</Box>
|
||||
<AppElicitationHost
|
||||
entries={appElicitations}
|
||||
sandboxPath={sandboxUrl}
|
||||
bridgeFactory={elicitationBridgeFactory}
|
||||
onSettle={handleAppElicitationSettle}
|
||||
onFail={handleAppElicitationFail}
|
||||
/>
|
||||
<ServerConfigModal
|
||||
opened={configModal !== null}
|
||||
mode={configModal?.mode ?? "add"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { ElicitRequest } from "@modelcontextprotocol/client";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import type { AppElicitationEntry } from "../../../lib/appElicitationController";
|
||||
import type { BridgeFactory } from "../AppRenderer/AppRenderer";
|
||||
import { AppElicitationHost } from "./AppElicitationHost";
|
||||
|
||||
const PLACEHOLDER_SANDBOX = "data:text/html,<title>Mock%20Sandbox</title>";
|
||||
|
||||
const params: ElicitRequest["params"] = {
|
||||
message: "Choose option A or B.",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: { choice: { type: "string", enum: ["option-a", "option-b"] } },
|
||||
required: ["choice"],
|
||||
},
|
||||
};
|
||||
|
||||
function entry(
|
||||
requestId: string,
|
||||
resourceUri = "ui://demo/choose-option.html",
|
||||
): AppElicitationEntry {
|
||||
return {
|
||||
requestId,
|
||||
sessionId: 0,
|
||||
resourceUri,
|
||||
params,
|
||||
signal: new AbortController().signal,
|
||||
resolve: fn(),
|
||||
reject: fn(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial mock: implements only the `AppBridge` members the renderer touches,
|
||||
* plus the elicitation pair. The double cast bridges the deliberately
|
||||
* incomplete shape, as in the AppRenderer stories.
|
||||
*/
|
||||
function createMockBridge(): AppBridge {
|
||||
return {
|
||||
getAppCapabilities: () => ({ elicitation: {} }),
|
||||
request: async () => ({
|
||||
action: "accept",
|
||||
content: { choice: "option-a" },
|
||||
}),
|
||||
sendHostContextChange: async () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
teardownResource: async () => ({}),
|
||||
close: async () => {},
|
||||
} as unknown as AppBridge;
|
||||
}
|
||||
|
||||
const okFactory: BridgeFactory = () => createMockBridge();
|
||||
|
||||
const failingFactory: BridgeFactory = () =>
|
||||
Promise.reject(new Error("Bridge connect failed: handshake timed out"));
|
||||
|
||||
const meta: Meta<typeof AppElicitationHost> = {
|
||||
title: "Elements/AppElicitationHost",
|
||||
component: AppElicitationHost,
|
||||
args: {
|
||||
sandboxPath: PLACEHOLDER_SANDBOX,
|
||||
bridgeFactory: okFactory,
|
||||
onSettle: fn(),
|
||||
onFail: fn(),
|
||||
},
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AppElicitationHost>;
|
||||
|
||||
/** One server-attached app, waiting on the user inside the sandbox frame. */
|
||||
export const SingleRequest: Story = {
|
||||
args: { entries: [entry("req-1")] },
|
||||
play: async ({ canvasElement }) => {
|
||||
// Modals portal to document.body, so scope to the whole document.
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await expect(await body.findByText("Choose option A or B.")).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Two elicitations in flight at once, each with its own frame and bridge — the
|
||||
* request-scoped ownership the contract requires.
|
||||
*/
|
||||
export const ConcurrentRequests: Story = {
|
||||
args: {
|
||||
entries: [
|
||||
entry("req-1", "ui://demo/first.html"),
|
||||
entry("req-2", "ui://demo/second.html"),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** The app could not be brought up; the host falls back to the native form. */
|
||||
export const RenderFailure: Story = {
|
||||
args: { entries: [entry("req-1")], bridgeFactory: failingFactory },
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await expect(await body.findByText(/App failed to render/)).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,447 @@
|
|||
import { act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/client";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import type { AppElicitationEntry } from "../../../lib/appElicitationController";
|
||||
import type { BridgeFactory } from "../AppRenderer/AppRenderer";
|
||||
import {
|
||||
APP_ELICITATION_INIT_TIMEOUT_MS,
|
||||
AppElicitationHost,
|
||||
} from "./AppElicitationHost";
|
||||
|
||||
const params: ElicitRequest["params"] = {
|
||||
message: "Choose an option",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: { choice: { type: "string" } },
|
||||
required: ["choice"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The renderer's bridge, reduced to what this component drives: the app's
|
||||
* advertised capabilities and the elicitation round-trip. `emit` lets a test
|
||||
* play the view's `initialized` signal, which is what triggers the send.
|
||||
*/
|
||||
function createMockBridge(options: {
|
||||
elicitation?: boolean;
|
||||
answer?: () => Promise<unknown>;
|
||||
}) {
|
||||
const listeners: Record<string, ((payload: unknown) => void)[]> = {};
|
||||
const answer =
|
||||
options.answer ?? (() => Promise.resolve({ action: "cancel" }));
|
||||
const request = vi.fn<(...args: unknown[]) => Promise<unknown>>(() =>
|
||||
answer(),
|
||||
);
|
||||
return {
|
||||
bridge: {
|
||||
getAppCapabilities: () =>
|
||||
options.elicitation === false ? {} : { elicitation: {} },
|
||||
request,
|
||||
teardownResource: vi.fn().mockResolvedValue({}),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
addEventListener: vi.fn(
|
||||
(event: string, handler: (p: unknown) => void) => {
|
||||
(listeners[event] ??= []).push(handler);
|
||||
},
|
||||
),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as AppBridge,
|
||||
request,
|
||||
emit: (event: string, payload?: unknown) => {
|
||||
(listeners[event] ?? []).forEach((h) => h(payload));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeEntry(
|
||||
requestId: string,
|
||||
resourceUri = "ui://demo/choose-option.html",
|
||||
): AppElicitationEntry {
|
||||
return {
|
||||
requestId,
|
||||
sessionId: 0,
|
||||
resourceUri,
|
||||
params,
|
||||
signal: new AbortController().signal,
|
||||
resolve: vi.fn(),
|
||||
reject: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Two microtasks settle the renderer's bridge promise chain (see AppRenderer). */
|
||||
async function flushAsync(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("AppElicitationHost (#1854)", () => {
|
||||
const onSettle = vi.fn();
|
||||
const onFail = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
onSettle.mockReset();
|
||||
onFail.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders nothing and fails every entry when there is no sandbox", () => {
|
||||
renderWithMantine(
|
||||
<AppElicitationHost
|
||||
entries={[makeEntry("a"), makeEntry("b")]}
|
||||
bridgeFactory={vi.fn() as unknown as BridgeFactory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("app-elicitation")).toBeNull();
|
||||
expect(onFail).toHaveBeenCalledTimes(2);
|
||||
expect(onFail.mock.calls[0][1].message).toMatch(/sandbox is not available/);
|
||||
});
|
||||
|
||||
it("forwards the request through the app's bridge once it is ready and settles with the result", async () => {
|
||||
const mock = createMockBridge({
|
||||
answer: () =>
|
||||
Promise.resolve({ action: "accept", content: { choice: "option-a" } }),
|
||||
});
|
||||
const factory = vi.fn(() => mock.bridge) as unknown as BridgeFactory;
|
||||
|
||||
renderWithMantine(
|
||||
<AppElicitationHost
|
||||
entries={[makeEntry("req-1")]}
|
||||
sandboxPath="/sandbox.html"
|
||||
bridgeFactory={factory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>,
|
||||
);
|
||||
await flushAsync();
|
||||
// Nothing is sent before the view says it is ready — an app that has not
|
||||
// completed ui/initialize has no handler registered yet.
|
||||
expect(mock.request).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
mock.emit("initialized");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mock.request.mock.calls[0][0]).toEqual({
|
||||
method: "elicitation/create",
|
||||
params,
|
||||
});
|
||||
expect(onSettle).toHaveBeenCalledWith("req-1", {
|
||||
action: "accept",
|
||||
content: { choice: "option-a" },
|
||||
});
|
||||
expect(onFail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads the app from the elicitation's own resource URI", async () => {
|
||||
const mock = createMockBridge({});
|
||||
const factory = vi.fn(() => mock.bridge) as unknown as BridgeFactory;
|
||||
renderWithMantine(
|
||||
<AppElicitationHost
|
||||
entries={[makeEntry("req-1", "ui://demo/other.html")]}
|
||||
sandboxPath="/sandbox.html"
|
||||
bridgeFactory={factory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>,
|
||||
);
|
||||
await flushAsync();
|
||||
expect(factory).toHaveBeenCalledWith(expect.anything(), {
|
||||
kind: "resource",
|
||||
resourceUri: "ui://demo/other.html",
|
||||
title: params.message,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives each concurrent request its own frame and bridge", async () => {
|
||||
const first = createMockBridge({});
|
||||
const second = createMockBridge({});
|
||||
const bridges = [first.bridge, second.bridge];
|
||||
const factory = vi.fn(() => bridges.shift()) as unknown as BridgeFactory;
|
||||
|
||||
renderWithMantine(
|
||||
<AppElicitationHost
|
||||
entries={[
|
||||
makeEntry("req-1", "ui://demo/first.html"),
|
||||
makeEntry("req-2", "ui://demo/second.html"),
|
||||
]}
|
||||
sandboxPath="/sandbox.html"
|
||||
bridgeFactory={factory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>,
|
||||
);
|
||||
await flushAsync();
|
||||
expect(factory).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Only the second app answers; its result must be attributed to req-2.
|
||||
await act(async () => {
|
||||
second.emit("initialized");
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(first.request).not.toHaveBeenCalled();
|
||||
expect(onSettle).toHaveBeenCalledTimes(1);
|
||||
expect(onSettle.mock.calls[0][0]).toBe("req-2");
|
||||
});
|
||||
|
||||
it("gives the focus trap, Escape and the overlay to the top modal only", async () => {
|
||||
// Every entry stays mounted (each app keeps its own bridge and handshake),
|
||||
// but only one may own the keyboard — otherwise the traps fight and a
|
||||
// single Escape can dismiss more than one pending request.
|
||||
const first = createMockBridge({});
|
||||
const second = createMockBridge({});
|
||||
const bridges = [first.bridge, second.bridge];
|
||||
const factory = vi.fn(() => bridges.shift()) as unknown as BridgeFactory;
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithMantine(
|
||||
<AppElicitationHost
|
||||
entries={[makeEntry("req-1"), makeEntry("req-2")]}
|
||||
sandboxPath="/sandbox.html"
|
||||
bridgeFactory={factory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>,
|
||||
);
|
||||
await flushAsync();
|
||||
expect(screen.getAllByTestId("app-elicitation")).toHaveLength(2);
|
||||
|
||||
// The covered dialog is inert: out of the a11y tree and out of focus
|
||||
// order, but still mounted — its app keeps its bridge and handshake.
|
||||
const [lower, top] = screen.getAllByTestId("app-elicitation");
|
||||
expect(lower.hasAttribute("inert")).toBe(true);
|
||||
expect(top.hasAttribute("inert")).toBe(false);
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
// Exactly one request is dismissed — the topmost, which is the last one.
|
||||
expect(onFail).toHaveBeenCalledTimes(1);
|
||||
expect(onFail.mock.calls[0][0]).toBe("req-2");
|
||||
expect(onFail.mock.calls[0][1].message).toMatch(/dismissed/);
|
||||
});
|
||||
|
||||
it("falls back when the app does not advertise elicitation", async () => {
|
||||
const mock = createMockBridge({ elicitation: false });
|
||||
renderWithMantine(
|
||||
<AppElicitationHost
|
||||
entries={[makeEntry("req-1")]}
|
||||
sandboxPath="/sandbox.html"
|
||||
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>,
|
||||
);
|
||||
await flushAsync();
|
||||
await act(async () => {
|
||||
mock.emit("initialized");
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(onSettle).not.toHaveBeenCalled();
|
||||
expect(onFail.mock.calls[0][1].message).toMatch(
|
||||
/does not support elicitation/,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back when the bridge request fails", async () => {
|
||||
const mock = createMockBridge({
|
||||
answer: () => Promise.reject(new Error("bridge exploded")),
|
||||
});
|
||||
renderWithMantine(
|
||||
<AppElicitationHost
|
||||
entries={[makeEntry("req-1")]}
|
||||
sandboxPath="/sandbox.html"
|
||||
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>,
|
||||
);
|
||||
await flushAsync();
|
||||
await act(async () => {
|
||||
mock.emit("initialized");
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(onFail).toHaveBeenCalledWith("req-1", expect.any(Error));
|
||||
expect(onFail.mock.calls[0][1].message).toMatch(/bridge exploded/);
|
||||
});
|
||||
|
||||
it("wraps a non-Error rejection so the fallback still gets an Error", async () => {
|
||||
// The rejection crosses a sandbox boundary; an app or bridge can reject
|
||||
// with anything, and `onFail` must still hand the caller an Error.
|
||||
const mock = createMockBridge({
|
||||
answer: () => Promise.reject("just a string"),
|
||||
});
|
||||
renderWithMantine(
|
||||
<AppElicitationHost
|
||||
entries={[makeEntry("req-1")]}
|
||||
sandboxPath="/sandbox.html"
|
||||
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>,
|
||||
);
|
||||
await flushAsync();
|
||||
await act(async () => {
|
||||
mock.emit("initialized");
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(onFail.mock.calls[0][1]).toBeInstanceOf(Error);
|
||||
expect(onFail.mock.calls[0][1].message).toBe("just a string");
|
||||
});
|
||||
|
||||
it("does not fall back on the init deadline once the request is in flight", async () => {
|
||||
// The deadline bounds the HANDSHAKE only. A user taking longer than 15s to
|
||||
// answer must not have their app yanked away.
|
||||
vi.useFakeTimers();
|
||||
let answer: ((result: ElicitResult) => void) | undefined;
|
||||
const mock = createMockBridge({
|
||||
answer: () => new Promise<ElicitResult>((resolve) => (answer = resolve)),
|
||||
});
|
||||
renderWithMantine(
|
||||
<AppElicitationHost
|
||||
entries={[makeEntry("req-1")]}
|
||||
sandboxPath="/sandbox.html"
|
||||
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
mock.emit("initialized");
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mock.request).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(APP_ELICITATION_INIT_TIMEOUT_MS * 2);
|
||||
});
|
||||
expect(onFail).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
answer?.({ action: "accept", content: { choice: "option-a" } });
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(onSettle).toHaveBeenCalledWith("req-1", {
|
||||
action: "accept",
|
||||
content: { choice: "option-a" },
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back when the renderer cannot build a bridge at all", async () => {
|
||||
const factory = vi.fn(() => {
|
||||
throw new Error("no connected MCP client");
|
||||
}) as unknown as BridgeFactory;
|
||||
renderWithMantine(
|
||||
<AppElicitationHost
|
||||
entries={[makeEntry("req-1")]}
|
||||
sandboxPath="/sandbox.html"
|
||||
bridgeFactory={factory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>,
|
||||
);
|
||||
await flushAsync();
|
||||
expect(onFail).toHaveBeenCalled();
|
||||
expect(await screen.findByText(/App failed to render/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back when the app never completes its handshake", async () => {
|
||||
vi.useFakeTimers();
|
||||
const mock = createMockBridge({});
|
||||
renderWithMantine(
|
||||
<AppElicitationHost
|
||||
entries={[makeEntry("req-1")]}
|
||||
sandboxPath="/sandbox.html"
|
||||
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(APP_ELICITATION_INIT_TIMEOUT_MS + 1);
|
||||
});
|
||||
expect(mock.request).not.toHaveBeenCalled();
|
||||
expect(onFail.mock.calls[0][1].message).toMatch(/did not initialize/);
|
||||
});
|
||||
|
||||
it("falls back when the user dismisses the modal", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mock = createMockBridge({});
|
||||
renderWithMantine(
|
||||
<AppElicitationHost
|
||||
entries={[makeEntry("req-1")]}
|
||||
sandboxPath="/sandbox.html"
|
||||
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>,
|
||||
);
|
||||
await flushAsync();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /close and use the built-in/i }),
|
||||
);
|
||||
// Dismissing is not an answer: the server still needs one, so this must be
|
||||
// a fallback rather than a fabricated `cancel`.
|
||||
expect(onSettle).not.toHaveBeenCalled();
|
||||
expect(onFail.mock.calls[0][1].message).toMatch(/dismissed/);
|
||||
});
|
||||
|
||||
it("sends only one request even if the view signals ready twice", async () => {
|
||||
const mock = createMockBridge({});
|
||||
renderWithMantine(
|
||||
<AppElicitationHost
|
||||
entries={[makeEntry("req-1")]}
|
||||
sandboxPath="/sandbox.html"
|
||||
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>,
|
||||
);
|
||||
await flushAsync();
|
||||
await act(async () => {
|
||||
mock.emit("initialized");
|
||||
mock.emit("initialized");
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mock.request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes decline and cancel through as completed answers", async () => {
|
||||
const results: ElicitResult[] = [
|
||||
{ action: "decline" },
|
||||
{ action: "cancel" },
|
||||
];
|
||||
for (const result of results) {
|
||||
onSettle.mockReset();
|
||||
const mock = createMockBridge({ answer: () => Promise.resolve(result) });
|
||||
const { unmount } = renderWithMantine(
|
||||
<AppElicitationHost
|
||||
entries={[makeEntry("req-1")]}
|
||||
sandboxPath="/sandbox.html"
|
||||
bridgeFactory={vi.fn(() => mock.bridge) as unknown as BridgeFactory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>,
|
||||
);
|
||||
await flushAsync();
|
||||
await act(async () => {
|
||||
mock.emit("initialized");
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(onSettle).toHaveBeenCalledWith("req-1", result);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
import { Alert, CloseButton, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ElicitResult } from "@modelcontextprotocol/client";
|
||||
import type { AppElicitationEntry } from "../../../lib/appElicitationController";
|
||||
import {
|
||||
AppRenderer,
|
||||
type AppRenderSource,
|
||||
type AppRendererHandle,
|
||||
type AppRendererStatus,
|
||||
type BridgeFactory,
|
||||
} from "../AppRenderer/AppRenderer";
|
||||
|
||||
/**
|
||||
* How long an app has to complete `ui/initialize` before the host gives up and
|
||||
* lets the native elicitation UI take the request.
|
||||
*
|
||||
* Short on purpose, and unrelated to {@link APP_ELICITATION_TIMEOUT_MS} (which
|
||||
* bounds the *answer*): nothing here waits on a human, so a sandbox that has
|
||||
* not handshaken in this window is broken rather than slow, and the user is
|
||||
* better served by the native form than by a spinner.
|
||||
*/
|
||||
export const APP_ELICITATION_INIT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Modal shell for one app-rendered elicitation.
|
||||
*
|
||||
* Deliberately headerless (`withCloseButton: false`, no `title`): concurrent
|
||||
* elicitations mean two of these are open at once, and Mantine's modal header
|
||||
* is a `<header>` — a second banner landmark, which axe flags as both
|
||||
* `landmark-no-duplicate-banner` and `landmark-unique`. The title and the close
|
||||
* affordance live in the body instead, and each dialog is named by its own
|
||||
* `aria-label` so the two remain distinguishable.
|
||||
*/
|
||||
const ElicitationModal = Modal.Root.withProps({
|
||||
centered: true,
|
||||
size: "lg",
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
|
||||
const ElicitationOverlay = Modal.Overlay.withProps({
|
||||
backgroundOpacity: 0.55,
|
||||
blur: 2,
|
||||
});
|
||||
|
||||
/** Title row: the server's prompt plus the dismiss control. */
|
||||
const TitleRow = Group.withProps({
|
||||
justify: "space-between",
|
||||
align: "flex-start",
|
||||
wrap: "nowrap",
|
||||
gap: "sm",
|
||||
});
|
||||
|
||||
const TitleText = Text.withProps({
|
||||
fw: 600,
|
||||
size: "md",
|
||||
});
|
||||
|
||||
/** Fixed-height frame the app renders into; apps size themselves within it. */
|
||||
const FrameBox = Stack.withProps({
|
||||
h: 360,
|
||||
gap: 0,
|
||||
});
|
||||
|
||||
const PromptText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "dimmed",
|
||||
});
|
||||
|
||||
function toError(err: unknown): Error {
|
||||
return err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
|
||||
export interface AppElicitationHostProps {
|
||||
/** Elicitations currently awaiting an app answer, oldest first. */
|
||||
entries: AppElicitationEntry[];
|
||||
/**
|
||||
* The inspector's sandbox-proxy URL. Absent means the sandbox controller is
|
||||
* not running, so nothing can be rendered — every entry falls back at once.
|
||||
*/
|
||||
sandboxPath?: string;
|
||||
/** Builds the per-app bridge. Must advertise `hostCapabilities.elicitation`. */
|
||||
bridgeFactory: BridgeFactory;
|
||||
/** The app answered: hand the standard result back to the server. */
|
||||
onSettle: (requestId: string, result: ElicitResult) => void;
|
||||
/** The app could not answer: fall back to the native elicitation UI. */
|
||||
onFail: (requestId: string, error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders every pending app-rendered elicitation (#1854), one modal and one
|
||||
* bridge per request.
|
||||
*
|
||||
* Keyed by `requestId` rather than by resource URI so that two concurrent
|
||||
* requests — even for the SAME app — get distinct React subtrees, distinct
|
||||
* iframes and distinct bridges. That is what makes the ownership request-scoped
|
||||
* in practice, not just on paper.
|
||||
*/
|
||||
export function AppElicitationHost({
|
||||
entries,
|
||||
sandboxPath,
|
||||
bridgeFactory,
|
||||
onSettle,
|
||||
onFail,
|
||||
}: AppElicitationHostProps) {
|
||||
// No sandbox → nothing can render. Fail every entry immediately rather than
|
||||
// showing an empty modal the user cannot act on.
|
||||
useEffect(() => {
|
||||
if (sandboxPath) return;
|
||||
for (const entry of entries) {
|
||||
onFail(
|
||||
entry.requestId,
|
||||
new Error("MCP App sandbox is not available in this session"),
|
||||
);
|
||||
}
|
||||
}, [entries, sandboxPath, onFail]);
|
||||
|
||||
if (!sandboxPath) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry, index) => (
|
||||
<AppElicitationFrame
|
||||
key={entry.requestId}
|
||||
entry={entry}
|
||||
// Only the last-rendered modal is on top, so only it may own the
|
||||
// focus trap, the Escape key and the overlay — see the prop's doc.
|
||||
isTop={index === entries.length - 1}
|
||||
sandboxPath={sandboxPath}
|
||||
bridgeFactory={bridgeFactory}
|
||||
onSettle={onSettle}
|
||||
onFail={onFail}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppElicitationFrameProps {
|
||||
entry: AppElicitationEntry;
|
||||
/**
|
||||
* Whether this modal is the topmost of the open set.
|
||||
*
|
||||
* Concurrent elicitations mean several are open at once, and each would
|
||||
* otherwise install its own focus trap, Escape handler and overlay — which
|
||||
* fight over the keyboard and let one Escape dismiss more than one pending
|
||||
* request. Only the top one owns those; the rest stay mounted (their apps
|
||||
* keep their bridges and their handshakes) but inert to the keyboard.
|
||||
*/
|
||||
isTop: boolean;
|
||||
sandboxPath: string;
|
||||
bridgeFactory: BridgeFactory;
|
||||
onSettle: (requestId: string, result: ElicitResult) => void;
|
||||
onFail: (requestId: string, error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One request: mount the app, and the moment its view reports `ready`, forward
|
||||
* the original `elicitation/create` through THAT app's bridge.
|
||||
*
|
||||
* Every failure route ends in `onFail`, which is the fallback signal — an
|
||||
* unreachable sandbox, a view that never handshakes, an app with no elicitation
|
||||
* capability, a bridge error, or the user dismissing the modal.
|
||||
*/
|
||||
function AppElicitationFrame({
|
||||
entry,
|
||||
isTop,
|
||||
sandboxPath,
|
||||
bridgeFactory,
|
||||
onSettle,
|
||||
onFail,
|
||||
}: AppElicitationFrameProps) {
|
||||
const rendererRef = useRef<AppRendererHandle>(null);
|
||||
// Guards the one-shot send: `ready` can fire again after a bridge rebuild,
|
||||
// and a second `elicitation/create` for the same server request would be a
|
||||
// duplicate the server never asked for.
|
||||
const sentRef = useRef(false);
|
||||
const [status, setStatus] = useState<AppRendererStatus>("loading");
|
||||
|
||||
const source = useMemo<AppRenderSource>(
|
||||
() => ({
|
||||
kind: "resource",
|
||||
resourceUri: entry.resourceUri,
|
||||
title: entry.params.message,
|
||||
}),
|
||||
[entry.resourceUri, entry.params.message],
|
||||
);
|
||||
|
||||
const fail = useCallback(
|
||||
(error: Error) => {
|
||||
onFail(entry.requestId, error);
|
||||
},
|
||||
[entry.requestId, onFail],
|
||||
);
|
||||
|
||||
// Dismissing is not an answer: the server is still waiting, so hand the
|
||||
// request to the native elicitation UI rather than inventing a `cancel`.
|
||||
const dismiss = useCallback(
|
||||
() => fail(new Error("App-rendered elicitation dismissed")),
|
||||
[fail],
|
||||
);
|
||||
|
||||
// Initialization deadline. Cleared as soon as the request goes out, so it
|
||||
// only ever bounds the handshake and never the user's answer.
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
if (sentRef.current) return;
|
||||
fail(new Error("MCP App did not initialize in time"));
|
||||
}, APP_ELICITATION_INIT_TIMEOUT_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [fail]);
|
||||
|
||||
const handleStatus = useCallback(
|
||||
(next: AppRendererStatus) => {
|
||||
setStatus(next);
|
||||
if (next === "error") {
|
||||
fail(new Error("MCP App failed to render"));
|
||||
return;
|
||||
}
|
||||
if (next !== "ready" || sentRef.current) return;
|
||||
sentRef.current = true;
|
||||
const handle = rendererRef.current;
|
||||
/* v8 ignore next 4 -- defensive: `ready` is dispatched from the renderer's
|
||||
own bridge callback, so its imperative handle is always attached by the
|
||||
time this runs. */
|
||||
if (!handle) {
|
||||
fail(new Error("MCP App renderer is unavailable"));
|
||||
return;
|
||||
}
|
||||
handle
|
||||
.requestElicitation(entry.params)
|
||||
.then((result) => onSettle(entry.requestId, result))
|
||||
.catch((err: unknown) => fail(toError(err)));
|
||||
},
|
||||
[entry.params, entry.requestId, fail, onSettle],
|
||||
);
|
||||
|
||||
return (
|
||||
<ElicitationModal
|
||||
// `opened` stays at the call site: it is REQUIRED by `ModalRootProps`,
|
||||
// and `.withProps()` supplies defaults without relaxing a required prop,
|
||||
// so baking it in leaves every call site failing TS2741.
|
||||
opened
|
||||
onClose={dismiss}
|
||||
trapFocus={isTop}
|
||||
closeOnEscape={isTop}
|
||||
>
|
||||
{isTop && <ElicitationOverlay />}
|
||||
{/* `aria-label` and the `data-*` attributes go on the CONTENT (the
|
||||
role="dialog" element), not the Root — the Root is only a portal
|
||||
wrapper, so a name placed there never reaches the dialog. Named per
|
||||
request rather than per prompt or per app: two concurrent
|
||||
elicitations can be for the SAME app URI with the SAME message, and
|
||||
only the request id tells those two dialogs apart — for
|
||||
`landmark-unique` and for anyone navigating by screen reader. */}
|
||||
<Modal.Content
|
||||
// A lower dialog is visually covered but would otherwise stay an
|
||||
// exposed `aria-modal` dialog with reachable controls and an iframe —
|
||||
// so browse navigation could land in a dialog that itself claims the
|
||||
// rest of the page is inert. `inert` takes it out of the a11y tree and
|
||||
// out of focus order WITHOUT unmounting, so its app keeps its bridge
|
||||
// and its handshake and is live the moment it becomes top.
|
||||
inert={!isTop}
|
||||
aria-label={`Elicitation ${entry.requestId} — ${entry.params.message} — rendered by ${entry.resourceUri}`}
|
||||
data-app-elicitation-status={status}
|
||||
data-testid="app-elicitation"
|
||||
>
|
||||
<Modal.Body>
|
||||
<Stack gap="xs">
|
||||
<TitleRow>
|
||||
<TitleText>{entry.params.message}</TitleText>
|
||||
<CloseButton
|
||||
onClick={dismiss}
|
||||
aria-label="Close and use the built-in elicitation form"
|
||||
/>
|
||||
</TitleRow>
|
||||
<PromptText>
|
||||
Answering through the server-provided MCP App.
|
||||
</PromptText>
|
||||
{status === "error" ? (
|
||||
<Alert color="red" title="App failed to render">
|
||||
Falling back to the built-in elicitation form.
|
||||
</Alert>
|
||||
) : (
|
||||
<FrameBox>
|
||||
<AppRenderer
|
||||
ref={rendererRef}
|
||||
sandboxPath={sandboxPath}
|
||||
source={source}
|
||||
bridgeFactory={bridgeFactory}
|
||||
onAppStatusChange={handleStatus}
|
||||
onError={fail}
|
||||
/>
|
||||
</FrameBox>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</ElicitationModal>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,238 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { JSONRPCMessage, Transport } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
appAdvertisesElicitation,
|
||||
observeAppCapabilities,
|
||||
} from "./appCapabilities";
|
||||
|
||||
function makeBridge(parsed?: Record<string, unknown>): AppBridge {
|
||||
return { getAppCapabilities: () => parsed } as unknown as AppBridge;
|
||||
}
|
||||
|
||||
function makeTransport(): {
|
||||
transport: Transport;
|
||||
inner: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const inner = vi.fn();
|
||||
const transport = { onmessage: inner } as unknown as Transport;
|
||||
return { transport, inner };
|
||||
}
|
||||
|
||||
function initializeFrame(
|
||||
appCapabilities: unknown,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): JSONRPCMessage {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "ui/initialize",
|
||||
params: {
|
||||
protocolVersion: "2026-01-26",
|
||||
appInfo: { name: "test-app", version: "1.0.0" },
|
||||
appCapabilities,
|
||||
...overrides,
|
||||
},
|
||||
} as unknown as JSONRPCMessage;
|
||||
}
|
||||
|
||||
describe("appCapabilities (#1854)", () => {
|
||||
it("records the raw ui/initialize capabilities the bridge's schema strips", () => {
|
||||
// The whole reason this module exists: ext-apps 1.7.5 parses away the
|
||||
// `elicitation` key, so an app that DID advertise it reads as one that did
|
||||
// not — and every negotiated elicitation silently becomes a fallback.
|
||||
const bridge = makeBridge({});
|
||||
const { transport, inner } = makeTransport();
|
||||
observeAppCapabilities(bridge, transport);
|
||||
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
||||
transport.onmessage?.(initializeFrame({ elicitation: {} }));
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(true);
|
||||
// The bridge's own handler still runs — observing must not swallow.
|
||||
expect(inner).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prefers the bridge's own value once ext-apps carries it", () => {
|
||||
const bridge = makeBridge({ elicitation: {} });
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when the bridge advertises nothing at all", () => {
|
||||
expect(appAdvertisesElicitation(makeBridge(undefined))).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for an app that advertised no elicitation", () => {
|
||||
const bridge = makeBridge({ availableDisplayModes: ["inline"] });
|
||||
const { transport } = makeTransport();
|
||||
observeAppCapabilities(bridge, transport);
|
||||
transport.onmessage?.(initializeFrame({ availableDisplayModes: [] }));
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores other methods and non-object capabilities", () => {
|
||||
const bridge = makeBridge(undefined);
|
||||
const { transport } = makeTransport();
|
||||
observeAppCapabilities(bridge, transport);
|
||||
transport.onmessage?.({
|
||||
jsonrpc: "2.0",
|
||||
method: "ui/notifications/initialized",
|
||||
} as JSONRPCMessage);
|
||||
transport.onmessage?.(initializeFrame("not-an-object"));
|
||||
transport.onmessage?.(initializeFrame(null));
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores frames that are not shaped like ui/initialize at all", () => {
|
||||
// The frame comes from sandboxed view code, so nothing about its shape is
|
||||
// guaranteed — a non-object, or an initialize with no params, must not
|
||||
// throw on the way through to the bridge's own handler.
|
||||
const bridge = makeBridge({});
|
||||
const { transport, inner } = makeTransport();
|
||||
observeAppCapabilities(bridge, transport);
|
||||
transport.onmessage?.("not-a-frame" as unknown as JSONRPCMessage);
|
||||
transport.onmessage?.(null as unknown as JSONRPCMessage);
|
||||
transport.onmessage?.({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "ui/initialize",
|
||||
} as unknown as JSONRPCMessage);
|
||||
transport.onmessage?.({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "ui/initialize",
|
||||
params: null,
|
||||
} as unknown as JSONRPCMessage);
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
||||
expect(inner).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("ignores a malformed initialize the bridge would reject", () => {
|
||||
// Fail-closed: accepting one would let a view flip `elicitation` on with a
|
||||
// frame that never negotiated anything, and the host would then forward a
|
||||
// request the bridge does not consider negotiated.
|
||||
const bridge = makeBridge({});
|
||||
const { transport } = makeTransport();
|
||||
observeAppCapabilities(bridge, transport);
|
||||
|
||||
// No protocolVersion.
|
||||
transport.onmessage?.({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "ui/initialize",
|
||||
params: { appInfo: { name: "a" }, appCapabilities: { elicitation: {} } },
|
||||
} as unknown as JSONRPCMessage);
|
||||
// No appInfo.
|
||||
transport.onmessage?.({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "ui/initialize",
|
||||
params: {
|
||||
protocolVersion: "2026-01-26",
|
||||
appCapabilities: { elicitation: {} },
|
||||
},
|
||||
} as unknown as JSONRPCMessage);
|
||||
// A notification, not the handshake request.
|
||||
transport.onmessage?.({
|
||||
jsonrpc: "2.0",
|
||||
method: "ui/initialize",
|
||||
params: {
|
||||
protocolVersion: "2026-01-26",
|
||||
appInfo: { name: "a" },
|
||||
appCapabilities: { elicitation: {} },
|
||||
},
|
||||
} as unknown as JSONRPCMessage);
|
||||
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
||||
});
|
||||
|
||||
it("takes the latest accepted handshake, in both directions", () => {
|
||||
// Verified against ext-apps 1.7.5: a second `ui/initialize` is accepted —
|
||||
// the bridge warns about the double-mount and the latest appInfo and
|
||||
// capabilities REPLACE the previous ones. Freezing this at the first frame
|
||||
// would leave the gate reporting capabilities the bridge no longer holds.
|
||||
const bridge = makeBridge({});
|
||||
const { transport } = makeTransport();
|
||||
observeAppCapabilities(bridge, transport);
|
||||
|
||||
transport.onmessage?.(initializeFrame({ availableDisplayModes: [] }));
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
||||
transport.onmessage?.(initializeFrame({ elicitation: {} }));
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(true);
|
||||
// …and a re-handshake that drops the capability turns it back off.
|
||||
transport.onmessage?.(initializeFrame({}));
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a recorded capability alone when a later frame is malformed", () => {
|
||||
// A rejected frame is not a route to changing the gate — in either
|
||||
// direction: it cannot set `elicitation`, and it cannot clear one the
|
||||
// bridge still holds.
|
||||
const bridge = makeBridge({});
|
||||
const { transport } = makeTransport();
|
||||
observeAppCapabilities(bridge, transport);
|
||||
transport.onmessage?.(initializeFrame({ elicitation: {} }));
|
||||
transport.onmessage?.({
|
||||
jsonrpc: "2.0",
|
||||
id: 9,
|
||||
method: "ui/initialize",
|
||||
params: { appCapabilities: {} },
|
||||
} as unknown as JSONRPCMessage);
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a frame the bridge's own schema rejects", () => {
|
||||
// `appInfo: {}` is missing the implementation name/version that
|
||||
// `McpUiInitializeRequestSchema` requires, so the bridge answers an error
|
||||
// and stores nothing — this gate must not be the laxer of the two.
|
||||
const bridge = makeBridge({});
|
||||
const { transport } = makeTransport();
|
||||
observeAppCapabilities(bridge, transport);
|
||||
transport.onmessage?.({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "ui/initialize",
|
||||
params: {
|
||||
protocolVersion: "2026-01-26",
|
||||
appInfo: {},
|
||||
appCapabilities: { elicitation: {} },
|
||||
},
|
||||
} as unknown as JSONRPCMessage);
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat a non-object elicitation value as an advertisement", () => {
|
||||
// The draft declares `elicitation` as an object; `true` is a value it does
|
||||
// not define, and truthiness would accept it.
|
||||
const bridge = makeBridge({});
|
||||
const { transport } = makeTransport();
|
||||
observeAppCapabilities(bridge, transport);
|
||||
transport.onmessage?.(initializeFrame({ elicitation: true }));
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
||||
transport.onmessage?.(initializeFrame({ elicitation: [] }));
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
||||
transport.onmessage?.(initializeFrame({ elicitation: {} }));
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps bridges independent", () => {
|
||||
const a = makeBridge({});
|
||||
const b = makeBridge({});
|
||||
const first = makeTransport();
|
||||
const second = makeTransport();
|
||||
observeAppCapabilities(a, first.transport);
|
||||
observeAppCapabilities(b, second.transport);
|
||||
first.transport.onmessage?.(initializeFrame({ elicitation: {} }));
|
||||
expect(appAdvertisesElicitation(a)).toBe(true);
|
||||
expect(appAdvertisesElicitation(b)).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates a transport with no prior handler", () => {
|
||||
const bridge = makeBridge({});
|
||||
const transport = {} as unknown as Transport;
|
||||
observeAppCapabilities(bridge, transport);
|
||||
expect(() =>
|
||||
transport.onmessage?.(initializeFrame({ elicitation: {} })),
|
||||
).not.toThrow();
|
||||
expect(appAdvertisesElicitation(bridge)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import { McpUiInitializeRequestSchema } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
|
||||
/**
|
||||
* The app capabilities exactly as the view sent them, per bridge.
|
||||
*
|
||||
* `AppBridge.getAppCapabilities()` cannot be used for `elicitation` (#1854):
|
||||
* ext-apps 1.7.5 parses the view's `ui/initialize` params through
|
||||
* `McpUiAppCapabilitiesSchema`, a plain Zod object, so any key that schema does
|
||||
* not declare is **stripped before the bridge stores it**. `elicitation` is
|
||||
* exactly such a key — it is what ext-apps#733 adds — so an app that correctly
|
||||
* advertises it looks, through the bridge's own accessor, like an app that did
|
||||
* not. That silently turns every negotiated elicitation into a native-UI
|
||||
* fallback, which is indistinguishable from "the feature is off".
|
||||
*
|
||||
* So the raw frame is observed on the way in and recorded here. A WeakMap keeps
|
||||
* this per-bridge (never global) and lets the entry die with the bridge.
|
||||
*
|
||||
* Delete this module when ext-apps ships #733: `getAppCapabilities()` will
|
||||
* carry `elicitation` itself, and {@link appAdvertisesElicitation} already
|
||||
* prefers that value.
|
||||
*/
|
||||
const rawAppCapabilities = new WeakMap<AppBridge, Record<string, unknown>>();
|
||||
|
||||
/**
|
||||
* The only part of a transport this needs: the inbound-message callback.
|
||||
*
|
||||
* Structural, and generic over the message type, on purpose. ext-apps'
|
||||
* `PostMessageTransport` implements the SDK *v1* `Transport` — a different
|
||||
* nominal type from the v2 client's, though runtime-identical — so naming
|
||||
* either would force a cast at the call site. Generic rather than
|
||||
* `(message: unknown)` because a handler typed for a narrower message is not
|
||||
* assignable to one typed for `unknown` (contravariance), which would put the
|
||||
* cast back; `rest` is `never[]` for the same reason, accepting any trailing
|
||||
* parameter list without claiming to know it. The message is only ever read
|
||||
* through {@link recordAdvertisedCapabilities}, which narrows from `unknown`.
|
||||
*/
|
||||
export interface MessageObservable<TMessage = unknown> {
|
||||
onmessage?: (message: TMessage, ...rest: never[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The capabilities a view advertised in an `ui/initialize` the bridge will
|
||||
* ACCEPT, or `undefined` for any other frame.
|
||||
*
|
||||
* Acceptance is decided by the bridge's own `McpUiInitializeRequestSchema`
|
||||
* rather than a hand-rolled approximation of it — an approximation drifts, and
|
||||
* anything it waves through that the bridge rejects becomes a second, laxer
|
||||
* route to setting `elicitation` on a frame that negotiated nothing. The one
|
||||
* check the schema cannot make is that this is a request at all: a JSON-RPC
|
||||
* notification carries the same `method`/`params`, so the id is checked here.
|
||||
*
|
||||
* The capabilities are then read from the ORIGINAL frame, not the parse output,
|
||||
* because that schema is exactly what strips `elicitation` (see the WeakMap's
|
||||
* doc comment) — validating with it and reading through it would defeat the
|
||||
* purpose of this module.
|
||||
*/
|
||||
function acceptedInitializeCapabilities(
|
||||
message: unknown,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (typeof message !== "object" || message === null) return undefined;
|
||||
const frame = message as { id?: unknown; params?: unknown };
|
||||
// A notification (no id) is not the handshake request.
|
||||
if (frame.id === undefined || frame.id === null) return undefined;
|
||||
if (!McpUiInitializeRequestSchema.safeParse(message).success) {
|
||||
return undefined;
|
||||
}
|
||||
const advertised = (frame.params as { appCapabilities?: unknown })
|
||||
.appCapabilities;
|
||||
return typeof advertised === "object" && advertised !== null
|
||||
? (advertised as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record `params.appCapabilities` from the view's handshake.
|
||||
*
|
||||
* Every *accepted* `ui/initialize` replaces the recorded value, because that is
|
||||
* what the bridge does — on a second handshake (a view double-mounting under
|
||||
* React StrictMode, or reconnecting) it warns and takes the latest appInfo and
|
||||
* capabilities. Freezing this at the first frame would leave the gate reporting
|
||||
* capabilities the bridge no longer holds, in both directions.
|
||||
*
|
||||
* A frame the bridge would *reject* records nothing and leaves the previous
|
||||
* value alone: it is a route to changing this gate in neither direction.
|
||||
*/
|
||||
function recordAdvertisedCapabilities(
|
||||
bridge: AppBridge,
|
||||
message: unknown,
|
||||
): void {
|
||||
const advertised = acceptedInitializeCapabilities(message);
|
||||
if (advertised) rawAppCapabilities.set(bridge, advertised);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a connected bridge's transport so the view's `ui/initialize` params are
|
||||
* recorded before the bridge parses them.
|
||||
*
|
||||
* Call AFTER `bridge.connect(transport)` — `connect` installs the handler this
|
||||
* wraps. That ordering is safe: the view cannot send `ui/initialize` until the
|
||||
* host has pushed its HTML into the sandbox, which happens later still.
|
||||
*/
|
||||
export function observeAppCapabilities<TMessage>(
|
||||
bridge: AppBridge,
|
||||
transport: MessageObservable<TMessage>,
|
||||
): void {
|
||||
const inner = transport.onmessage?.bind(transport);
|
||||
transport.onmessage = (message: TMessage, ...rest: never[]) => {
|
||||
recordAdvertisedCapabilities(bridge, message);
|
||||
inner?.(message, ...rest);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app running on this bridge advertised the `elicitation`
|
||||
* capability. Prefers the bridge's own accessor (correct once ext-apps#733
|
||||
* ships) and falls back to the observed raw frame.
|
||||
*/
|
||||
export function appAdvertisesElicitation(bridge: AppBridge): boolean {
|
||||
const parsed = bridge.getAppCapabilities() as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return (
|
||||
isCapabilityObject(parsed?.elicitation) ||
|
||||
isCapabilityObject(rawAppCapabilities.get(bridge)?.elicitation)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A declared capability is an OBJECT (`{}` today, room for sub-options later),
|
||||
* which is what ext-apps#733 specifies. Truthiness is not the same test: an
|
||||
* `elicitation: true` is a value the draft does not define, and treating it as
|
||||
* an advertisement would accept something the bridge's own schema will reject
|
||||
* the moment it carries the key.
|
||||
*/
|
||||
function isCapabilityObject(value: unknown): boolean {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -156,6 +158,33 @@ describe("createAppBridgeFactory", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("does not advertise hostCapabilities.elicitation by default (#1854)", async () => {
|
||||
// An App-tool frame is never handed an elicitation, so claiming the host
|
||||
// capability there would tell those apps something untrue about the host.
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource: vi.fn(),
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
expect(bridgeInstances[0].ctorArgs[2]).not.toHaveProperty("elicitation");
|
||||
});
|
||||
|
||||
it("advertises hostCapabilities.elicitation when opted in (#1854)", async () => {
|
||||
// The value reaches the AppBridge CONSTRUCTOR, which is what the bridge
|
||||
// echoes in its `ui/initialize` response — an app reads it there to decide
|
||||
// whether this host will forward an elicitation to it at all.
|
||||
const factory = createAppBridgeFactory({
|
||||
advertiseElicitation: true,
|
||||
getClient: () => fakeClient,
|
||||
readResource: vi.fn(),
|
||||
});
|
||||
await factory(makeIframe(), {
|
||||
kind: "resource",
|
||||
resourceUri: "ui://demo/pick.html",
|
||||
});
|
||||
expect(bridgeInstances[0].ctorArgs[2]).toMatchObject({ elicitation: {} });
|
||||
});
|
||||
|
||||
it("on sandboxready, reads the UI resource, wraps the html with the per-app CSP, and echoes the approved sandbox config", async () => {
|
||||
const readResource = vi.fn().mockResolvedValue(
|
||||
uiResource("<h1>weather</h1>", {
|
||||
|
|
@ -167,7 +196,7 @@ describe("createAppBridgeFactory", () => {
|
|||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
});
|
||||
await factory(makeIframe(), tool);
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
const bridge = bridgeInstances[0];
|
||||
|
||||
bridge.emit("sandboxready");
|
||||
|
|
@ -215,7 +244,7 @@ describe("createAppBridgeFactory", () => {
|
|||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
});
|
||||
await factory(makeIframe(), tool);
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
bridgeInstances[0].emit("sandboxready");
|
||||
await flush();
|
||||
expect(HOST_CAPABILITIES.sandbox).toBeUndefined();
|
||||
|
|
@ -235,7 +264,7 @@ describe("createAppBridgeFactory", () => {
|
|||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
});
|
||||
await factory(makeIframe(), tool);
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
const bridge = bridgeInstances[0];
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
|
|
@ -256,8 +285,8 @@ describe("createAppBridgeFactory", () => {
|
|||
readResource,
|
||||
});
|
||||
await factory(makeIframe(), {
|
||||
name: "plain",
|
||||
inputSchema: { type: "object" },
|
||||
kind: "tool",
|
||||
tool: { name: "plain", inputSchema: { type: "object" } },
|
||||
});
|
||||
bridgeInstances[0].emit("sandboxready");
|
||||
await flush();
|
||||
|
|
@ -274,7 +303,7 @@ describe("createAppBridgeFactory", () => {
|
|||
readResource,
|
||||
onResourceError,
|
||||
});
|
||||
await factory(makeIframe(), tool);
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
const bridge = bridgeInstances[0];
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
|
|
@ -295,7 +324,7 @@ describe("createAppBridgeFactory", () => {
|
|||
readResource,
|
||||
onResourceError,
|
||||
});
|
||||
await factory(makeIframe(), tool);
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
const bridge = bridgeInstances[0];
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
|
|
@ -317,7 +346,7 @@ describe("createAppBridgeFactory", () => {
|
|||
readResource,
|
||||
onResourceError,
|
||||
});
|
||||
await factory(makeIframe(), tool);
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
const bridge = bridgeInstances[0];
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
|
|
@ -335,7 +364,7 @@ describe("createAppBridgeFactory", () => {
|
|||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
});
|
||||
await factory(makeIframe(), tool);
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
const bridge = bridgeInstances[0];
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
|
|
@ -352,7 +381,7 @@ describe("createAppBridgeFactory", () => {
|
|||
getClient: () => fakeClient,
|
||||
readResource: vi.fn().mockResolvedValue(uiResource("<h1>x</h1>")),
|
||||
});
|
||||
await factory(makeIframe(), tool);
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
const bridge = bridgeInstances[0];
|
||||
|
||||
await expect(
|
||||
|
|
@ -376,7 +405,7 @@ describe("createAppBridgeFactory", () => {
|
|||
getClient: () => fakeClient,
|
||||
readResource: vi.fn().mockResolvedValue(uiResource("<h1>x</h1>")),
|
||||
});
|
||||
await factory(makeIframe(), tool);
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
expect(bridgeInstances[0].ctorArgs[2]).toMatchObject({ downloadFile: {} });
|
||||
});
|
||||
|
||||
|
|
@ -394,7 +423,7 @@ describe("createAppBridgeFactory", () => {
|
|||
getClient: () => fakeClient,
|
||||
readResource: vi.fn().mockResolvedValue(uiResource("<h1>x</h1>")),
|
||||
});
|
||||
await factory(makeIframe(), tool);
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
return bridgeInstances[0];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,21 @@ import {
|
|||
isHttpUrl,
|
||||
} from "../../../lib/downloadFile";
|
||||
import { snapshotHostContext } from "./hostContext";
|
||||
import type { BridgeFactory } from "./AppRenderer";
|
||||
import { observeAppCapabilities } from "./appCapabilities";
|
||||
import type { AppRenderSource, BridgeFactory } from "./AppRenderer";
|
||||
|
||||
/**
|
||||
* The `ui://` resource a render source loads, or `undefined` for an App tool
|
||||
* whose `_meta` names none (in which case there is nothing to push into the
|
||||
* sandbox and the frame stays empty).
|
||||
*/
|
||||
function resolveSourceUri(source: AppRenderSource): string | undefined {
|
||||
return source.kind === "resource"
|
||||
? source.resourceUri
|
||||
: getToolUiResourceUri(
|
||||
source.tool as Parameters<typeof getToolUiResourceUri>[0],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Host identity advertised to MCP Apps during the bridge handshake. Static —
|
||||
|
|
@ -74,6 +88,16 @@ export interface AppBridgeFactoryDeps {
|
|||
* frame; the error is also always console.error'd.
|
||||
*/
|
||||
onResourceError?: (err: Error) => void;
|
||||
/**
|
||||
* Advertise `hostCapabilities.elicitation` — "this host can forward a
|
||||
* form-mode `elicitation/create` to the app and return its result to the
|
||||
* server" (#1854).
|
||||
*
|
||||
* Off by default, and deliberately per-factory rather than global: an App
|
||||
* *tool* frame is never handed an elicitation, so claiming the capability
|
||||
* there would tell the app something untrue about what its host will do.
|
||||
*/
|
||||
advertiseElicitation?: boolean;
|
||||
}
|
||||
|
||||
/** First text content block of a UI resource, plus its `_meta` (sandbox hints). */
|
||||
|
|
@ -198,7 +222,7 @@ function downloadResourceItem(item: EmbeddedResource | ResourceLink): boolean {
|
|||
export function createAppBridgeFactory(
|
||||
deps: AppBridgeFactoryDeps,
|
||||
): BridgeFactory {
|
||||
return async (iframe, tool) => {
|
||||
return async (iframe, source) => {
|
||||
const client = deps.getClient();
|
||||
if (!client) {
|
||||
throw new Error("Cannot render MCP App: no connected MCP client.");
|
||||
|
|
@ -211,7 +235,17 @@ export function createAppBridgeFactory(
|
|||
// Per-app copy so the approved-sandbox echo (set on sandboxready below)
|
||||
// never mutates the shared HOST_CAPABILITIES constant — each app may
|
||||
// declare its own csp/permissions.
|
||||
const hostCapabilities: McpUiHostCapabilities = { ...HOST_CAPABILITIES };
|
||||
const hostCapabilities: McpUiHostCapabilities = {
|
||||
...HOST_CAPABILITIES,
|
||||
// `elicitation` is not part of ext-apps 1.7.5's `McpUiHostCapabilities`
|
||||
// (ext-apps#733 adds it), so it is spread in as an extra key. The bridge
|
||||
// forwards the capabilities object verbatim in its `ui/initialize`
|
||||
// response, which is exactly what the app reads. TODO: drop the cast when
|
||||
// a release containing #733 ships.
|
||||
...(deps.advertiseElicitation
|
||||
? ({ elicitation: {} } as Partial<McpUiHostCapabilities>)
|
||||
: {}),
|
||||
};
|
||||
// ext-apps' `AppBridge` peers on SDK v1's `Client`/`Implementation`; both
|
||||
// are runtime-compatible with v2's. Cast at this single construction
|
||||
// boundary. TODO: drop when ext-apps#702 ships a v2 peer release.
|
||||
|
|
@ -234,9 +268,7 @@ export function createAppBridgeFactory(
|
|||
bridge.addEventListener("sandboxready", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const uri = getToolUiResourceUri(
|
||||
tool as Parameters<typeof getToolUiResourceUri>[0],
|
||||
);
|
||||
const uri = resolveSourceUri(source);
|
||||
if (!uri) return;
|
||||
const result = await deps.readResource(uri);
|
||||
const { html, meta } = extractHtmlAndMeta(result);
|
||||
|
|
@ -338,6 +370,10 @@ export function createAppBridgeFactory(
|
|||
|
||||
const transport = new PostMessageTransport(targetWindow, targetWindow);
|
||||
await bridge.connect(transport);
|
||||
// Record the view's raw `ui/initialize` capabilities before the bridge's
|
||||
// own schema strips the keys it predates (#1854). Must follow `connect`,
|
||||
// which is what installs the handler this wraps.
|
||||
observeAppCapabilities(bridge, transport);
|
||||
return bridge;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
197
clients/web/src/lib/appElicitationController.test.ts
Normal file
197
clients/web/src/lib/appElicitationController.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ElicitRequest } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
AppElicitationController,
|
||||
type AppElicitationSession,
|
||||
} from "./appElicitationController";
|
||||
|
||||
const params: ElicitRequest["params"] = {
|
||||
message: "Choose an option",
|
||||
requestedSchema: { type: "object", properties: {} },
|
||||
};
|
||||
|
||||
/**
|
||||
* Queue a request through a session. Defaults to one session per controller,
|
||||
* which is what a single connection looks like; the session tests open their
|
||||
* own to model a client swap.
|
||||
*/
|
||||
function request(
|
||||
controller: AppElicitationController,
|
||||
requestId: string,
|
||||
resourceUri = "ui://demo/pick.html",
|
||||
signal: AbortSignal = new AbortController().signal,
|
||||
session: AppElicitationSession = defaultSession(controller),
|
||||
) {
|
||||
return session.render({ requestId, resourceUri, params, signal });
|
||||
}
|
||||
|
||||
const sessions = new WeakMap<AppElicitationController, AppElicitationSession>();
|
||||
function defaultSession(
|
||||
controller: AppElicitationController,
|
||||
): AppElicitationSession {
|
||||
let session = sessions.get(controller);
|
||||
if (!session) {
|
||||
session = controller.openSession();
|
||||
sessions.set(controller, session);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
describe("AppElicitationController (#1854)", () => {
|
||||
it("queues a request and notifies subscribers", () => {
|
||||
const controller = new AppElicitationController();
|
||||
const listener = vi.fn();
|
||||
controller.subscribe(listener);
|
||||
|
||||
void request(controller, "a").catch(() => {});
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(controller.getEntries()).toHaveLength(1);
|
||||
expect(controller.getEntries()[0]).toMatchObject({
|
||||
requestId: "a",
|
||||
resourceUri: "ui://demo/pick.html",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the entries array identity stable between changes", () => {
|
||||
// `useSyncExternalStore` re-renders on every getSnapshot identity change,
|
||||
// so a fresh array per read would loop forever.
|
||||
const controller = new AppElicitationController();
|
||||
expect(controller.getEntries()).toBe(controller.getEntries());
|
||||
});
|
||||
|
||||
it("settle resolves the render promise and drops the entry", async () => {
|
||||
const controller = new AppElicitationController();
|
||||
const pending = request(controller, "a");
|
||||
controller.settle("a", { action: "accept", content: { choice: "x" } });
|
||||
await expect(pending).resolves.toEqual({
|
||||
action: "accept",
|
||||
content: { choice: "x" },
|
||||
});
|
||||
expect(controller.getEntries()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fail rejects the render promise so the client falls back", async () => {
|
||||
const controller = new AppElicitationController();
|
||||
const pending = request(controller, "a");
|
||||
controller.fail("a", new Error("sandbox unavailable"));
|
||||
await expect(pending).rejects.toThrow(/sandbox unavailable/);
|
||||
expect(controller.getEntries()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("settling an unknown or already-settled id is a no-op", async () => {
|
||||
const controller = new AppElicitationController();
|
||||
const pending = request(controller, "a");
|
||||
controller.settle("a", { action: "cancel" });
|
||||
await expect(pending).resolves.toEqual({ action: "cancel" });
|
||||
// A second settle must not throw — the modal's unmount and the client's
|
||||
// abort can race, and both call in.
|
||||
expect(() => controller.settle("a", { action: "decline" })).not.toThrow();
|
||||
expect(() => controller.fail("nope", new Error("x"))).not.toThrow();
|
||||
});
|
||||
|
||||
it("keeps concurrent requests independent", async () => {
|
||||
const controller = new AppElicitationController();
|
||||
const first = request(controller, "a", "ui://demo/first.html");
|
||||
const second = request(controller, "b", "ui://demo/second.html");
|
||||
expect(controller.getEntries().map((e) => e.requestId)).toEqual(["a", "b"]);
|
||||
|
||||
controller.settle("b", { action: "accept", content: { choice: "b" } });
|
||||
expect(controller.getEntries().map((e) => e.requestId)).toEqual(["a"]);
|
||||
await expect(second).resolves.toMatchObject({ content: { choice: "b" } });
|
||||
|
||||
controller.settle("a", { action: "accept", content: { choice: "a" } });
|
||||
await expect(first).resolves.toMatchObject({ content: { choice: "a" } });
|
||||
});
|
||||
|
||||
it("drops and rejects an entry when the originating request aborts", async () => {
|
||||
const controller = new AppElicitationController();
|
||||
const aborter = new AbortController();
|
||||
const pending = request(
|
||||
controller,
|
||||
"a",
|
||||
"ui://demo/pick.html",
|
||||
aborter.signal,
|
||||
);
|
||||
expect(controller.getEntries()).toHaveLength(1);
|
||||
aborter.abort();
|
||||
await expect(pending).rejects.toThrow(/aborted/);
|
||||
expect(controller.getEntries()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never queues a request whose signal already aborted", async () => {
|
||||
const controller = new AppElicitationController();
|
||||
const aborter = new AbortController();
|
||||
aborter.abort();
|
||||
await expect(
|
||||
request(controller, "a", "ui://demo/pick.html", aborter.signal),
|
||||
).rejects.toThrow(/aborted/);
|
||||
expect(controller.getEntries()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("closing a session drops the entries that session queued", async () => {
|
||||
// The host closes the old session when the InspectorClient is replaced: the
|
||||
// bridge factory resolves its client at call time, so an entry left queued
|
||||
// could otherwise rebuild against the NEXT connection and answer through a
|
||||
// different server.
|
||||
const controller = new AppElicitationController();
|
||||
const session = controller.openSession();
|
||||
const listener = vi.fn();
|
||||
const first = request(controller, "a", "ui://a", undefined, session);
|
||||
const second = request(controller, "b", "ui://b", undefined, session);
|
||||
controller.subscribe(listener);
|
||||
|
||||
session.close(new Error("connection replaced"));
|
||||
|
||||
await expect(first).rejects.toThrow(/connection replaced/);
|
||||
await expect(second).rejects.toThrow(/connection replaced/);
|
||||
expect(controller.getEntries()).toHaveLength(0);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refuses a request a closed session makes during its own teardown", async () => {
|
||||
// The half a one-shot sweep cannot provide: the replaced client
|
||||
// disconnects asynchronously and can still enqueue, and that entry would be
|
||||
// rendered by a factory bound to the REPLACEMENT client.
|
||||
const controller = new AppElicitationController();
|
||||
const session = controller.openSession();
|
||||
session.close(new Error("connection replaced"));
|
||||
await expect(
|
||||
request(controller, "late", "ui://a", undefined, session),
|
||||
).rejects.toThrow(/session is closed/);
|
||||
expect(controller.getEntries()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("leaves another session's entries alone", async () => {
|
||||
const controller = new AppElicitationController();
|
||||
const oldSession = controller.openSession();
|
||||
const newSession = controller.openSession();
|
||||
const stale = request(controller, "a", "ui://a", undefined, oldSession);
|
||||
const live = request(controller, "b", "ui://b", undefined, newSession);
|
||||
|
||||
oldSession.close(new Error("connection replaced"));
|
||||
|
||||
await expect(stale).rejects.toThrow(/connection replaced/);
|
||||
expect(controller.getEntries().map((e) => e.requestId)).toEqual(["b"]);
|
||||
controller.settle("b", { action: "cancel" });
|
||||
await expect(live).resolves.toEqual({ action: "cancel" });
|
||||
});
|
||||
|
||||
it("closing a session with nothing queued notifies nobody", () => {
|
||||
const controller = new AppElicitationController();
|
||||
const session = controller.openSession();
|
||||
const listener = vi.fn();
|
||||
controller.subscribe(listener);
|
||||
session.close(new Error("nothing to drop"));
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops notifying an unsubscribed listener", () => {
|
||||
const controller = new AppElicitationController();
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = controller.subscribe(listener);
|
||||
unsubscribe();
|
||||
void request(controller, "a").catch(() => {});
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
157
clients/web/src/lib/appElicitationController.ts
Normal file
157
clients/web/src/lib/appElicitationController.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import type { ElicitResult } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
AppElicitationRenderer,
|
||||
AppElicitationRequest,
|
||||
} from "@inspector/core/mcp/appElicitation.js";
|
||||
|
||||
/**
|
||||
* One app-rendered elicitation awaiting an answer, plus the settle functions
|
||||
* for the `InspectorClient` promise it belongs to (#1854).
|
||||
*
|
||||
* The entry — not a "currently active app" — is what owns the renderer, so two
|
||||
* concurrent elicitations each drive their own iframe and bridge and cannot
|
||||
* resolve through each other's.
|
||||
*/
|
||||
export interface AppElicitationEntry extends AppElicitationRequest {
|
||||
/** The session (one InspectorClient) this request belongs to. */
|
||||
sessionId: number;
|
||||
/** Hands the app's standard `ElicitResult` back to the server. */
|
||||
resolve: (result: ElicitResult) => void;
|
||||
/** Asks `InspectorClient` to fall back to the native elicitation UI. */
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One client's window onto the controller.
|
||||
*
|
||||
* Requests are bound to the connection that made them. A closed session
|
||||
* rejects immediately, which is what a one-shot sweep cannot do: an
|
||||
* `InspectorClient` being replaced disconnects asynchronously and can still
|
||||
* enqueue during its own teardown, and that late entry would otherwise be
|
||||
* rendered by a factory bound to the *replacement* client — reading its
|
||||
* resource, and answering, through a different server.
|
||||
*/
|
||||
export interface AppElicitationSession {
|
||||
/** The renderer handed to this client's `InspectorClient`. */
|
||||
render: AppElicitationRenderer;
|
||||
/** Reject everything from this session and refuse anything later. */
|
||||
close: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges `InspectorClient`'s renderer callback — supplied at construction,
|
||||
* long before any React tree exists — to the React component that actually
|
||||
* mounts the app.
|
||||
*
|
||||
* The client is given {@link render} once and for all; the UI subscribes and
|
||||
* re-renders as entries come and go. Without this indirection the renderer
|
||||
* would have to be rebuilt (and the client reconstructed) whenever the host
|
||||
* component remounted.
|
||||
*/
|
||||
export class AppElicitationController {
|
||||
private entries: AppElicitationEntry[] = [];
|
||||
private listeners = new Set<() => void>();
|
||||
private nextSessionId = 0;
|
||||
/** Sessions still accepting requests. A closed one is removed. */
|
||||
private openSessions = new Set<number>();
|
||||
|
||||
/** Current queue. Stable identity between changes, for `useSyncExternalStore`. */
|
||||
getEntries = (): AppElicitationEntry[] => this.entries;
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
private emit(): void {
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a window for one `InspectorClient`. Its `render` is what that client
|
||||
* is constructed with; `close` ends it when the client is replaced.
|
||||
*/
|
||||
openSession(): AppElicitationSession {
|
||||
const sessionId = this.nextSessionId++;
|
||||
this.openSessions.add(sessionId);
|
||||
return {
|
||||
render: (request) => this.render(sessionId, request),
|
||||
close: (error) => this.closeSession(sessionId, error),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a request for the UI and resolve when {@link settle} or {@link fail}
|
||||
* is called for it.
|
||||
*
|
||||
* An abort of the originating request (cancelled tool call, disconnect) drops
|
||||
* the entry and rejects, so a modal can never outlive the request behind it.
|
||||
* A request from a closed session is rejected without ever being queued.
|
||||
*/
|
||||
private render = (sessionId: number, request: AppElicitationRequest) =>
|
||||
new Promise<ElicitResult>((resolve, reject) => {
|
||||
if (!this.openSessions.has(sessionId)) {
|
||||
reject(new Error("App elicitation session is closed"));
|
||||
return;
|
||||
}
|
||||
const entry: AppElicitationEntry = {
|
||||
...request,
|
||||
sessionId,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
const onAbort = () => {
|
||||
this.remove(entry.requestId);
|
||||
reject(new Error("App-rendered elicitation aborted"));
|
||||
};
|
||||
if (request.signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
request.signal.addEventListener("abort", onAbort, { once: true });
|
||||
this.entries = [...this.entries, entry];
|
||||
this.emit();
|
||||
});
|
||||
|
||||
private take(requestId: string): AppElicitationEntry | undefined {
|
||||
const entry = this.entries.find((e) => e.requestId === requestId);
|
||||
if (entry) this.remove(requestId);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private remove(requestId: string): void {
|
||||
const next = this.entries.filter((e) => e.requestId !== requestId);
|
||||
if (next.length === this.entries.length) return;
|
||||
this.entries = next;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete an elicitation with the app's result. `decline` and `cancel` are
|
||||
* completions too — they are returned to the server, not fallen back on.
|
||||
*/
|
||||
settle(requestId: string, result: ElicitResult): void {
|
||||
this.take(requestId)?.resolve(result);
|
||||
}
|
||||
|
||||
/** Give up on the app and let the native elicitation UI take the request. */
|
||||
fail(requestId: string, error: Error): void {
|
||||
this.take(requestId)?.reject(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* End a session: reject everything it queued, and refuse anything it queues
|
||||
* later. The refusal is the half a one-shot sweep cannot provide — see
|
||||
* {@link AppElicitationSession}.
|
||||
*/
|
||||
private closeSession(sessionId: number, error: Error): void {
|
||||
this.openSessions.delete(sessionId);
|
||||
const dropped = this.entries.filter((e) => e.sessionId === sessionId);
|
||||
if (dropped.length === 0) return;
|
||||
this.entries = this.entries.filter((e) => e.sessionId !== sessionId);
|
||||
this.emit();
|
||||
for (const entry of dropped) entry.reject(error);
|
||||
}
|
||||
}
|
||||
286
clients/web/src/test/core/mcp/appElicitation.test.ts
Normal file
286
clients/web/src/test/core/mcp/appElicitation.test.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type {
|
||||
ClientCapabilities,
|
||||
ElicitRequest,
|
||||
ElicitResult,
|
||||
ServerCapabilities,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv";
|
||||
import {
|
||||
getElicitationUiResourceUri,
|
||||
getUiClientCapability,
|
||||
getUiServerCapability,
|
||||
isFormElicitation,
|
||||
supportsAppElicitation,
|
||||
validateAppElicitResult,
|
||||
} from "@inspector/core/mcp/appElicitation.js";
|
||||
import {
|
||||
MCP_APP_MIME_TYPE,
|
||||
UI_EXTENSION_KEY,
|
||||
} from "@inspector/core/mcp/extensions.js";
|
||||
|
||||
/** A client that satisfies all three client-side negotiation gates. */
|
||||
function eligibleClient(): ClientCapabilities {
|
||||
return {
|
||||
elicitation: { form: {} },
|
||||
extensions: {
|
||||
[UI_EXTENSION_KEY]: {
|
||||
mimeTypes: [MCP_APP_MIME_TYPE],
|
||||
elicitation: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A server that advertises the nested MCP Apps elicitation setting. */
|
||||
function eligibleServer(): ServerCapabilities {
|
||||
return { extensions: { [UI_EXTENSION_KEY]: { elicitation: {} } } };
|
||||
}
|
||||
|
||||
function formParams(
|
||||
extra: Partial<ElicitRequest["params"]> = {},
|
||||
): ElicitRequest["params"] {
|
||||
return {
|
||||
message: "Choose an option",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: { choice: { type: "string" } },
|
||||
required: ["choice"],
|
||||
},
|
||||
...extra,
|
||||
} as ElicitRequest["params"];
|
||||
}
|
||||
|
||||
describe("appElicitation negotiation (#1854)", () => {
|
||||
describe("capability readers", () => {
|
||||
it("reads the UI block from either side", () => {
|
||||
expect(getUiClientCapability(eligibleClient())).toEqual({
|
||||
mimeTypes: [MCP_APP_MIME_TYPE],
|
||||
elicitation: {},
|
||||
});
|
||||
expect(getUiServerCapability(eligibleServer())).toEqual({
|
||||
elicitation: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined for absent, null and non-object capabilities", () => {
|
||||
expect(getUiClientCapability(undefined)).toBeUndefined();
|
||||
expect(getUiClientCapability(null)).toBeUndefined();
|
||||
expect(getUiClientCapability({})).toBeUndefined();
|
||||
expect(getUiServerCapability({ extensions: {} })).toBeUndefined();
|
||||
expect(
|
||||
getUiClientCapability({
|
||||
extensions: { [UI_EXTENSION_KEY]: null },
|
||||
} as unknown as ClientCapabilities),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsAppElicitation", () => {
|
||||
it("is true only when all four gates pass", () => {
|
||||
expect(supportsAppElicitation(eligibleClient(), eligibleServer())).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("is false without the core form elicitation capability", () => {
|
||||
const client = eligibleClient();
|
||||
delete client.elicitation;
|
||||
expect(supportsAppElicitation(client, eligibleServer())).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the client advertised only url-mode elicitation", () => {
|
||||
const client = { ...eligibleClient(), elicitation: { url: {} } };
|
||||
expect(supportsAppElicitation(client, eligibleServer())).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the client does not accept the MCP App MIME type", () => {
|
||||
const client: ClientCapabilities = {
|
||||
elicitation: { form: {} },
|
||||
extensions: {
|
||||
[UI_EXTENSION_KEY]: { mimeTypes: ["text/html"], elicitation: {} },
|
||||
},
|
||||
};
|
||||
expect(supportsAppElicitation(client, eligibleServer())).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the client advertises the MIME type but not elicitation", () => {
|
||||
// The specific "MIME type alone is not sufficient" case: this is exactly
|
||||
// what a CLI/TUI client looks like, and it must not be offered an app.
|
||||
const client: ClientCapabilities = {
|
||||
elicitation: { form: {} },
|
||||
extensions: {
|
||||
[UI_EXTENSION_KEY]: { mimeTypes: [MCP_APP_MIME_TYPE] },
|
||||
},
|
||||
};
|
||||
expect(supportsAppElicitation(client, eligibleServer())).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the server did not advertise it", () => {
|
||||
expect(supportsAppElicitation(eligibleClient(), {})).toBe(false);
|
||||
expect(
|
||||
supportsAppElicitation(eligibleClient(), {
|
||||
extensions: { [UI_EXTENSION_KEY]: {} },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(supportsAppElicitation(eligibleClient(), undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getElicitationUiResourceUri", () => {
|
||||
it("returns the URI from _meta.ui.resourceUri", () => {
|
||||
expect(
|
||||
getElicitationUiResourceUri(
|
||||
formParams({ _meta: { ui: { resourceUri: "ui://demo/pick.html" } } }),
|
||||
),
|
||||
).toBe("ui://demo/pick.html");
|
||||
});
|
||||
|
||||
it("returns undefined when no app is attached", () => {
|
||||
expect(getElicitationUiResourceUri(formParams())).toBeUndefined();
|
||||
expect(
|
||||
getElicitationUiResourceUri(formParams({ _meta: {} })),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getElicitationUiResourceUri(formParams({ _meta: { ui: "nope" } })),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getElicitationUiResourceUri(formParams({ _meta: { ui: null } })),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getElicitationUiResourceUri(formParams({ _meta: { ui: {} } })),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws on a non-string resourceUri", () => {
|
||||
expect(() =>
|
||||
getElicitationUiResourceUri(
|
||||
formParams({ _meta: { ui: { resourceUri: 42 } } }),
|
||||
),
|
||||
).toThrow(/must be a string/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["relative", "demo/pick.html"],
|
||||
["wrong scheme", "https://example.com/pick.html"],
|
||||
["scheme with no host", "ui:///pick.html"],
|
||||
["bare scheme", "ui:pick.html"],
|
||||
["not a URL at all", " "],
|
||||
])("throws on a %s URI", (_label, uri) => {
|
||||
expect(() =>
|
||||
getElicitationUiResourceUri(
|
||||
formParams({ _meta: { ui: { resourceUri: uri } } }),
|
||||
),
|
||||
).toThrow(/absolute ui:\/\/ URI/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFormElicitation", () => {
|
||||
it("treats an omitted mode as form", () => {
|
||||
expect(isFormElicitation(formParams())).toBe(true);
|
||||
expect(isFormElicitation(formParams({ mode: "form" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects url mode", () => {
|
||||
expect(isFormElicitation(formParams({ mode: "url" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAppElicitResult", () => {
|
||||
const provider = new AjvJsonSchemaValidator();
|
||||
const params = formParams();
|
||||
|
||||
it("accepts a well-formed accept", () => {
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, {
|
||||
action: "accept",
|
||||
content: { choice: "option-a" },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts decline and cancel with no content", () => {
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, { action: "decline" }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, { action: "cancel" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a non-object result", () => {
|
||||
expect(
|
||||
validateAppElicitResult(
|
||||
provider,
|
||||
params,
|
||||
null as unknown as ElicitResult,
|
||||
),
|
||||
).toMatch(/invalid elicitation result/);
|
||||
});
|
||||
|
||||
it("rejects an unknown action", () => {
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, {
|
||||
action: "maybe",
|
||||
} as unknown as ElicitResult),
|
||||
).toMatch(/invalid elicitation result/);
|
||||
});
|
||||
|
||||
it("rejects a decline whose content the standard result forbids", () => {
|
||||
// Checking `action` alone would wave this through: `ElicitResult` permits
|
||||
// only primitives and string arrays in `content`, so a nested object
|
||||
// would reach the server as a result it can legitimately reject.
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, {
|
||||
action: "decline",
|
||||
content: { x: {} },
|
||||
} as unknown as ElicitResult),
|
||||
).toMatch(/invalid elicitation result/);
|
||||
});
|
||||
|
||||
it("rejects an accept with no usable content", () => {
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, {
|
||||
action: "accept",
|
||||
} as ElicitResult),
|
||||
).toMatch(/without a content object/);
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, {
|
||||
action: "accept",
|
||||
content: [] as unknown as Record<string, never>,
|
||||
} as ElicitResult),
|
||||
).toMatch(/invalid elicitation result|without a content object/);
|
||||
});
|
||||
|
||||
it("rejects content that does not match the requested schema", () => {
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, {
|
||||
action: "accept",
|
||||
content: { choice: 7 } as unknown as Record<string, never>,
|
||||
} as ElicitResult),
|
||||
).toMatch(/does not match the requested schema/);
|
||||
});
|
||||
|
||||
it("skips validation when the request declared no usable schema", () => {
|
||||
const noSchema = { message: "hi" } as ElicitRequest["params"];
|
||||
expect(
|
||||
validateAppElicitResult(provider, noSchema, {
|
||||
action: "accept",
|
||||
content: { anything: true },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not reject a result over a schema the validator cannot compile", () => {
|
||||
const badSchema = formParams({
|
||||
requestedSchema: { type: "object", properties: { a: { type: 9 } } },
|
||||
} as unknown as Partial<ElicitRequest["params"]>);
|
||||
expect(
|
||||
validateAppElicitResult(provider, badSchema, {
|
||||
action: "accept",
|
||||
content: { a: 1 },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -145,4 +145,42 @@ describe("extensions (#1738, #1740)", () => {
|
|||
expect(map).toEqual({ [EMA_EXTENSION_KEY]: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe("app-rendered elicitation opt-in (#1854)", () => {
|
||||
it("does not advertise the nested elicitation setting by default", () => {
|
||||
const map = buildClientExtensions({ enterpriseManaged: false });
|
||||
expect(map[UI_EXTENSION_KEY]).toEqual(UI_ADVERTISEMENT);
|
||||
});
|
||||
|
||||
it("nests `elicitation` inside the UI extension when opted in", () => {
|
||||
const map = buildClientExtensions({
|
||||
enterpriseManaged: false,
|
||||
appElicitation: true,
|
||||
});
|
||||
expect(map[UI_EXTENSION_KEY]).toEqual({
|
||||
...UI_ADVERTISEMENT,
|
||||
elicitation: {},
|
||||
});
|
||||
// A nested setting, NOT a second extension — the contract is explicit
|
||||
// that no new extension id is introduced.
|
||||
expect(Object.keys(map)).toEqual([TASKS_EXTENSION_KEY, UI_EXTENSION_KEY]);
|
||||
});
|
||||
|
||||
it("advertises nothing when the UI extension itself is turned off", () => {
|
||||
const map = buildClientExtensions({
|
||||
enterpriseManaged: false,
|
||||
appElicitation: true,
|
||||
advertised: { [UI_EXTENSION_KEY]: false },
|
||||
});
|
||||
expect(map).not.toHaveProperty(UI_EXTENSION_KEY);
|
||||
});
|
||||
|
||||
it("does not mutate the shared registry advertisement", () => {
|
||||
buildClientExtensions({ enterpriseManaged: false, appElicitation: true });
|
||||
const ui = ADVERTISABLE_EXTENSIONS.find(
|
||||
(e) => e.key === UI_EXTENSION_KEY,
|
||||
);
|
||||
expect(ui?.advertisement).toEqual(UI_ADVERTISEMENT);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue