mirror of
https://github.com/block/goose.git
synced 2026-07-09 16:09:22 +00:00
Fix MCP app sandbox bridge lifecycle (#10064)
Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
parent
e8891b7bb0
commit
31bc265a69
1 changed files with 271 additions and 14 deletions
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue