Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ interface MockBridge {
close: ReturnType<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
onrequestdisplaymode?: (params: {
mode: "inline" | "fullscreen" | "pip";
}) => Promise<{ mode: "inline" | "fullscreen" | "pip" }>;
/** Test helper: dispatch a bridge event (e.g. "initialized") to listeners. */
emit: (event: string, payload?: unknown) => void;
}
Expand Down Expand Up @@ -232,6 +235,92 @@ describe("AppRenderer", () => {
});
});

it("forwards view size-changed notifications to onSizeChange", async () => {
const bridge = createMockBridge();
const onSizeChange = vi.fn();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
onSizeChange={onSizeChange}
/>,
);
await flushAsync();
await act(async () => {
bridge.emit("sizechange", { width: 480, height: 600 });
});
expect(onSizeChange).toHaveBeenCalledWith({ width: 480, height: 600 });
});

it("does not throw on size-changed when no onSizeChange is provided", async () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
bridge.emit("sizechange", { height: 320 });
});
expect(screen.getByTitle("Cohort App")).toBeInTheDocument();
});

it("routes ui/request-display-mode to onRequestDisplayMode and returns the applied mode", async () => {
const bridge = createMockBridge();
const onRequestDisplayMode = vi
.fn<(m: "inline" | "fullscreen" | "pip") => "inline" | "fullscreen">()
.mockReturnValue("fullscreen");
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
displayMode="inline"
onRequestDisplayMode={onRequestDisplayMode}
/>,
);
await flushAsync();
await expect(
bridge.onrequestdisplaymode?.({ mode: "fullscreen" }),
).resolves.toEqual({ mode: "fullscreen" });
expect(onRequestDisplayMode).toHaveBeenCalledWith("fullscreen");
});

it("declines ui/request-display-mode by returning the current displayMode when no handler is provided", async () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
displayMode="fullscreen"
/>,
);
await flushAsync();
await expect(
bridge.onrequestdisplaymode?.({ mode: "pip" }),
).resolves.toEqual({ mode: "fullscreen" });
});

it("declines ui/request-display-mode with inline when neither a handler nor displayMode is set", async () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await expect(
bridge.onrequestdisplaymode?.({ mode: "pip" }),
).resolves.toEqual({ mode: "inline" });
});

