Skip to content

Commit 412bb47

Browse files
committed
basic-host: handle containerDimensions
1 parent 152b953 commit 412bb47

File tree

3 files changed

+180
-15
lines changed

3 files changed

+180
-15
lines changed

examples/basic-host/src/implementation.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,12 +228,19 @@ export type AppMessage = McpUiMessageRequest["params"];
228228
export interface AppBridgeCallbacks {
229229
onContextUpdate?: (context: ModelContext | null) => void;
230230
onMessage?: (message: AppMessage) => void;
231+
onDisplayModeChange?: (mode: "inline" | "fullscreen") => void;
232+
}
233+
234+
export interface AppBridgeOptions {
235+
containerDimensions?: { maxHeight?: number; width?: number } | { height: number; width?: number };
236+
displayMode?: "inline" | "fullscreen";
231237
}
232238

233239
export function newAppBridge(
234240
serverInfo: ServerInfo,
235241
iframe: HTMLIFrameElement,
236242
callbacks?: AppBridgeCallbacks,
243+
options?: AppBridgeOptions,
237244
): AppBridge {
238245
const serverCapabilities = serverInfo.client.getServerCapabilities();
239246
const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, {
@@ -242,6 +249,12 @@ export function newAppBridge(
242249
serverResources: serverCapabilities?.resources,
243250
// Declare support for model context updates
244251
updateModelContext: { text: {} },
252+
}, {
253+
hostContext: {
254+
containerDimensions: options?.containerDimensions ?? { maxHeight: 600 },
255+
displayMode: options?.displayMode ?? "inline",
256+
availableDisplayModes: ["inline", "fullscreen"],
257+
},
245258
});
246259

247260
// Register all handlers before calling connect(). The Guest UI can start
@@ -308,5 +321,18 @@ export function newAppBridge(
308321
iframe.animate([from, to], { duration: 300, easing: "ease-out" });
309322
};
310323

324+
// Handle display mode change requests from the app
325+
appBridge.onrequestdisplaymode = async (params) => {
326+
log.info("Display mode request from MCP App:", params);
327+
const newMode = params.mode === "fullscreen" ? "fullscreen" : "inline";
328+
// Update host context and notify the app
329+
appBridge.sendHostContextChange({
330+
displayMode: newMode,
331+
});
332+
// Notify the host UI (via callback)
333+
callbacks?.onDisplayModeChange?.(newMode);
334+
return { mode: newMode };
335+
};
336+
311337
return appBridge;
312338
}

examples/basic-host/src/index.module.css

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,61 @@
186186
border: 3px dashed #888;
187187
border-radius: 4px;
188188
}
189+
190+
&.fullscreen {
191+
position: fixed;
192+
top: 0;
193+
left: 0;
194+
right: 0;
195+
bottom: 0;
196+
z-index: 1000;
197+
margin: 0;
198+
padding: 1rem;
199+
background: white;
200+
border: none;
201+
border-radius: 0;
202+
display: flex;
203+
flex-direction: column;
204+
205+
/* Hide collapsible panels in fullscreen */
206+
.collapsiblePanel {
207+
display: none;
208+
}
209+
210+
iframe {
211+
flex: 1;
212+
height: 100%;
213+
border: none;
214+
border-radius: 0;
215+
}
216+
}
217+
}
218+
219+
.appToolbar {
220+
display: flex;
221+
align-items: center;
222+
gap: 0.5rem;
223+
margin-bottom: 0.5rem;
224+
225+
.collapsiblePanel {
226+
flex: 1;
227+
margin: 0;
228+
}
229+
}
230+
231+
.fullscreenButton {
232+
width: 32px;
233+
height: 32px;
234+
padding: 0;
235+
border: 1px solid #ccc;
236+
border-radius: 4px;
237+
background: #f5f5f5;
238+
font-size: 1.25rem;
239+
cursor: pointer;
240+
241+
&:hover {
242+
background: #e0e0e0;
243+
}
189244
}
190245

