Skip to content
Open
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
61 changes: 60 additions & 1 deletion examples/threejs-server/src/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
35 changes: 4 additions & 31 deletions examples/threejs-server/src/mcp-app-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -21,6 +21,8 @@ import "./global.css";
* This interface can be reused for other views.
*/
export interface ViewProps<TToolInput = Record<string, unknown>> {
/** The connected MCP App instance */
app: App;
/** Complete tool input (after streaming finishes) */
toolInputs: TToolInput | null;
/** Partial tool input (during streaming) */
Expand All @@ -29,14 +31,6 @@ export interface ViewProps<TToolInput = Record<string, unknown>> {
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"];
}

// =============================================================================
Expand Down Expand Up @@ -91,24 +85,6 @@ function McpAppWrapper() {
}
}, [app]);

// Memoized callbacks that forward to app methods
const callServerTool = useCallback<App["callServerTool"]>(
(params, options) => app!.callServerTool(params, options),
[app],
);
const sendMessage = useCallback<App["sendMessage"]>(
(params, options) => app!.sendMessage(params, options),
[app],
);
const openLink = useCallback<App["openLink"]>(
(params, options) => app!.openLink(params, options),
[app],
);
const sendLog = useCallback<App["sendLog"]>(
(params) => app!.sendLog(params),
[app],
);

if (error) {
return <div className="error">Error: {error.message}</div>;
}
Expand All @@ -119,14 +95,11 @@ function McpAppWrapper() {

return (
<ThreeJSApp
app={app}
toolInputs={toolInputs}
toolInputsPartial={toolInputsPartial}
toolResult={toolResult}
hostContext={hostContext}
callServerTool={callServerTool}
sendMessage={sendMessage}
openLink={openLink}
sendLog={sendLog}
/>
);
}
Expand Down
69 changes: 62 additions & 7 deletions examples/threejs-server/src/threejs-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | null>(null);
const [currentDisplayMode, setCurrentDisplayMode] = useState<
"inline" | "fullscreen"
>("inline");
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const animControllerRef = useRef<ReturnType<
Expand All @@ -224,6 +223,38 @@ export default function ThreeJSApp({
paddingLeft: safeAreaInsets?.left,
};

const canFullscreen =
hostContext?.availableDisplayModes?.includes("fullscreen") ?? false;
const isFullscreen = currentDisplayMode === "fullscreen";

// Sync display mode from host context
useEffect(() => {
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;
Expand Down Expand Up @@ -269,7 +300,7 @@ export default function ThreeJSApp({
return (
<div
ref={containerRef}
className="threejs-container"
className={`threejs-container${isFullscreen ? " fullscreen" : ""}`}
style={containerStyle}
>
<canvas
Expand All @@ -283,6 +314,30 @@ export default function ThreeJSApp({
}}
/>
{error && <div className="error-overlay">Error: {error}</div>}
<button
className={`fullscreen-btn${canFullscreen ? " available" : ""}`}
title={isFullscreen ? "Exit fullscreen" : "Toggle fullscreen"}
onClick={toggleFullscreen}
>
<svg
className="expand-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
</svg>
<svg
className="collapse-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3" />
</svg>
</button>
</div>
);
}
Loading