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 (
+
+ );
+}