191246
.collapsiblePanel {

examples/basic-host/src/index.tsx

Lines changed: 99 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,20 @@ interface HostProps {
5454
type ToolCallEntry = ToolCallInfo & { id: number };
5555
let nextToolCallId = 0;
5656

57+
// Parse URL query params for debugging: ?server=name&tool=name&call=true
58+
function getQueryParams() {
59+
const params = new URLSearchParams(window.location.search);
60+
return {
61+
server: params.get("server"),
62+
tool: params.get("tool"),
63+
call: params.get("call") === "true",
64+
};
65+
}
66+
5767
function Host({ serversPromise }: HostProps) {
5868
const [toolCalls, setToolCalls] = useState<ToolCallEntry[]>([]);
5969
const [destroyingIds, setDestroyingIds] = useState<Set<number>>(new Set());
70+
const queryParams = useMemo(() => getQueryParams(), []);
6071

6172
const requestClose = (id: number) => {
6273
setDestroyingIds((s) => new Set(s).add(id));
@@ -85,6 +96,9 @@ function Host({ serversPromise }: HostProps) {
8596
<CallToolPanel
8697
serversPromise={serversPromise}
8798
addToolCall={(info) => setToolCalls([...toolCalls, { ...info, id: nextToolCallId++ }])}
99+
initialServer={queryParams.server}
100+
initialTool={queryParams.tool}
101+
autoCall={queryParams.call}
88102
/>
89103
</>
90104
);
@@ -95,11 +109,15 @@ function Host({ serversPromise }: HostProps) {
95109
interface CallToolPanelProps {
96110
serversPromise: Promise<ServerInfo[]>;
97111
addToolCall: (info: ToolCallInfo) => void;
112+
initialServer?: string | null;
113+
initialTool?: string | null;
114+
autoCall?: boolean;
98115
}
99-
function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
116+
function CallToolPanel({ serversPromise, addToolCall, initialServer, initialTool, autoCall }: CallToolPanelProps) {
100117
const [selectedServer, setSelectedServer] = useState<ServerInfo | null>(null);
101118
const [selectedTool, setSelectedTool] = useState("");
102119
const [inputJson, setInputJson] = useState("{}");
120+
const [hasAutoCalledRef] = useState({ called: false });
103121

104122
// Filter out app-only tools, prioritize tools with UIs
105123
const toolNames = selectedServer
@@ -118,16 +136,21 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
118136
}
119137
}, [inputJson]);
120138

121-
const handleServerSelect = (server: ServerInfo) => {
139+
const handleServerSelect = (server: ServerInfo, preferredTool?: string) => {
122140
setSelectedServer(server);
123141
// Filter out app-only tools, prioritize tools with UIs
124142
const visibleTools = Array.from(server.tools.values())
125143
.filter((tool) => isToolVisibleToModel(tool))
126144
.sort(compareTools);
127-
const firstTool = visibleTools[0]?.name ?? "";
128-
setSelectedTool(firstTool);
145+
146+
// Use preferred tool if it exists and is visible, otherwise first visible tool
147+
const targetTool = preferredTool && visibleTools.some(t => t.name === preferredTool)
148+
? preferredTool
149+
: visibleTools[0]?.name ?? "";
150+
151+
setSelectedTool(targetTool);
129152
// Set input JSON to tool defaults (if any)
130-
setInputJson(getToolDefaults(server.tools.get(firstTool)));
153+
setInputJson(getToolDefaults(server.tools.get(targetTool)));
131154
};
132155

133156
const handleToolSelect = (toolName: string) => {
@@ -136,10 +159,21 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
136159
setInputJson(getToolDefaults(selectedServer?.tools.get(toolName)));
137160
};
138161

139-
const handleSubmit = () => {
140-
if (!selectedServer) return;
141-
const toolCallInfo = callTool(selectedServer, selectedTool, JSON.parse(inputJson));
162+
// Submit with optional override for server/tool (used by auto-call)
163+
const handleSubmit = (overrideServer?: ServerInfo, overrideTool?: string) => {
164+
const server = overrideServer ?? selectedServer;
165+
const tool = overrideTool ?? selectedTool;
166+
if (!server) return;
167+
168+
const toolCallInfo = callTool(server, tool, JSON.parse(inputJson));
142169
addToolCall(toolCallInfo);
170+
171+
// Update URL for easy refresh/sharing (without triggering navigation)
172+
const url = new URL(window.location.href);
173+
url.searchParams.set("server", server.name);
174+
url.searchParams.set("tool", tool);
175+
url.searchParams.set("call", "true"); // Auto-call on refresh
176+
history.replaceState(null, "", url.toString());
143177
};
144178

145179
return (
@@ -148,7 +182,17 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
148182
<label>
149183
Server
150184
<Suspense fallback={<select disabled><option>Loading...</option></select>}>
151-
<ServerSelect serversPromise={serversPromise} onSelect={handleServerSelect} />
185+
<ServerSelect
186+
serversPromise={serversPromise}
187+
onSelect={handleServerSelect}
188+
initialServer={initialServer}
189+
initialTool={initialTool}
190+
autoCall={autoCall && !hasAutoCalledRef.called}
191+
onAutoCall={(server, tool) => {
192+
hasAutoCalledRef.called = true;
193+
handleSubmit(server, tool);
194+
}}
195+
/>
152196
</Suspense>
153197
</label>
154198
<label>
@@ -184,17 +228,47 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
184228
// ServerSelect calls use() and renders the server <select>
185229
interface ServerSelectProps {
186230
serversPromise: Promise<ServerInfo[]>;
187-
onSelect: (server: ServerInfo) => void;
231+
onSelect: (server: ServerInfo, toolName?: string) => void;
232+
initialServer?: string | null;
233+
initialTool?: string | null;
234+
autoCall?: boolean;
235+
onAutoCall?: (server: ServerInfo, tool: string) => void;
188236
}
189-
function ServerSelect({ serversPromise, onSelect }: ServerSelectProps) {
237+
function ServerSelect({ serversPromise, onSelect, initialServer, initialTool, autoCall, onAutoCall }: ServerSelectProps) {
190238
const servers = use(serversPromise);
239+
const [hasInitialized, setHasInitialized] = useState(false);
191240
const [selectedIndex, setSelectedIndex] = useState(0);
192241

242+
// Initialize with the correct server/tool when servers are loaded
193243
useEffect(() => {
194-
if (servers.length > selectedIndex) {
195-
onSelect(servers[selectedIndex]);
244+
if (hasInitialized || servers.length === 0) return;
245+
246+
// Find initial server index if specified
247+
let idx = 0;
248+
if (initialServer) {
249+
const foundIdx = servers.findIndex(s => s.name === initialServer);
250+
if (foundIdx >= 0) idx = foundIdx;
196251
}
197-
}, [servers]);
252+
253+
const server = servers[idx];
254+
setSelectedIndex(idx);
255+
256+
// Find the tool to use
257+
const visibleTools = Array.from(server.tools.values())
258+
.filter((tool) => isToolVisibleToModel(tool))
259+
.sort(compareTools);
260+
const targetTool = initialTool && visibleTools.some(t => t.name === initialTool)
261+
? initialTool
262+
: visibleTools[0]?.name ?? "";
263+
264+
onSelect(server, targetTool);
265+
setHasInitialized(true);
266+
267+
// Auto-call after initial selection if requested
268+
if (autoCall && targetTool) {
269+
onAutoCall?.(server, targetTool);
270+
}
271+
}, [servers, hasInitialized, initialServer, initialTool, autoCall, onSelect, onAutoCall]);
198272

199273
if (servers.length === 0) {
200274
return <select disabled><option>No servers configured</option></select>;
@@ -349,6 +423,7 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
349423
const [modelContext, setModelContext] = useState<ModelContext | null>(null);
350424
const [toolResult, setToolResult] = useState<object | null>(null);
351425
const [messages, setMessages] = useState<AppMessage[]>([]);
426+
const [displayMode, setDisplayMode] = useState<"inline" | "fullscreen">("inline");
352427

353428
useEffect(() => {
354429
const iframe = iframeRef.current!;
@@ -365,6 +440,11 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
365440
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe, {
366441
onContextUpdate: setModelContext,
367442
onMessage: (msg) => setMessages((prev) => [...prev, msg]),
443+
onDisplayModeChange: setDisplayMode,
444+
}, {
445+
// Provide container dimensions - maxHeight for flexible sizing
446+
containerDimensions: { maxHeight: 600 },
447+
displayMode: "inline",
368448
});
369449
appBridgeRef.current = appBridge;
370450
initializeApp(iframe, appBridge, toolCallInfo);
@@ -431,8 +511,12 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
431511
};
432512
const messagesText = messages.map(formatMessage).join("\n\n");
433513

514+
const panelClassName = displayMode === "fullscreen"
515+
? `${styles.appIframePanel} ${styles.fullscreen}`
516+
: styles.appIframePanel;
517+
434518
return (
435-
<div className={styles.appIframePanel}>
519+
<div className={panelClassName}>
436520
<CollapsiblePanel icon="📥" label="Tool Input" content={inputJson} />
437521
<iframe ref={iframeRef} />
438522
{resultJson && (

0 commit comments

Comments
 (0)