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
14 changed files with 4824 additions and 139 deletions
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue