Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 79 additions & 25 deletions ui/desktop/src/components/McpApps/McpAppRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type {
McpUiSizeChangedNotification,
} from '@modelcontextprotocol/ext-apps/app-bridge';
import type { CallToolResult, JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js';
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { callTool, readResource } from '../../api';
import { AppEvents } from '../../constants/events';
import { useTheme } from '../../contexts/ThemeContext';
Expand All @@ -39,12 +39,60 @@ import {
McpAppToolInput,
McpAppToolInputPartial,
McpAppToolResult,
DimensionLayout,
} from './types';

const DEFAULT_IFRAME_HEIGHT = 200;

const AVAILABLE_DISPLAY_MODES: McpUiDisplayMode[] = ['inline'];

const DISPLAY_MODE_LAYOUTS: Record<GooseDisplayMode, DimensionLayout> = {
inline: { width: 'fixed', height: 'unbounded' },
fullscreen: { width: 'fixed', height: 'fixed' },
standalone: { width: 'fixed', height: 'fixed' },
pip: { width: 'fixed', height: 'fixed' },
// sidecar: { width: 'fixed', height: 'flexible' }, // example on how to use flexible layout
};

function getContainerDimensions(
displayMode: GooseDisplayMode,
measuredWidth: number,
measuredHeight: number
): McpUiHostContext['containerDimensions'] {
const layout = DISPLAY_MODE_LAYOUTS[displayMode] ?? DISPLAY_MODE_LAYOUTS.inline;

// Only require a measurement for axes that are fixed or flexible (unbounded axes are omitted).
if (
(layout.width !== 'unbounded' && measuredWidth <= 0) ||
(layout.height !== 'unbounded' && measuredHeight <= 0)
)
return undefined;

const widthDimension = (() => {
switch (layout.width) {
case 'fixed':
return { width: measuredWidth };
case 'flexible':
return { maxWidth: measuredWidth };
case 'unbounded':
return {};
}
})();

const heightDimension = (() => {
switch (layout.height) {
case 'fixed':
return { height: measuredHeight };
case 'flexible':
return { maxHeight: measuredHeight };
case 'unbounded':
return {};
}
})();

return { ...widthDimension, ...heightDimension };
}

async function fetchMcpAppProxyUrl(csp: McpUiResourceCsp | null): Promise<string | null> {
try {
const baseUrl = await window.electron.getGoosedHostPort();
Expand Down Expand Up @@ -195,8 +243,10 @@ export default function McpAppRenderer({

const [state, dispatch] = useReducer(appReducer, initialState);
const [iframeHeight, setIframeHeight] = useState(DEFAULT_IFRAME_HEIGHT);
// null = fluid (100% width), number = explicit width from app
const [iframeWidth, setIframeWidth] = useState<number | null>(null);

const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState<number>(0);
const [containerHeight, setContainerHeight] = useState<number>(0);

// Fetch the resource from the extension to get HTML and metadata (CSP, permissions, etc.).
// If cachedHtml is provided we show it immediately; the fetch updates metadata and
Expand Down Expand Up @@ -383,22 +433,28 @@ export default function McpAppRenderer({
[]
);

/**
* Height: non-positive values are ignored (keeps previous height).
* Width: if provided, container uses that width (capped at 100%);
* if omitted or non-positive, container is fluid (100%).
*/
const handleSizeChanged = useCallback(
({ height, width }: McpUiSizeChangedNotification['params']) => {
if (height !== undefined && height > 0) {
setIframeHeight(height);
}
if (width !== undefined) {
setIframeWidth(width > 0 ? width : null);
const handleSizeChanged = useCallback(({ height }: McpUiSizeChangedNotification['params']) => {
if (height !== undefined && height > 0) {
setIframeHeight(height);
}
}, []);

// Track the container's pixel dimensions so we can report them to apps via containerDimensions.
useEffect(() => {
const el = containerRef.current;
if (!el) return;

const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
setContainerWidth((prev) => (prev !== Math.round(width) ? Math.round(width) : prev));
setContainerHeight((prev) => (prev !== Math.round(height) ? Math.round(height) : prev));
}
},
[]
);
});

observer.observe(el);
return () => observer.disconnect();
}, []);

const handleFallbackRequest = useCallback(
async (request: JSONRPCRequest, _extra: RequestHandlerExtra) => {
Expand Down Expand Up @@ -453,7 +509,7 @@ export default function McpAppRenderer({
displayMode: displayMode as McpUiDisplayMode,
availableDisplayModes:
displayMode === 'standalone' ? [displayMode as McpUiDisplayMode] : AVAILABLE_DISPLAY_MODES,
// todo: containerDimensions: {} (depends on displayMode)
containerDimensions: getContainerDimensions(displayMode, containerWidth, containerHeight),
locale: navigator.language,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
userAgent: navigator.userAgent,
Expand All @@ -471,7 +527,7 @@ export default function McpAppRenderer({
};

return context;
}, [resolvedTheme, displayMode]);
}, [resolvedTheme, displayMode, containerWidth, containerHeight]);

const appToolResult = useMemo((): CallToolResult | undefined => {
if (!toolResult) return undefined;
Expand Down Expand Up @@ -537,8 +593,7 @@ export default function McpAppRenderer({
};

const containerClasses = cn(
'bg-background-default overflow-hidden',
iframeWidth === null && '[&_iframe]:!w-full',
'bg-background-default overflow-hidden [&_iframe]:!w-full',
isError && 'border border-red-500 rounded-lg bg-red-50 dark:bg-red-900/20',
!isError && !isExpandedView && 'mt-6 mb-2',
!isError && !isExpandedView && meta.prefersBorder && 'border border-border-default rounded-lg'
Expand All @@ -547,13 +602,12 @@ export default function McpAppRenderer({
const containerStyle = isExpandedView
? { width: '100%', height: '100%' }
: {
width: iframeWidth !== null ? `${iframeWidth}px` : '100%',
maxWidth: '100%',
width: '100%',
height: `${iframeHeight || DEFAULT_IFRAME_HEIGHT}px`,
};

return (
<div className={containerClasses} style={containerStyle}>
<div ref={containerRef} className={containerClasses} style={containerStyle}>
{renderContent()}
</div>
);
Expand Down
13 changes: 13 additions & 0 deletions ui/desktop/src/components/McpApps/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ export type SandboxPermissions = string;

export type GooseDisplayMode = McpUiDisplayMode | 'standalone';

/**
* Per the ext-apps spec, each axis is independently:
* fixed – sends width/height (host controls, view fills it)
* flexible – sends maxWidth/maxHeight (view controls, up to max; host resizes via size-changed)
* unbounded – field omitted (view controls with no limit; host resizes via size-changed)
*/
export type DimensionMode = 'fixed' | 'flexible' | 'unbounded';

export interface DimensionLayout {
width: DimensionMode;
height: DimensionMode;
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't we get these from the server spec?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec exports the containerDimensions shape on McpUiHostContext but not a DimensionMode/DimensionLayout type — those are our internal mapping for deciding which fields to populate per display mode. If the spec ever exports them we'll switch.

/**
* Tool input from the message stream.
* McpAppRenderer extracts `.arguments` when passing to the SDK's AppRenderer.
Expand Down
Loading