feat(apps): AppRenderer with imperative ref (#1262) (#1282)

* feat(apps): add AppRenderer element with imperative ref (#1262)

Introduces a thin host-side shell for embedded MCP Apps. The renderer
owns the iframe element and the AppBridge lifecycle; the wiring layer
constructs the bridge from the active MCP Client via bridgeFactory and
drives input/result/cancellation through the imperative ref handle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review: address PR feedback on AppRenderer

- Document BridgeFactory stability requirement (unstable identity thrashes
  the iframe on every render).
- Null bridgeRef synchronously inside teardown() before awaiting disposal,
  so a concurrent unmount cleanup cannot dispose the same bridge twice.
- Comment the iframe trust model: the outer frame loads the inspector's
  trusted same-origin sandbox-proxy, which sandboxes the actual app
  content. Adding `sandbox` to this frame would block the postMessage
  bridge.
- Rename `Error` story export to `BridgeError` to avoid shadowing the
  global Error constructor; drop the `globalThis.Error` workaround.
- Comment flushAsync to explain the two-tick microtask budget.
- Add unmount-while-tearing-down test to lock in the no-double-dispose
  guarantee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-05-05 09:56:48 -04:00 • committed by GitHub
commit 323f42d11c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 672 additions and 0 deletions

View file

@ -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,<title>Mock%20Sandbox</title>";
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<AppBridge>(() => {
// 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 (
<Box {...dims} bd="1px solid var(--mantine-color-gray-4)">
{children}
</Box>
);
}
const meta: Meta<typeof AppRenderer> = {
title: "Elements/AppRenderer",
component: AppRenderer,
args: {
sandboxPath: PLACEHOLDER_SANDBOX,
tool: cohortTool,
onError: fn(),
},
parameters: {
layout: "centered",
},
};
export default meta;
type Story = StoryObj<typeof AppRenderer>;
export const Loading: Story = {
args: {
bridgeFactory: pendingFactory,
},
render: (args) => (
<FrameContainer size="default">
<AppRenderer {...args} />
</FrameContainer>
),
};
export const Loaded: Story = {
args: {
bridgeFactory: okFactory,
},
render: (args) => (
<FrameContainer size="default">
<AppRenderer {...args} />
</FrameContainer>
),
};
export const BridgeError: Story = {
args: {
bridgeFactory: failingFactory,
},
render: (args) => (
<FrameContainer size="default">
<AppRenderer {...args} />
</FrameContainer>
),
};
export const Maximized: Story = {
args: {
bridgeFactory: okFactory,
},
render: (args) => (
<FrameContainer size="maximized">
<AppRenderer {...args} />
</FrameContainer>
),
};

View file

@ -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<typeof vi.fn>;
sendToolResult: ReturnType<typeof vi.fn>;
sendToolCancelled: ReturnType<typeof vi.fn>;
teardownResource: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
}
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<void> {
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(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => 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(
<AppRenderer
sandboxPath="/sandbox.html"
tool={{ name: "no_title", inputSchema: { type: "object" } }}
bridgeFactory={() => 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<BridgeFactory>(() => asBridge(bridge));
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={factory}
/>,
);
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<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => 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<AppRendererHandle>();
const result: CallToolResult = {
content: [{ type: "text", text: "ok" }],
};
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => 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<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => 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<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => 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<string, unknown>) => void) | undefined;
bridge.teardownResource.mockImplementationOnce(
() =>
new Promise<Record<string, unknown>>((resolve) => {
resolveTeardown = resolve;
}),
);
const ref = createRef<AppRendererHandle>();
const { unmount } = renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => 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<AppBridge>((resolve) => {
resolveBridge = resolve;
});
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={factory}
/>,
);
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(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => 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<AppBridge>((resolve) => {
resolveBridge = resolve;
});
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={factory}
/>,
);
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(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={factory}
onError={onError}
/>,
);
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(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={factory}
onError={onError}
/>,
);
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(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={factory}
/>,
);
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<BridgeFactory>()
.mockReturnValueOnce(asBridge(first))
.mockReturnValueOnce(asBridge(second));
const { rerender } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox-a.html"
tool={tool}
bridgeFactory={factory}
/>,
);
await flushAsync();
rerender(
<AppRenderer
sandboxPath="/sandbox-b.html"
tool={tool}
bridgeFactory={factory}
/>,
);
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(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => 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(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
unmount();
await Promise.resolve();
await Promise.resolve();
});
expect(bridge.teardownResource).toHaveBeenCalledTimes(1);
expect(bridge.close).toHaveBeenCalledTimes(1);
});
});

View file

@ -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<AppBridge>;
export interface AppRendererHandle {
sendToolInput(args: Record<string, unknown>): Promise<void>;
sendToolResult(result: CallToolResult): Promise<void>;
sendToolCancelled(reason: string): Promise<void>;
teardown(): Promise<void>;
}
export interface AppRendererProps {
sandboxPath: string;
tool: Tool;
bridgeFactory: BridgeFactory;
onError?: (err: Error) => void;
ref?: Ref<AppRendererHandle>;
}
function toError(err: unknown): Error {
return err instanceof Error ? err : new Error(String(err));
}
async function disposeBridge(bridge: AppBridge): Promise<void> {
// 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<HTMLIFrameElement | null>(null);
const bridgeRef = useRef<AppBridge | null>(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<AppBridge>;
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 (
<Box
component="iframe"
ref={iframeRef}
src={sandboxPath}
title={tool.title ?? tool.name}
w="100%"
h="100%"
bd={0}
display="block"
/>
);
}