it("pushes a displayMode change to the running view via host-context-changed", async () => {
const bridge = createMockBridge();
// Stable factory identity so the rerender reuses the live bridge instead of
Expand Down
38 changes: 38 additions & 0 deletions clients/web/src/components/elements/AppRenderer/AppRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "react";
import type {
AppBridge,
AppBridgeEventMap,
McpUiDisplayMode,
} from "@modelcontextprotocol/ext-apps/app-bridge";
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
Expand Down Expand Up @@ -41,12 +42,26 @@ export interface AppRendererProps {
tool: Tool;
bridgeFactory: BridgeFactory;
onError?: (err: Error) => void;
/**
* Called when the running view reports a new rendered content size via
* `ui/notifications/size-changed` (typically driven by its `ResizeObserver`).
* Width and height (px) are both optional. The host uses this to resize the
* iframe's container so the widget is neither clipped nor padded with dead
* space.
*/
onSizeChange?: (size: AppBridgeEventMap["sizechange"]) => void;
/**
* Current host display mode for the app frame. Pushed to the running view
* via `host-context-changed` whenever it changes (e.g. Maximize/Restore), so
* an app can adapt its layout to inline vs fullscreen.
*/
displayMode?: McpUiDisplayMode;
/**
* Handles a view-originated `ui/request-display-mode`. Return the mode the
* host actually applied — the spec lets the host decline an unsupported mode
* by returning its current one.
*/
onRequestDisplayMode?: (requested: McpUiDisplayMode) => McpUiDisplayMode;
/**
* The host-controlled box the app renders within, used to derive
* `hostContext.containerDimensions`. This MUST be an element whose size is
Expand Down Expand Up @@ -102,7 +117,9 @@ export function AppRenderer({
tool,
bridgeFactory,
onError,
onSizeChange,
displayMode,
onRequestDisplayMode,
containerRef,
ref,
}: AppRendererProps) {
Expand All @@ -123,8 +140,14 @@ export function AppRenderer({
tool: Tool;
} | null>(null);
const onErrorRef = useRef(onError);
const onSizeChangeRef = useRef(onSizeChange);
const displayModeRef = useRef(displayMode);
const onRequestDisplayModeRef = useRef(onRequestDisplayMode);
useEffect(() => {
onErrorRef.current = onError;
onSizeChangeRef.current = onSizeChange;
displayModeRef.current = displayMode;
onRequestDisplayModeRef.current = onRequestDisplayMode;
});

// Flush buffered tool input/result to the view, but only once the bridge
Expand Down Expand Up @@ -249,6 +272,21 @@ export function AppRenderer({
}
flushPending();
});
// Forward the view's content-size reports (ui/notifications/size-changed)
// so the host can resize the iframe container to fit the rendered widget.
bridge.addEventListener("sizechange", (size) => {
onSizeChangeRef.current?.(size);
});
// Handle ui/request-display-mode: let the host (AppsScreen) decide what
// mode to actually apply and return that. With no handler the request is
// declined by returning the current host-side mode.
bridge.onrequestdisplaymode = async ({ mode }) => {
const handler = onRequestDisplayModeRef.current;
const applied = handler
? handler(mode)
: (displayModeRef.current ?? "inline");
return { mode: applied };
};
flushPending();
})
.catch((err) => {
Expand Down
167 changes: 167 additions & 0 deletions clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createRef, useState } from "react";
import { act } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { McpUiDisplayMode } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import {
Expand Down Expand Up @@ -55,10 +57,68 @@ const okBridgeFactory: BridgeFactory = () =>
sendToolInput: async () => {},
sendToolResult: async () => {},
sendToolCancelled: async () => {},
sendHostContextChange: async () => {},
addEventListener: () => {},
removeEventListener: () => {},
teardownResource: async () => ({}),
close: async () => {},
}) as unknown as AppBridge;

// A bridge factory whose bridge supports the addEventListener/emit surface the
// AppRenderer wires up, so a test can drive a bridge event (sizechange) through
// to the screen's handling. `emit` dispatches to the captured listeners;
// `bridges` exposes each built bridge so tests can also drive the per-bridge
// handlers (onrequestdisplaymode).
function createEventBridgeFactory(): {
factory: BridgeFactory;
bridges: AppBridge[];
emit: (event: string, payload?: unknown) => void;
} {
const listeners: Record<string, ((payload: unknown) => void)[]> = {};
const bridges: AppBridge[] = [];
const factory: BridgeFactory = () => {
const bridge = {
sendToolInput: async () => {},
sendToolResult: async () => {},
sendToolCancelled: async () => {},
sendHostContextChange: async () => {},
teardownResource: async () => ({}),
close: async () => {},
addEventListener: (event: string, handler: (p: unknown) => void) => {
(listeners[event] ??= []).push(handler);
},
removeEventListener: () => {},
} as unknown as AppBridge;
bridges.push(bridge);
return bridge;
};
return {
factory,
bridges,
emit: (event, payload) =>
(listeners[event] ?? []).forEach((h) => h(payload)),
};
}

// Invoke the onrequestdisplaymode handler the screen attached to the latest
// bridge, mimicking a ui/request-display-mode request from the running view.
async function requestDisplayMode(
bridges: AppBridge[],
mode: McpUiDisplayMode,
): Promise<{ mode: McpUiDisplayMode }> {
const handler = bridges.at(-1)?.onrequestdisplaymode as unknown as
| ((params: { mode: McpUiDisplayMode }) => Promise<{
mode: McpUiDisplayMode;
}>)
| undefined;
if (!handler) throw new Error("no onrequestdisplaymode handler attached");
let result: { mode: McpUiDisplayMode } = { mode };
await act(async () => {
result = await handler({ mode });
});
return result;
}

function buildProps(overrides: Partial<AppsScreenProps> = {}): AppsScreenProps {
return {
tools: [fieldedApp, noFieldsApp, cohortApp] as Tool[],
Expand Down Expand Up @@ -249,6 +309,113 @@ describe("AppsScreen", () => {
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
});

it("sizes the renderer frame to the view-reported height", async () => {
const user = userEvent.setup();
const { factory, emit } = createEventBridgeFactory();
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
// Auto-launches the no-fields app, mounting the renderer and registering
// its sizechange listener once the bridge resolves.
await user.click(screen.getByText("Ops Dashboard"));
const iframe = screen.getByTitle("Ops Dashboard");
const frame = iframe.parentElement as HTMLElement;
// Until the view reports a size, the frame flex-grows to fill the card.
expect(frame.style.flexGrow).toBe("1");

await act(async () => {
// Let the synchronous bridge factory's promise chain settle so the
// sizechange listener is registered before we emit.
await Promise.resolve();
await Promise.resolve();
emit("sizechange", { height: 600 });
});
// A reported height switches the frame to its content size: it stops
// flex-growing and takes the explicit `h`.
expect(frame.style.flexGrow).toBe("0");
});

it("ignores a size-changed report that carries no height", async () => {
const user = userEvent.setup();
const { factory, emit } = createEventBridgeFactory();
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
await user.click(screen.getByText("Ops Dashboard"));
const frame = screen.getByTitle("Ops Dashboard")
.parentElement as HTMLElement;
await act(async () => {
await Promise.resolve();
await Promise.resolve();
emit("sizechange", { width: 400 });
});
// No height in the report — the frame keeps flex-growing.
expect(frame.style.flexGrow).toBe("1");
});

it("ignores a size-changed report with a non-positive height", async () => {
const user = userEvent.setup();
const { factory, emit } = createEventBridgeFactory();
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
await user.click(screen.getByText("Ops Dashboard"));
const frame = screen.getByTitle("Ops Dashboard")
.parentElement as HTMLElement;
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// A transient 0 (pre-layout / teardown) must not collapse the frame.
emit("sizechange", { height: 0 });
});
expect(frame.style.flexGrow).toBe("1");
// A subsequent positive report is honored.
await act(async () => emit("sizechange", { height: 480 }));
expect(frame.style.flexGrow).toBe("0");
});

it("maximizes the frame when the view requests fullscreen via request-display-mode", async () => {
const user = userEvent.setup();
const { factory, bridges } = createEventBridgeFactory();
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
await user.click(screen.getByText("Ops Dashboard"));
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const result = await requestDisplayMode(bridges, "fullscreen");
expect(result).toEqual({ mode: "fullscreen" });
// Fullscreen hides the sidebar (same effect as the Maximize button).
expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument();
});

it("declines an unsupported display mode, returning the current mode", async () => {
const user = userEvent.setup();
const { factory, bridges } = createEventBridgeFactory();
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
await user.click(screen.getByText("Ops Dashboard"));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
// "pip" is not in HOST_AVAILABLE_DISPLAY_MODES — declined, current mode
// ("inline") returned, and the sidebar stays visible.
const result = await requestDisplayMode(bridges, "pip");
expect(result).toEqual({ mode: "inline" });
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
});

it("restores inline via request-display-mode after maximizing", async () => {
const user = userEvent.setup();
const { factory, bridges } = createEventBridgeFactory();
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
await user.click(screen.getByText("Ops Dashboard"));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await requestDisplayMode(bridges, "fullscreen");
expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument();
const result = await requestDisplayMode(bridges, "inline");
expect(result).toEqual({ mode: "inline" });
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
});

it("calls onCloseApp and clears selection on Close", async () => {
const user = userEvent.setup();
const onCloseApp = vi.fn();
Expand Down
Loading
Loading