feat(basic-host): URL params, fullscreen mode, unified layout (#266)

* basic-host: handle containerDimensions

* feat(basic-host): Unified tool call layout with shared collapsible panels

* snapshots

* format
This commit is contained in:
Olivier Chafik 2026-01-14 22:09:41 +00:00 • committed by GitHub
commit 75cbf8612c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 218 additions and 141 deletions

View file

@ -228,12 +228,19 @@ export type AppMessage = McpUiMessageRequest["params"];
export interface AppBridgeCallbacks { export interface AppBridgeCallbacks {
onContextUpdate?: (context: ModelContext | null) => void; onContextUpdate?: (context: ModelContext | null) => void;
onMessage?: (message: AppMessage) => void; onMessage?: (message: AppMessage) => void;
onDisplayModeChange?: (mode: "inline" | "fullscreen") => void;
}
export interface AppBridgeOptions {
containerDimensions?: { maxHeight?: number; width?: number } | { height: number; width?: number };
displayMode?: "inline" | "fullscreen";
} }
export function newAppBridge( export function newAppBridge(
serverInfo: ServerInfo, serverInfo: ServerInfo,
iframe: HTMLIFrameElement, iframe: HTMLIFrameElement,
callbacks?: AppBridgeCallbacks, callbacks?: AppBridgeCallbacks,
options?: AppBridgeOptions,
): AppBridge { ): AppBridge {
const serverCapabilities = serverInfo.client.getServerCapabilities(); const serverCapabilities = serverInfo.client.getServerCapabilities();
const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, { const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, {
@ -242,6 +249,12 @@ export function newAppBridge(
serverResources: serverCapabilities?.resources, serverResources: serverCapabilities?.resources,
// Declare support for model context updates // Declare support for model context updates
updateModelContext: { text: {} }, updateModelContext: { text: {} },
}, {
hostContext: {
containerDimensions: options?.containerDimensions ?? { maxHeight: 600 },
displayMode: options?.displayMode ?? "inline",
availableDisplayModes: ["inline", "fullscreen"],
},
}); });
// Register all handlers before calling connect(). The Guest UI can start // Register all handlers before calling connect(). The Guest UI can start
@ -308,5 +321,18 @@ export function newAppBridge(
iframe.animate([from, to], { duration: 300, easing: "ease-out" }); iframe.animate([from, to], { duration: 300, easing: "ease-out" });
}; };
// Handle display mode change requests from the app
appBridge.onrequestdisplaymode = async (params) => {
log.info("Display mode request from MCP App:", params);
const newMode = params.mode === "fullscreen" ? "fullscreen" : "inline";
// Update host context and notify the app
appBridge.sendHostContextChange({
displayMode: newMode,
});
// Notify the host UI (via callback)
callbacks?.onDisplayModeChange?.(newMode);
return { mode: newMode };
};
return appBridge; return appBridge;
} }

View file

