diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx new file mode 100644 index 00000000..2ee718b3 --- /dev/null +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Box } from "@mantine/core"; +import type { ReactNode } from "react"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { fn } from "storybook/test"; +import { AppRenderer, type BridgeFactory } from "./AppRenderer"; + +const PLACEHOLDER_SANDBOX = "data:text/html,Mock%20Sandbox"; + +const cohortTool: Tool = { + name: "get-cohort-data", + title: "Cohort Data", + description: "Returns cohort retention heatmap data.", + inputSchema: { type: "object" }, +}; + +function createMockBridge(): AppBridge { + return { + sendToolInput: async () => {}, + sendToolResult: async () => {}, + sendToolCancelled: async () => {}, + teardownResource: async () => ({}), + close: async () => {}, + } as unknown as AppBridge; +} + +const okFactory: BridgeFactory = () => createMockBridge(); + +const pendingFactory: BridgeFactory = () => + new Promise(() => { + // Never resolves — represents the period before the bridge is ready. + }); + +const failingFactory: BridgeFactory = () => + Promise.reject(new Error("Bridge connect failed: handshake timed out")); + +function FrameContainer({ + children, + size, +}: { + children: ReactNode; + size: "default" | "maximized"; +}) { + const dims = + size === "maximized" ? { w: "100%", h: 600 } : { w: 480, h: 320 }; + return ( + + {children} + + ); +} + +const meta: Meta = { + title: "Elements/AppRenderer", + component: AppRenderer, + args: { + sandboxPath: PLACEHOLDER_SANDBOX, + tool: cohortTool, + onError: fn(), + }, + parameters: { + layout: "centered", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Loading: Story = { + args: { + bridgeFactory: pendingFactory, + }, + render: (args) => ( + + + + ), +}; + +export const Loaded: Story = { + args: { + bridgeFactory: okFactory, + }, + render: (args) => ( + + + + ), +}; + +export const BridgeError: Story = { + args: { + bridgeFactory: failingFactory, + }, + render: (args) => ( + + + + ), +}; + +export const Maximized: Story = { + args: { + bridgeFactory: okFactory, + }, + render: (args) => ( + + + + ), +}; diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx new file mode 100644 index 00000000..f7d081e7 --- /dev/null +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx @@ -0,0 +1,410 @@ +import { createRef } from "react"; +import { act } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { + AppRenderer, + type AppRendererHandle, + type BridgeFactory, +} from "./AppRenderer"; + +const tool: Tool = { + name: "cohort_app", + title: "Cohort App", + inputSchema: { type: "object" }, +}; + +interface MockBridge { + sendToolInput: ReturnType; + sendToolResult: ReturnType; + sendToolCancelled: ReturnType; + teardownResource: ReturnType; + close: ReturnType; +} + +function createMockBridge(): MockBridge { + return { + sendToolInput: vi.fn().mockResolvedValue(undefined), + sendToolResult: vi.fn().mockResolvedValue(undefined), + sendToolCancelled: vi.fn().mockResolvedValue(undefined), + teardownResource: vi.fn().mockResolvedValue({}), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function asBridge(mock: MockBridge): AppBridge { + return mock as unknown as AppBridge; +} + +// Two microtask ticks are enough to settle the bridge promise chain in the +// component when the factory is synchronous: tick 1 resolves +// `Promise.resolve(bridgeFactory(iframe))`, tick 2 runs the `.then` that +// assigns `bridgeRef.current`. Tests using a multi-hop async factory must +// flush further inline (see the post-unmount disposal cases). +async function flushAsync(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("AppRenderer", () => { + it("renders an iframe with the sandbox path and tool title", () => { + const bridge = createMockBridge(); + renderWithMantine( + asBridge(bridge)} + />, + ); + const iframe = screen.getByTitle("Cohort App") as HTMLIFrameElement; + expect(iframe.tagName).toBe("IFRAME"); + expect(iframe.getAttribute("src")).toBe("/sandbox.html"); + }); + + it("falls back to tool name when title is missing", () => { + const bridge = createMockBridge(); + renderWithMantine( + asBridge(bridge)} + />, + ); + expect(screen.getByTitle("no_title")).toBeInTheDocument(); + }); + + it("invokes the bridge factory with the iframe element", async () => { + const bridge = createMockBridge(); + const factory = vi.fn(() => asBridge(bridge)); + renderWithMantine( + , + ); + await flushAsync(); + expect(factory).toHaveBeenCalledTimes(1); + expect(factory.mock.calls[0]?.[0]).toBeInstanceOf(HTMLIFrameElement); + }); + + it("forwards sendToolInput through the bridge", async () => { + const bridge = createMockBridge(); + const ref = createRef(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await act(async () => { + await ref.current?.sendToolInput({ city: "NYC" }); + }); + expect(bridge.sendToolInput).toHaveBeenCalledWith({ + arguments: { city: "NYC" }, + }); + }); + + it("forwards sendToolResult through the bridge", async () => { + const bridge = createMockBridge(); + const ref = createRef(); + const result: CallToolResult = { + content: [{ type: "text", text: "ok" }], + }; + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await act(async () => { + await ref.current?.sendToolResult(result); + }); + expect(bridge.sendToolResult).toHaveBeenCalledWith(result); + }); + + it("forwards sendToolCancelled through the bridge", async () => { + const bridge = createMockBridge(); + const ref = createRef(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await act(async () => { + await ref.current?.sendToolCancelled("user-aborted"); + }); + expect(bridge.sendToolCancelled).toHaveBeenCalledWith({ + reason: "user-aborted", + }); + }); + + it("teardown disposes the bridge once and is idempotent", async () => { + const bridge = createMockBridge(); + const ref = createRef(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await act(async () => { + await ref.current?.teardown(); + await ref.current?.teardown(); + }); + expect(bridge.teardownResource).toHaveBeenCalledTimes(1); + expect(bridge.close).toHaveBeenCalledTimes(1); + }); + + it("does not double-dispose when unmount races an in-flight teardown", async () => { + const bridge = createMockBridge(); + let resolveTeardown: ((value: Record) => void) | undefined; + bridge.teardownResource.mockImplementationOnce( + () => + new Promise>((resolve) => { + resolveTeardown = resolve; + }), + ); + const ref = createRef(); + const { unmount } = renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + + // Kick off teardown but leave its `teardownResource` await pending. + const teardownPromise = ref.current!.teardown(); + + // Unmount while teardown is in flight. + unmount(); + + // Resolve the pending teardownResource, then let `close` settle. + await act(async () => { + resolveTeardown?.({}); + await teardownPromise; + for (let i = 0; i < 4; i++) { + await Promise.resolve(); + } + }); + + expect(bridge.teardownResource).toHaveBeenCalledTimes(1); + expect(bridge.close).toHaveBeenCalledTimes(1); + }); + + it("send methods are silent no-ops before the bridge resolves", async () => { + const bridge = createMockBridge(); + let resolveBridge: ((b: AppBridge) => void) | undefined; + const factory: BridgeFactory = () => + new Promise((resolve) => { + resolveBridge = resolve; + }); + const ref = createRef(); + renderWithMantine( + , + ); + await act(async () => { + await ref.current?.sendToolInput({ city: "NYC" }); + await ref.current?.sendToolResult({ content: [] }); + await ref.current?.sendToolCancelled("aborted"); + await ref.current?.teardown(); + }); + expect(bridge.sendToolInput).not.toHaveBeenCalled(); + expect(bridge.sendToolResult).not.toHaveBeenCalled(); + expect(bridge.sendToolCancelled).not.toHaveBeenCalled(); + expect(bridge.teardownResource).not.toHaveBeenCalled(); + // Resolve to satisfy the pending factory before unmount. + resolveBridge?.(asBridge(bridge)); + await flushAsync(); + }); + + it("unmount triggers teardownResource and close", async () => { + const bridge = createMockBridge(); + const { unmount } = renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await act(async () => { + unmount(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(bridge.teardownResource).toHaveBeenCalledTimes(1); + expect(bridge.close).toHaveBeenCalledTimes(1); + }); + + it("disposes a bridge that resolves after the component unmounts", async () => { + const bridge = createMockBridge(); + let resolveBridge: ((b: AppBridge) => void) | undefined; + const factory: BridgeFactory = () => + new Promise((resolve) => { + resolveBridge = resolve; + }); + const { unmount } = renderWithMantine( + , + ); + unmount(); + await act(async () => { + resolveBridge?.(asBridge(bridge)); + // disposeBridge awaits teardownResource then close — flush enough + // microtasks to let both calls complete. + for (let i = 0; i < 8; i++) { + await Promise.resolve(); + } + }); + expect(bridge.teardownResource).toHaveBeenCalledTimes(1); + expect(bridge.close).toHaveBeenCalledTimes(1); + }); + + it("calls onError when the bridge factory throws", async () => { + const onError = vi.fn(); + const factory: BridgeFactory = () => { + throw new Error("boom"); + }; + renderWithMantine( + , + ); + await flushAsync(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect((onError.mock.calls[0]?.[0] as Error).message).toBe("boom"); + }); + + it("wraps non-Error rejections from the factory", async () => { + const onError = vi.fn(); + const factory: BridgeFactory = () => Promise.reject("plain string"); + renderWithMantine( + , + ); + await flushAsync(); + expect(onError).toHaveBeenCalledTimes(1); + expect((onError.mock.calls[0]?.[0] as Error).message).toBe("plain string"); + }); + + it("does not throw when no onError is provided and the factory fails", async () => { + const factory: BridgeFactory = () => Promise.reject(new Error("ignored")); + renderWithMantine( + , + ); + await flushAsync(); + expect(screen.getByTitle("Cohort App")).toBeInTheDocument(); + }); + + it("rebuilds the bridge when sandboxPath changes", async () => { + const first = createMockBridge(); + const second = createMockBridge(); + const factory = vi + .fn() + .mockReturnValueOnce(asBridge(first)) + .mockReturnValueOnce(asBridge(second)); + + const { rerender } = renderWithMantine( + , + ); + await flushAsync(); + + rerender( + , + ); + await flushAsync(); + + expect(factory).toHaveBeenCalledTimes(2); + expect(first.teardownResource).toHaveBeenCalledTimes(1); + expect(first.close).toHaveBeenCalledTimes(1); + }); + + it("swallows teardownResource errors but still closes the transport", async () => { + const bridge = createMockBridge(); + bridge.teardownResource.mockRejectedValueOnce(new Error("teardown failed")); + const { unmount } = renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await act(async () => { + unmount(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(bridge.teardownResource).toHaveBeenCalledTimes(1); + expect(bridge.close).toHaveBeenCalledTimes(1); + }); + + it("swallows close errors during disposal", async () => { + const bridge = createMockBridge(); + bridge.close.mockRejectedValueOnce(new Error("close failed")); + const { unmount } = renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await act(async () => { + unmount(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(bridge.teardownResource).toHaveBeenCalledTimes(1); + expect(bridge.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx new file mode 100644 index 00000000..a537c9c7 --- /dev/null +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx @@ -0,0 +1,150 @@ +import { Box } from "@mantine/core"; +import { useEffect, useImperativeHandle, useRef, type Ref } from "react"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Constructs the `AppBridge` for a freshly mounted sandbox iframe. Wrap with + * `useCallback` (or hoist out of render) — the renderer treats a new factory + * identity as a signal to tear down the current bridge and rebuild, so an + * unstable factory will thrash the iframe on every render. + */ +export type BridgeFactory = ( + iframe: HTMLIFrameElement, +) => AppBridge | Promise; + +export interface AppRendererHandle { + sendToolInput(args: Record): Promise; + sendToolResult(result: CallToolResult): Promise; + sendToolCancelled(reason: string): Promise; + teardown(): Promise; +} + +export interface AppRendererProps { + sandboxPath: string; + tool: Tool; + bridgeFactory: BridgeFactory; + onError?: (err: Error) => void; + ref?: Ref; +} + +function toError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} + +async function disposeBridge(bridge: AppBridge): Promise { + // Best-effort: still close the transport even if teardownResource fails, + // otherwise the iframe unmount would leak MessagePort listeners. + try { + await bridge.teardownResource({}); + } catch { + /* swallow — closing transport below is the load-bearing step */ + } + try { + await bridge.close(); + } catch { + /* swallow — already disposing */ + } +} + +export function AppRenderer({ + sandboxPath, + tool, + bridgeFactory, + onError, + ref, +}: AppRendererProps) { + const iframeRef = useRef(null); + const bridgeRef = useRef(null); + const teardownStartedRef = useRef(false); + const onErrorRef = useRef(onError); + useEffect(() => { + onErrorRef.current = onError; + }); + + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) return; + + let cancelled = false; + teardownStartedRef.current = false; + + let pending: Promise; + try { + pending = Promise.resolve(bridgeFactory(iframe)); + } catch (err) { + onErrorRef.current?.(toError(err)); + return () => { + cancelled = true; + }; + } + + pending + .then((bridge) => { + if (cancelled) { + void disposeBridge(bridge); + return; + } + bridgeRef.current = bridge; + }) + .catch((err) => { + if (!cancelled) onErrorRef.current?.(toError(err)); + }); + + return () => { + cancelled = true; + const bridge = bridgeRef.current; + bridgeRef.current = null; + if (bridge) void disposeBridge(bridge); + }; + }, [bridgeFactory, sandboxPath]); + + useImperativeHandle( + ref, + () => ({ + async sendToolInput(args) { + const bridge = bridgeRef.current; + if (!bridge) return; + await bridge.sendToolInput({ arguments: args }); + }, + async sendToolResult(result) { + const bridge = bridgeRef.current; + if (!bridge) return; + await bridge.sendToolResult(result); + }, + async sendToolCancelled(reason) { + const bridge = bridgeRef.current; + if (!bridge) return; + await bridge.sendToolCancelled({ reason }); + }, + async teardown() { + const bridge = bridgeRef.current; + if (!bridge || teardownStartedRef.current) return; + teardownStartedRef.current = true; + // Null the ref synchronously so a concurrent unmount cleanup cannot + // see a still-live bridge and dispose it a second time. + bridgeRef.current = null; + await disposeBridge(bridge); + }, + }), + [], + ); + + // The iframe deliberately has no `sandbox` attribute: `sandboxPath` resolves + // to the inspector's own bundled sandbox-proxy page (trusted, same-origin), + // which then loads the untrusted MCP App content into a nested sandboxed + // iframe. Sandboxing this outer frame would block the postMessage bridge + // that `AppBridge` relies on. + return ( + + ); +}