Fix MCP app sandbox bridge lifecycle (#10064)

Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
Douwe Osinga 2026-06-30 19:02:25 -04:00 committed by GitHub
parent e8891b7bb0
commit 31bc265a69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,8 +1,8 @@
/**
* McpAppRenderer Renders interactive MCP App UIs inside a sandboxed iframe.
*
* This component implements the host side of the MCP Apps protocol using the
* @mcp-ui/client SDK's AppRenderer. It handles resource fetching, sandbox
* This component implements the host side of the MCP Apps protocol using
* @mcp-ui/client protocol primitives. It handles resource fetching, sandbox
* proxy setup, CSP enforcement, and bidirectional communication with guest apps.
*
* Protocol references:
@ -15,9 +15,16 @@
* - "standalone" Goose-specific mode for dedicated Electron windows
*/
import { AppRenderer, type RequestHandlerExtra } from '@mcp-ui/client';
import {
AppBridge,
PostMessageTransport,
type AppInfo,
type RequestHandlerExtra,
type SandboxConfig,
} from '@mcp-ui/client';
import type {
McpUiDisplayMode,
McpUiHostCapabilities,
McpUiHostContext,
McpUiResourceCsp,
McpUiResourcePermissions,
@ -125,6 +132,7 @@ const i18n = defineMessages({
const DEFAULT_IFRAME_HEIGHT = 200;
const FULLSCREEN_HEADER_HEIGHT = 48;
const DEFAULT_SANDBOX_PERMISSIONS = 'allow-scripts allow-same-origin allow-forms';
const DISPLAY_MODE_LAYOUTS: Record<GooseDisplayMode, DimensionLayout> = {
inline: { width: 'fixed', height: 'unbounded' },
@ -229,6 +237,257 @@ interface ResourceMeta {
const DEFAULT_META: ResourceMeta = { csp: null, permissions: null, prefersBorder: true };
type FallbackRequestHandler = {
fallbackRequestHandler?: (
request: JSONRPCRequest,
extra: RequestHandlerExtra
) => Promise<Record<string, unknown>>;
};
interface GooseAppFrameProps {
html: string;
sandbox: SandboxConfig;
hostContext: McpUiHostContext;
toolInput?: Record<string, unknown>;
toolInputPartial?: Record<string, unknown>;
toolResult?: CallToolResult;
toolCancelled?: boolean;
onMessage: (params: {
content: Array<{ type: string; text?: string }>;
}) => Promise<Record<string, unknown>>;
onOpenLink: (params: { url: string }) => Promise<{ status: 'success' | 'error'; message?: string }>;
onCallTool: (params: {
name: string;
arguments?: Record<string, unknown>;
}) => Promise<CallToolResult>;
onReadResource: (params: { uri: string }) => Promise<{
contents: Array<{ uri: string; text: string; mimeType?: string }>;
}>;
onLoggingMessage: (params: { level?: string; logger?: string; data?: unknown }) => void;
onFallbackRequest: (
request: JSONRPCRequest,
extra: RequestHandlerExtra
) => Promise<Record<string, unknown>>;
onSizeChanged?: (params: McpUiSizeChangedNotification['params']) => void;
onInitialized?: (appInfo: AppInfo) => void;
onError?: (error: Error) => void;
}
const SANDBOX_PROXY_READY_METHOD = 'ui/notifications/sandbox-proxy-ready';
function GooseAppFrame({
html,
sandbox,
hostContext,
toolInput,
toolInputPartial,
toolResult,
toolCancelled,
onMessage,
onOpenLink,
onCallTool,
onReadResource,
onLoggingMessage,
onFallbackRequest,
onSizeChanged,
onInitialized,
onError,
}: GooseAppFrameProps) {
const containerRef = useRef<HTMLDivElement>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const bridgeRef = useRef<AppBridge | null>(null);
const [connected, setConnected] = useState(false);
const [initialized, setInitialized] = useState(false);
const hostContextRef = useRef(hostContext);
const onMessageRef = useRef(onMessage);
const onOpenLinkRef = useRef(onOpenLink);
const onCallToolRef = useRef(onCallTool);
const onReadResourceRef = useRef(onReadResource);
const onLoggingMessageRef = useRef(onLoggingMessage);
const onFallbackRequestRef = useRef(onFallbackRequest);
const onSizeChangedRef = useRef(onSizeChanged);
const onInitializedRef = useRef(onInitialized);
const onErrorRef = useRef(onError);
useEffect(() => {
hostContextRef.current = hostContext;
onMessageRef.current = onMessage;
onOpenLinkRef.current = onOpenLink;
onCallToolRef.current = onCallTool;
onReadResourceRef.current = onReadResource;
onLoggingMessageRef.current = onLoggingMessage;
onFallbackRequestRef.current = onFallbackRequest;
onSizeChangedRef.current = onSizeChanged;
onInitializedRef.current = onInitialized;
onErrorRef.current = onError;
});
useEffect(() => {
const container = containerRef.current;
if (!container) return;
setConnected(false);
setInitialized(false);
const capabilities: McpUiHostCapabilities = {
openLinks: {},
serverTools: {},
serverResources: {},
logging: {},
message: { text: {} },
};
const bridge = new AppBridge(
null,
{ name: 'MCP-UI Host', version: '1.0.0' },
capabilities,
{ hostContext: hostContextRef.current }
);
bridge.onmessage = (params) => onMessageRef.current(params);
bridge.onopenlink = (params) => onOpenLinkRef.current(params);
bridge.onloggingmessage = (params) => onLoggingMessageRef.current(params);
bridge.oncalltool = (params) => onCallToolRef.current(params);
bridge.onreadresource = (params) => onReadResourceRef.current(params);
(bridge as FallbackRequestHandler).fallbackRequestHandler = (request, extra) =>
onFallbackRequestRef.current(request, extra);
const iframe = document.createElement('iframe');
iframe.style.width = '100%';
iframe.style.height = '600px';
iframe.style.border = 'none';
iframe.style.backgroundColor = 'transparent';
iframe.setAttribute('sandbox', sandbox.permissions || DEFAULT_SANDBOX_PERMISSIONS);
let active = true;
let settled = false;
const cleanupReadyListener = () => {
window.removeEventListener('message', handleReadyMessage);
iframe.removeEventListener('error', handleFrameError);
clearTimeout(timeout);
};
const fail = (error: Error) => {
if (settled) return;
settled = true;
cleanupReadyListener();
if (active) {
onErrorRef.current?.(error);
}
};
const ready = () => {
if (settled) return;
settled = true;
cleanupReadyListener();
void connectBridge();
};
function handleReadyMessage(event: MessageEvent) {
if (event.source !== iframe.contentWindow) return;
if (event.data?.method === SANDBOX_PROXY_READY_METHOD) {
ready();
}
}
function handleFrameError() {
fail(new Error('Failed to load sandbox proxy iframe'));
}
const timeout = window.setTimeout(() => {
fail(new Error('Timed out waiting for sandbox proxy iframe to be ready'));
}, 10_000);
const connectBridge = async () => {
if (!active || !iframe.contentWindow) return;
try {
bridge.onsizechange = (params) => {
onSizeChangedRef.current?.(params);
if (params.width !== undefined) {
iframe.style.width = `${params.width}px`;
}
if (params.height !== undefined) {
iframe.style.height = `${params.height}px`;
}
};
bridge.oninitialized = () => {
if (!active) return;
setInitialized(true);
onInitializedRef.current?.({
appVersion: bridge.getAppVersion(),
appCapabilities: bridge.getAppCapabilities(),
});
};
await bridge.connect(new PostMessageTransport(iframe.contentWindow, iframe.contentWindow));
if (!active) return;
bridgeRef.current = bridge;
setConnected(true);
} catch (error) {
if (!active) return;
onErrorRef.current?.(error instanceof Error ? error : new Error(String(error)));
}
};
window.addEventListener('message', handleReadyMessage);
iframe.addEventListener('error', handleFrameError);
container.replaceChildren(iframe);
iframeRef.current = iframe;
iframe.src = sandbox.url.href;
return () => {
active = false;
cleanupReadyListener();
if (iframeRef.current === iframe) {
iframeRef.current = null;
}
if (bridgeRef.current === bridge) {
bridgeRef.current = null;
}
bridge.close();
iframe.remove();
};
}, [sandbox.permissions, sandbox.url.href]);
useEffect(() => {
const bridge = bridgeRef.current;
if (!connected || !bridge) return;
void Promise.resolve(bridge.sendSandboxResourceReady({ html, csp: sandbox.csp })).catch(
(error: unknown) => {
onErrorRef.current?.(error instanceof Error ? error : new Error(String(error)));
}
);
}, [connected, html, sandbox.csp]);
useEffect(() => {
const bridge = bridgeRef.current;
if (connected && initialized && toolInput && bridge) {
void bridge.sendToolInput({ arguments: toolInput });
}
}, [connected, initialized, toolInput]);
useEffect(() => {
const bridge = bridgeRef.current;
if (connected && initialized && toolResult && bridge) {
void bridge.sendToolResult(toolResult);
}
}, [connected, initialized, toolResult]);
useEffect(() => {
const bridge = bridgeRef.current;
if (initialized && bridge) {
bridge.setHostContext(hostContext);
}
}, [initialized, hostContext]);
useEffect(() => {
const bridge = bridgeRef.current;
if (initialized && toolInputPartial && bridge) {
void bridge.sendToolInputPartial({ arguments: toolInputPartial });
}
}, [initialized, toolInputPartial]);
useEffect(() => {
const bridge = bridgeRef.current;
if (initialized && toolCancelled && bridge) {
void bridge.sendToolCancelled({});
}
}, [initialized, toolCancelled]);
return <div ref={containerRef} className="flex h-full w-full flex-col" />;
}
// Lifecycle: idle → loading_resource → loading_sandbox → ready
// Any state can transition to error. The sandbox URL is fetched only once
// to prevent iframe recreation (which would cause the app to lose state).
@ -732,7 +991,7 @@ export default function McpAppRenderer({
if (!readySandboxUrl) return null;
return {
url: readySandboxUrl,
permissions: meta.permissions || 'allow-scripts allow-same-origin',
permissions: meta.permissions || DEFAULT_SANDBOX_PERMISSIONS,
csp: mcpUiCsp,
};
}, [readySandboxUrl, meta.permissions, mcpUiCsp]);
@ -782,7 +1041,6 @@ export default function McpAppRenderer({
mcpTool,
]);
const isToolCancelled = !!toolCancelled;
const isError = state.status === 'error';
const isReady = state.status === 'ready';
@ -814,22 +1072,21 @@ export default function McpAppRenderer({
if (!sandboxConfig) return null;
return (
<AppRenderer
<GooseAppFrame
sandbox={sandboxConfig}
toolName={resourceUri}
html={html ?? undefined}
toolInput={toolInput?.arguments}
toolInputPartial={toolInputPartial ? { arguments: toolInputPartial.arguments } : undefined}
toolCancelled={isToolCancelled}
html={html ?? ''}
hostContext={hostContext}
toolInput={toolInput?.arguments}
toolInputPartial={toolInputPartial?.arguments}
toolResult={toolResult}
onOpenLink={handleOpenLink}
toolCancelled={!!toolCancelled}
onMessage={handleMessage}
onOpenLink={handleOpenLink}
onCallTool={handleCallTool}
onReadResource={handleReadResource}
onLoggingMessage={handleLoggingMessage}
onSizeChanged={handleSizeChanged}
onFallbackRequest={handleFallbackRequest}
onSizeChanged={handleSizeChanged}
onError={handleError}
/>
);
@ -936,7 +1193,7 @@ export default function McpAppRenderer({
};
// Single stable container — CSS switches between inline/fullscreen/pip positioning.
// The AppRenderer and its iframe are never unmounted, preserving app state across mode changes.
// The iframe is never unmounted, preserving app state across mode changes.
const containerClasses = cn(
'mcp-app-container bg-background-primary [&_iframe]:!w-full',
isFillsViewport && 'fixed inset-0 z-[1000] overflow-hidden [&_iframe]:!h-full',