@ -72,7 +72,8 @@
.toolCallInfoPanel { .toolCallInfoPanel {
display: flex; display: flex;
gap: 1rem; flex-direction: column;
gap: 0.5rem;
animation: slideDown 0.3s ease-out; animation: slideDown 0.3s ease-out;
} }
@ -80,57 +81,6 @@
from { opacity: 0; transform: translateY(-12px); } from { opacity: 0; transform: translateY(-12px); }
} }
.inputInfoPanel {
display: flex;
flex: 3;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
h2 {
display: flex;
flex-direction: column;
margin: 0;
font-size: 1.5rem;
position: relative;
.toolName {
font-family: monospace;
}
.closeButton {
position: absolute;
top: 0;
right: 0;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: none;
border-radius: 4px;
background: #e0e0e0;
font-size: 1.25rem;
line-height: 1;
color: #666;
cursor: pointer;
&:hover {
background: #d0d0d0;
color: #333;
}
}
}
}
.outputInfoPanel {
flex: 4;
min-width: 0;
}
.appOutputPanel {
flex: 1;
min-width: 0;
}
.appHeader { .appHeader {
display: flex; display: flex;
align-items: center; align-items: center;
@ -166,16 +116,6 @@
} }
} }
.jsonBlock {
flex-grow: 1;
min-height: 0;
margin: 0;
padding: 1rem;
border-radius: 4px;
background-color: #f5f5f5;
overflow: auto;
}
.appIframePanel { .appIframePanel {
min-height: 200px; min-height: 200px;
@ -186,6 +126,62 @@
border: 3px dashed #888; border: 3px dashed #888;
border-radius: 4px; border-radius: 4px;
} }
&.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
margin: 0;
padding: 1rem;
max-width: none;
background: white;
border: none;
border-radius: 0;
display: flex;
flex-direction: column;
/* Hide collapsible panels in fullscreen */
.collapsiblePanel {
display: none;
}
iframe {
flex: 1;
height: 100%;
border: none;
border-radius: 0;
}
}
}
.appToolbar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
.collapsiblePanel {
flex: 1;
margin: 0;
}
}
.fullscreenButton {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid #ccc;
border-radius: 4px;
background: #f5f5f5;
font-size: 1.25rem;
cursor: pointer;
&:hover {
background: #e0e0e0;
}
} }
.collapsiblePanel { .collapsiblePanel {

View file

@ -54,9 +54,20 @@ interface HostProps {
type ToolCallEntry = ToolCallInfo & { id: number }; type ToolCallEntry = ToolCallInfo & { id: number };
let nextToolCallId = 0; let nextToolCallId = 0;
// Parse URL query params for debugging: ?server=name&tool=name&call=true
function getQueryParams() {
const params = new URLSearchParams(window.location.search);
return {
server: params.get("server"),
tool: params.get("tool"),
call: params.get("call") === "true",
};
}
function Host({ serversPromise }: HostProps) { function Host({ serversPromise }: HostProps) {
const [toolCalls, setToolCalls] = useState<ToolCallEntry[]>([]); const [toolCalls, setToolCalls] = useState<ToolCallEntry[]>([]);
const [destroyingIds, setDestroyingIds] = useState<Set<number>>(new Set()); const [destroyingIds, setDestroyingIds] = useState<Set<number>>(new Set());
const queryParams = useMemo(() => getQueryParams(), []);
const requestClose = (id: number) => { const requestClose = (id: number) => {
setDestroyingIds((s) => new Set(s).add(id)); setDestroyingIds((s) => new Set(s).add(id));
@ -85,6 +96,9 @@ function Host({ serversPromise }: HostProps) {
<CallToolPanel <CallToolPanel
serversPromise={serversPromise} serversPromise={serversPromise}
addToolCall={(info) => setToolCalls([...toolCalls, { ...info, id: nextToolCallId++ }])} addToolCall={(info) => setToolCalls([...toolCalls, { ...info, id: nextToolCallId++ }])}
initialServer={queryParams.server}
initialTool={queryParams.tool}
autoCall={queryParams.call}
/> />
</> </>
); );
@ -95,11 +109,15 @@ function Host({ serversPromise }: HostProps) {
interface CallToolPanelProps { interface CallToolPanelProps {
serversPromise: Promise<ServerInfo[]>; serversPromise: Promise<ServerInfo[]>;
addToolCall: (info: ToolCallInfo) => void; addToolCall: (info: ToolCallInfo) => void;
initialServer?: string | null;
initialTool?: string | null;
autoCall?: boolean;
} }
function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) { function CallToolPanel({ serversPromise, addToolCall, initialServer, initialTool, autoCall }: CallToolPanelProps) {
const [selectedServer, setSelectedServer] = useState<ServerInfo | null>(null); const [selectedServer, setSelectedServer] = useState<ServerInfo | null>(null);
const [selectedTool, setSelectedTool] = useState(""); const [selectedTool, setSelectedTool] = useState("");
const [inputJson, setInputJson] = useState("{}"); const [inputJson, setInputJson] = useState("{}");
const [hasAutoCalledRef] = useState({ called: false });
// Filter out app-only tools, prioritize tools with UIs // Filter out app-only tools, prioritize tools with UIs
const toolNames = selectedServer const toolNames = selectedServer
@ -118,16 +136,21 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
} }
}, [inputJson]); }, [inputJson]);
const handleServerSelect = (server: ServerInfo) => { const handleServerSelect = (server: ServerInfo, preferredTool?: string) => {
setSelectedServer(server); setSelectedServer(server);
// Filter out app-only tools, prioritize tools with UIs // Filter out app-only tools, prioritize tools with UIs
const visibleTools = Array.from(server.tools.values()) const visibleTools = Array.from(server.tools.values())
.filter((tool) => isToolVisibleToModel(tool)) .filter((tool) => isToolVisibleToModel(tool))
.sort(compareTools); .sort(compareTools);
const firstTool = visibleTools[0]?.name ?? "";
setSelectedTool(firstTool); // Use preferred tool if it exists and is visible, otherwise first visible tool
const targetTool = preferredTool && visibleTools.some(t => t.name === preferredTool)
? preferredTool
: visibleTools[0]?.name ?? "";
setSelectedTool(targetTool);
// Set input JSON to tool defaults (if any) // Set input JSON to tool defaults (if any)
setInputJson(getToolDefaults(server.tools.get(firstTool))); setInputJson(getToolDefaults(server.tools.get(targetTool)));
}; };
const handleToolSelect = (toolName: string) => { const handleToolSelect = (toolName: string) => {
@ -136,10 +159,21 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
setInputJson(getToolDefaults(selectedServer?.tools.get(toolName))); setInputJson(getToolDefaults(selectedServer?.tools.get(toolName)));
}; };
const handleSubmit = () => { // Submit with optional override for server/tool (used by auto-call)
if (!selectedServer) return; const handleSubmit = (overrideServer?: ServerInfo, overrideTool?: string) => {
const toolCallInfo = callTool(selectedServer, selectedTool, JSON.parse(inputJson)); const server = overrideServer ?? selectedServer;
const tool = overrideTool ?? selectedTool;
if (!server) return;
const toolCallInfo = callTool(server, tool, JSON.parse(inputJson));
addToolCall(toolCallInfo); addToolCall(toolCallInfo);
// Update URL for easy refresh/sharing (without triggering navigation)
const url = new URL(window.location.href);
url.searchParams.set("server", server.name);
url.searchParams.set("tool", tool);
url.searchParams.set("call", "true"); // Auto-call on refresh
history.replaceState(null, "", url.toString());
}; };
return ( return (
@ -148,7 +182,17 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
<label> <label>
Server Server
<Suspense fallback={<select disabled><option>Loading...</option></select>}> <Suspense fallback={<select disabled><option>Loading...</option></select>}>
<ServerSelect serversPromise={serversPromise} onSelect={handleServerSelect} /> <ServerSelect
serversPromise={serversPromise}
onSelect={handleServerSelect}
initialServer={initialServer}
initialTool={initialTool}
autoCall={autoCall && !hasAutoCalledRef.called}
onAutoCall={(server, tool) => {
hasAutoCalledRef.called = true;
handleSubmit(server, tool);
}}
/>
</Suspense> </Suspense>
</label> </label>
<label> <label>
@ -184,17 +228,47 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
// ServerSelect calls use() and renders the server <select> // ServerSelect calls use() and renders the server <select>
interface ServerSelectProps { interface ServerSelectProps {
serversPromise: Promise<ServerInfo[]>; serversPromise: Promise<ServerInfo[]>;
onSelect: (server: ServerInfo) => void; onSelect: (server: ServerInfo, toolName?: string) => void;
initialServer?: string | null;
initialTool?: string | null;
autoCall?: boolean;
onAutoCall?: (server: ServerInfo, tool: string) => void;
} }
function ServerSelect({ serversPromise, onSelect }: ServerSelectProps) { function ServerSelect({ serversPromise, onSelect, initialServer, initialTool, autoCall, onAutoCall }: ServerSelectProps) {
const servers = use(serversPromise); const servers = use(serversPromise);
const [hasInitialized, setHasInitialized] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
// Initialize with the correct server/tool when servers are loaded
useEffect(() => { useEffect(() => {
if (servers.length > selectedIndex) { if (hasInitialized || servers.length === 0) return;
onSelect(servers[selectedIndex]);
// Find initial server index if specified
let idx = 0;
if (initialServer) {
const foundIdx = servers.findIndex(s => s.name === initialServer);
if (foundIdx >= 0) idx = foundIdx;
} }
}, [servers]);
const server = servers[idx];
setSelectedIndex(idx);
// Find the tool to use
const visibleTools = Array.from(server.tools.values())
.filter((tool) => isToolVisibleToModel(tool))
.sort(compareTools);
const targetTool = initialTool && visibleTools.some(t => t.name === initialTool)
? initialTool
: visibleTools[0]?.name ?? "";
onSelect(server, targetTool);
setHasInitialized(true);
// Auto-call after initial selection if requested
if (autoCall && targetTool) {
onAutoCall?.(server, targetTool);
}
}, [servers, hasInitialized, initialServer, initialTool, autoCall, onSelect, onAutoCall]);
if (servers.length === 0) { if (servers.length === 0) {
return <select disabled><option>No servers configured</option></select>; return <select disabled><option>No servers configured</option></select>;
@ -233,33 +307,14 @@ function ToolCallInfoPanel({ toolCallInfo, isDestroying, onRequestClose, onClose
} }
}, [isDestroying, isApp, onCloseComplete]); }, [isDestroying, isApp, onCloseComplete]);
const inputJson = JSON.stringify(toolCallInfo.input, null, 2);
return ( return (
<div <div
className={styles.toolCallInfoPanel} className={styles.toolCallInfoPanel}
style={isDestroying ? { opacity: 0.5, pointerEvents: "none" } : undefined} style={isDestroying ? { opacity: 0.5, pointerEvents: "none" } : undefined}
> >
{/* For non-app tools, show input/output side by side */} {/* Row 1: Header with server:tool name and close button */}
{!isApp && (
<div className={styles.inputInfoPanel}>
<h2>
<span>{toolCallInfo.serverInfo.name}</span>
<span className={styles.toolName}>{toolCallInfo.tool.name}</span>
{onRequestClose && !isDestroying && (
<button
className={styles.closeButton}
onClick={onRequestClose}
title="Close"
>
×
</button>
)}
</h2>
<JsonBlock value={toolCallInfo.input} />
</div>
)}
<div className={isApp ? styles.appOutputPanel : styles.outputInfoPanel}>
{/* For apps, show header above the app: ServerName:tool_name */}
{isApp && (
<div className={styles.appHeader}> <div className={styles.appHeader}>
<span>{toolCallInfo.serverInfo.name}:<span className={styles.toolName}>{toolCallInfo.tool.name}</span></span> <span>{toolCallInfo.serverInfo.name}:<span className={styles.toolName}>{toolCallInfo.tool.name}</span></span>
{onRequestClose && !isDestroying && ( {onRequestClose && !isDestroying && (
@ -272,31 +327,30 @@ function ToolCallInfoPanel({ toolCallInfo, isDestroying, onRequestClose, onClose
</button> </button>
)} )}
</div> </div>
)}
{/* Row 2: Tool Input */}
<CollapsiblePanel icon="📥" label="Tool Input" content={inputJson} />
{/* Row 3: App iframe (if app) */}
{isApp && (
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback="Loading..."> <Suspense fallback="Loading...">
{ <AppIFramePanel
isApp
? <AppIFramePanel
toolCallInfo={toolCallInfo} toolCallInfo={toolCallInfo}
isDestroying={isDestroying} isDestroying={isDestroying}
onTeardownComplete={onCloseComplete} onTeardownComplete={onCloseComplete}
/> />
: <ToolResultPanel toolCallInfo={toolCallInfo} /> </Suspense>
} </ErrorBoundary>
)}
{/* Row 4: Tool Result */}
<ErrorBoundary>
<Suspense fallback="Loading result...">
<ToolResultPanel toolCallInfo={toolCallInfo} />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
</div>
);
}
function JsonBlock({ value }: { value: object }) {
return (
<pre className={styles.jsonBlock}>
<code>{JSON.stringify(value, null, 2)}</code>
</pre>
); );
} }
@ -347,8 +401,8 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
const iframeRef = useRef<HTMLIFrameElement | null>(null); const iframeRef = useRef<HTMLIFrameElement | null>(null);
const appBridgeRef = useRef<ReturnType<typeof newAppBridge> | null>(null); const appBridgeRef = useRef<ReturnType<typeof newAppBridge> | null>(null);
const [modelContext, setModelContext] = useState<ModelContext | null>(null); const [modelContext, setModelContext] = useState<ModelContext | null>(null);
const [toolResult, setToolResult] = useState<object | null>(null);
const [messages, setMessages] = useState<AppMessage[]>([]); const [messages, setMessages] = useState<AppMessage[]>([]);
const [displayMode, setDisplayMode] = useState<"inline" | "fullscreen">("inline");
useEffect(() => { useEffect(() => {
const iframe = iframeRef.current!; const iframe = iframeRef.current!;
@ -365,6 +419,11 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe, { const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe, {
onContextUpdate: setModelContext, onContextUpdate: setModelContext,
onMessage: (msg) => setMessages((prev) => [...prev, msg]), onMessage: (msg) => setMessages((prev) => [...prev, msg]),
onDisplayModeChange: setDisplayMode,
}, {
// Provide container dimensions - maxHeight for flexible sizing
containerDimensions: { maxHeight: 600 },
displayMode: "inline",
}); });
appBridgeRef.current = appBridge; appBridgeRef.current = appBridge;
initializeApp(iframe, appBridge, toolCallInfo); initializeApp(iframe, appBridge, toolCallInfo);
@ -372,8 +431,6 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
}); });
}); });
// Track tool result for display
toolCallInfo.resultPromise.then(setToolResult).catch(() => {});
}, [toolCallInfo]); }, [toolCallInfo]);
// Graceful teardown: wait for guest to respond before unmounting // Graceful teardown: wait for guest to respond before unmounting
@ -421,9 +478,6 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
: ""; : "";
const fullContext = [contextText, contextJson].filter(Boolean).join("\n\n"); const fullContext = [contextText, contextJson].filter(Boolean).join("\n\n");
const inputJson = JSON.stringify(toolCallInfo.input, null, 2);
const resultJson = toolResult ? JSON.stringify(toolResult, null, 2) : null;
// Format messages // Format messages
const formatMessage = (m: AppMessage) => { const formatMessage = (m: AppMessage) => {
const content = m.content.map(formatContentBlock).join("\n"); const content = m.content.map(formatContentBlock).join("\n");
@ -431,13 +485,13 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
}; };
const messagesText = messages.map(formatMessage).join("\n\n"); const messagesText = messages.map(formatMessage).join("\n\n");
const panelClassName = displayMode === "fullscreen"
? `${styles.appIframePanel} ${styles.fullscreen}`
: styles.appIframePanel;
return ( return (
<div className={styles.appIframePanel}> <div className={panelClassName}>
<CollapsiblePanel icon="📥" label="Tool Input" content={inputJson} />
<iframe ref={iframeRef} /> <iframe ref={iframeRef} />
{resultJson && (
<CollapsiblePanel icon="📤" label="Tool Result" content={resultJson} />
)}
{messages.length > 0 && ( {messages.length > 0 && (
<CollapsiblePanel <CollapsiblePanel
icon="💬" icon="💬"
@ -459,7 +513,8 @@ interface ToolResultPanelProps {
} }
function ToolResultPanel({ toolCallInfo }: ToolResultPanelProps) { function ToolResultPanel({ toolCallInfo }: ToolResultPanelProps) {
const result = use(toolCallInfo.resultPromise); const result = use(toolCallInfo.resultPromise);
return <JsonBlock value={result} />; const resultJson = JSON.stringify(result, null, 2);
return <CollapsiblePanel icon="📤" label="Tool Result" content={resultJson} />;
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Before After
Before After

Some files were not shown because too many files have changed in this diff Show more