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 {
onContextUpdate?: (context: ModelContext | null) => 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(
serverInfo: ServerInfo,
iframe: HTMLIFrameElement,
callbacks?: AppBridgeCallbacks,
options?: AppBridgeOptions,
): AppBridge {
const serverCapabilities = serverInfo.client.getServerCapabilities();
const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, {
@ -242,6 +249,12 @@ export function newAppBridge(
serverResources: serverCapabilities?.resources,
// Declare support for model context updates
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
@ -308,5 +321,18 @@ export function newAppBridge(
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;
}

View file

@ -72,7 +72,8 @@
.toolCallInfoPanel {
display: flex;
gap: 1rem;
flex-direction: column;
gap: 0.5rem;
animation: slideDown 0.3s ease-out;
}
@ -80,57 +81,6 @@
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 {
display: flex;
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 {
min-height: 200px;
@ -186,6 +126,62 @@
border: 3px dashed #888;
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 {

View file

@ -54,9 +54,20 @@ interface HostProps {
type ToolCallEntry = ToolCallInfo & { id: number };
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) {
const [toolCalls, setToolCalls] = useState<ToolCallEntry[]>([]);
const [destroyingIds, setDestroyingIds] = useState<Set<number>>(new Set());
const queryParams = useMemo(() => getQueryParams(), []);
const requestClose = (id: number) => {
setDestroyingIds((s) => new Set(s).add(id));
@ -85,6 +96,9 @@ function Host({ serversPromise }: HostProps) {
<CallToolPanel
serversPromise={serversPromise}
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 {
serversPromise: Promise<ServerInfo[]>;
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 [selectedTool, setSelectedTool] = useState("");
const [inputJson, setInputJson] = useState("{}");
const [hasAutoCalledRef] = useState({ called: false });
// Filter out app-only tools, prioritize tools with UIs
const toolNames = selectedServer
@ -118,16 +136,21 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
}
}, [inputJson]);
const handleServerSelect = (server: ServerInfo) => {
const handleServerSelect = (server: ServerInfo, preferredTool?: string) => {
setSelectedServer(server);
// Filter out app-only tools, prioritize tools with UIs
const visibleTools = Array.from(server.tools.values())
.filter((tool) => isToolVisibleToModel(tool))
.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)
setInputJson(getToolDefaults(server.tools.get(firstTool)));
setInputJson(getToolDefaults(server.tools.get(targetTool)));
};
const handleToolSelect = (toolName: string) => {
@ -136,10 +159,21 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
setInputJson(getToolDefaults(selectedServer?.tools.get(toolName)));
};
const handleSubmit = () => {
if (!selectedServer) return;
const toolCallInfo = callTool(selectedServer, selectedTool, JSON.parse(inputJson));
// Submit with optional override for server/tool (used by auto-call)
const handleSubmit = (overrideServer?: ServerInfo, overrideTool?: string) => {
const server = overrideServer ?? selectedServer;
const tool = overrideTool ?? selectedTool;
if (!server) return;
const toolCallInfo = callTool(server, tool, JSON.parse(inputJson));
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 (
@ -148,7 +182,17 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
<label>
Server
<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>
</label>
<label>
@ -184,17 +228,47 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
// ServerSelect calls use() and renders the server <select>
interface ServerSelectProps {
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 [hasInitialized, setHasInitialized] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
// Initialize with the correct server/tool when servers are loaded
useEffect(() => {
if (servers.length > selectedIndex) {
onSelect(servers[selectedIndex]);
if (hasInitialized || servers.length === 0) return;
// 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) {
return <select disabled><option>No servers configured</option></select>;
@ -233,74 +307,54 @@ function ToolCallInfoPanel({ toolCallInfo, isDestroying, onRequestClose, onClose
}
}, [isDestroying, isApp, onCloseComplete]);
const inputJson = JSON.stringify(toolCallInfo.input, null, 2);
return (
<div
className={styles.toolCallInfoPanel}
style={isDestroying ? { opacity: 0.5, pointerEvents: "none" } : undefined}
>
{/* For non-app tools, show input/output side by side */}
{!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}>
<span>{toolCallInfo.serverInfo.name}:<span className={styles.toolName}>{toolCallInfo.tool.name}</span></span>
{onRequestClose && !isDestroying && (
<button
className={styles.closeButton}
onClick={onRequestClose}
title="Close"
>
×
</button>
)}
</div>
{/* Row 1: Header with server:tool name and close button */}
<div className={styles.appHeader}>
<span>{toolCallInfo.serverInfo.name}:<span className={styles.toolName}>{toolCallInfo.tool.name}</span></span>
{onRequestClose && !isDestroying && (
<button
className={styles.closeButton}
onClick={onRequestClose}
title="Close"
>
×
</button>
)}
</div>
{/* Row 2: Tool Input */}
<CollapsiblePanel icon="📥" label="Tool Input" content={inputJson} />
{/* Row 3: App iframe (if app) */}
{isApp && (
<ErrorBoundary>
<Suspense fallback="Loading...">
{
isApp
? <AppIFramePanel
toolCallInfo={toolCallInfo}
isDestroying={isDestroying}
onTeardownComplete={onCloseComplete}
/>
: <ToolResultPanel toolCallInfo={toolCallInfo} />
}
<AppIFramePanel
toolCallInfo={toolCallInfo}
isDestroying={isDestroying}
onTeardownComplete={onCloseComplete}
/>
</Suspense>
</ErrorBoundary>
</div>
)}
{/* Row 4: Tool Result */}
<ErrorBoundary>
<Suspense fallback="Loading result...">
<ToolResultPanel toolCallInfo={toolCallInfo} />
</Suspense>
</ErrorBoundary>
</div>
);
}
function JsonBlock({ value }: { value: object }) {
return (
<pre className={styles.jsonBlock}>
<code>{JSON.stringify(value, null, 2)}</code>
</pre>
);
}
interface CollapsiblePanelProps {
icon: string;
label: string;
@ -347,8 +401,8 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const appBridgeRef = useRef<ReturnType<typeof newAppBridge> | null>(null);
const [modelContext, setModelContext] = useState<ModelContext | null>(null);
const [toolResult, setToolResult] = useState<object | null>(null);
const [messages, setMessages] = useState<AppMessage[]>([]);
const [displayMode, setDisplayMode] = useState<"inline" | "fullscreen">("inline");
useEffect(() => {
const iframe = iframeRef.current!;
@ -365,6 +419,11 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe, {
onContextUpdate: setModelContext,
onMessage: (msg) => setMessages((prev) => [...prev, msg]),
onDisplayModeChange: setDisplayMode,
}, {
// Provide container dimensions - maxHeight for flexible sizing
containerDimensions: { maxHeight: 600 },
displayMode: "inline",
});
appBridgeRef.current = appBridge;
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]);
// 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 inputJson = JSON.stringify(toolCallInfo.input, null, 2);
const resultJson = toolResult ? JSON.stringify(toolResult, null, 2) : null;
// Format messages
const formatMessage = (m: AppMessage) => {
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 panelClassName = displayMode === "fullscreen"
? `${styles.appIframePanel} ${styles.fullscreen}`
: styles.appIframePanel;
return (
<div className={styles.appIframePanel}>
<CollapsiblePanel icon="📥" label="Tool Input" content={inputJson} />
<div className={panelClassName}>
<iframe ref={iframeRef} />
{resultJson && (
<CollapsiblePanel icon="📤" label="Tool Result" content={resultJson} />
)}
{messages.length > 0 && (
<CollapsiblePanel
icon="💬"
@ -459,7 +513,8 @@ interface ToolResultPanelProps {
}
function ToolResultPanel({ toolCallInfo }: ToolResultPanelProps) {
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