diff --git a/examples/threejs-server/src/global.css b/examples/threejs-server/src/global.css index 55d812012..c969ccc41 100644 --- a/examples/threejs-server/src/global.css +++ b/examples/threejs-server/src/global.css @@ -6,7 +6,8 @@ box-sizing: border-box; } -html, body { +html, +body { font-family: var(--font-sans, system-ui, -apple-system, sans-serif); font-size: 1rem; margin: 0; @@ -45,3 +46,61 @@ html, body { font-family: var(--font-sans, system-ui); padding: 20px; } + +/* Fullscreen button */ +.fullscreen-btn { + position: absolute; + top: 10px; + right: 10px; + width: 40px; + height: 40px; + border: none; + border-radius: 8px; + background: rgba(0, 0, 0, 0.6); + color: white; + cursor: pointer; + display: none; /* Hidden by default, shown when fullscreen available */ + align-items: center; + justify-content: center; + transition: + background 0.2s, + opacity 0.2s; + opacity: 0; /* Initially invisible, shown on hover */ + z-index: 100; +} + +.fullscreen-btn:hover { + background: rgba(0, 0, 0, 0.8); + opacity: 1; +} + +.fullscreen-btn svg { + width: 20px; + height: 20px; +} + +.fullscreen-btn .collapse-icon { + display: none; +} + +.fullscreen-btn.available { + display: flex; +} + +/* Show button on container hover */ +.threejs-container:hover .fullscreen-btn.available { + opacity: 0.7; +} + +/* Fullscreen mode: swap icons and remove border radius */ +.threejs-container.fullscreen .fullscreen-btn .expand-icon { + display: none; +} + +.threejs-container.fullscreen .fullscreen-btn .collapse-icon { + display: block; +} + +.threejs-container.fullscreen canvas { + border-radius: 0 !important; +} diff --git a/examples/threejs-server/src/mcp-app-wrapper.tsx b/examples/threejs-server/src/mcp-app-wrapper.tsx index 4313fbbd5..f9dd263e5 100644 --- a/examples/threejs-server/src/mcp-app-wrapper.tsx +++ b/examples/threejs-server/src/mcp-app-wrapper.tsx @@ -7,7 +7,7 @@ import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { StrictMode, useState, useCallback, useEffect } from "react"; +import { StrictMode, useState, useEffect } from "react"; import { createRoot } from "react-dom/client"; import ThreeJSApp from "./threejs-app.tsx"; import "./global.css"; @@ -21,6 +21,8 @@ import "./global.css"; * This interface can be reused for other views. */ export interface ViewProps> { + /** The connected MCP App instance */ + app: App; /** Complete tool input (after streaming finishes) */ toolInputs: TToolInput | null; /** Partial tool input (during streaming) */ @@ -29,14 +31,6 @@ export interface ViewProps> { toolResult: CallToolResult | null; /** Host context (theme, dimensions, locale, etc.) */ hostContext: McpUiHostContext | null; - /** Call a tool on the MCP server */ - callServerTool: App["callServerTool"]; - /** Send a message to the host's chat */ - sendMessage: App["sendMessage"]; - /** Request the host to open a URL */ - openLink: App["openLink"]; - /** Send log messages to the host */ - sendLog: App["sendLog"]; } // ============================================================================= @@ -91,24 +85,6 @@ function McpAppWrapper() { } }, [app]); - // Memoized callbacks that forward to app methods - const callServerTool = useCallback( - (params, options) => app!.callServerTool(params, options), - [app], - ); - const sendMessage = useCallback( - (params, options) => app!.sendMessage(params, options), - [app], - ); - const openLink = useCallback( - (params, options) => app!.openLink(params, options), - [app], - ); - const sendLog = useCallback( - (params) => app!.sendLog(params), - [app], - ); - if (error) { return
Error: {error.message}
; } @@ -119,14 +95,11 @@ function McpAppWrapper() { return ( ); } diff --git a/examples/threejs-server/src/threejs-app.tsx b/examples/threejs-server/src/threejs-app.tsx index 2b99266b0..b640d0a6e 100644 --- a/examples/threejs-server/src/threejs-app.tsx +++ b/examples/threejs-server/src/threejs-app.tsx @@ -4,7 +4,7 @@ * Renders interactive 3D scenes using Three.js with streaming code preview. * Receives all MCP App props from the wrapper. */ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; @@ -195,16 +195,15 @@ async function executeThreeCode( // ============================================================================= export default function ThreeJSApp({ + app, toolInputs, toolInputsPartial, - toolResult: _toolResult, hostContext, - callServerTool: _callServerTool, - sendMessage: _sendMessage, - openLink: _openLink, - sendLog: _sendLog, }: ThreeJSAppProps) { const [error, setError] = useState(null); + const [currentDisplayMode, setCurrentDisplayMode] = useState< + "inline" | "fullscreen" + >("inline"); const canvasRef = useRef(null); const containerRef = useRef(null); const animControllerRef = useRef { + if (hostContext?.displayMode === "inline" || hostContext?.displayMode === "fullscreen") { + setCurrentDisplayMode(hostContext.displayMode); + } + }, [hostContext?.displayMode]); + + const toggleFullscreen = useCallback(async () => { + const newMode = isFullscreen ? "inline" : "fullscreen"; + try { + const result = await app.requestDisplayMode({ mode: newMode }); + if (result.mode === newMode) { + setCurrentDisplayMode(result.mode); + } + } catch { + // ignore + } + }, [isFullscreen, app]); + + // Escape key exits fullscreen + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && isFullscreen) toggleFullscreen(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [isFullscreen, toggleFullscreen]); + // Visibility-based pause/play useEffect(() => { if (!containerRef.current) return; @@ -269,7 +300,7 @@ export default function ThreeJSApp({ return (
{error &&
Error: {error}
} +
); }