* 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:
parent
1ffcdc38ca
commit
323f42d11c
3 changed files with 672 additions and 0 deletions
|
|
@ -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>
|
||||
),
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
150
clients/web/src/components/elements/AppRenderer/AppRenderer.tsx
Normal file
150
clients/web/src/components/elements/AppRenderer/AppRenderer.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue