diff --git a/examples/map-server/mcp-app.html b/examples/map-server/mcp-app.html
index 473eb50f6..b19eed4e5 100644
--- a/examples/map-server/mcp-app.html
+++ b/examples/map-server/mcp-app.html
@@ -1,72 +1,90 @@
-
+
-
-
-
+
+
+
CesiumJS Globe
-
-
+
+
Loading globe...
-
+
diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts
index 1475d81e7..4fda13284 100644
--- a/examples/map-server/src/mcp-app.ts
+++ b/examples/map-server/src/mcp-app.ts
@@ -397,6 +397,17 @@ async function initCesium(): Promise {
// CesiumJS sets image-rendering: pixelated by default which looks bad on scaled displays
// Setting to "auto" allows the browser to apply smooth interpolation
cesiumViewer.canvas.style.imageRendering = "auto";
+ // Prevent touch events from propagating to the parent scroll view.
+ // CesiumJS uses pointer events internally, which don't suppress native
+ // scroll gesture recognition on touch devices. Explicit non-passive touch
+ // listeners with preventDefault() are needed.
+ for (const eventName of ["touchstart", "touchmove"] as const) {
+ cesiumViewer.canvas.addEventListener(
+ eventName,
+ (e: TouchEvent) => e.preventDefault(),
+ { passive: false },
+ );
+ }
// Note: DO NOT set resolutionScale = devicePixelRatio here!
// When useBrowserRecommendedResolution: false, Cesium already uses devicePixelRatio.
// Setting resolutionScale = devicePixelRatio would double the scaling (e.g., 2x2=4x on Retina)
@@ -623,6 +634,16 @@ function updateFullscreenButton(): void {
// Show button only if fullscreen is available
btn.style.display = canFullscreen ? "flex" : "none";
+ // Position button respecting safe area insets
+ const insets = context?.safeAreaInsets;
+ btn.style.top = `${10 + (insets?.top ?? 0)}px`;
+ btn.style.right = `${10 + (insets?.right ?? 0)}px`;
+
+ // Always show button on touch devices (hover doesn't work on mobile)
+ if (context?.deviceCapabilities?.touch) {
+ btn.style.opacity = canFullscreen ? "0.7" : "0";
+ }
+
// Toggle icons based on current mode
const isFullscreen = currentDisplayMode === "fullscreen";
expandIcon.style.display = isFullscreen ? "none" : "block";
@@ -718,8 +739,8 @@ app.onhostcontextchanged = (params) => {
);
}
- // Update button if available modes changed
- if (params.availableDisplayModes) {
+ // Update button if available modes or safe area changed
+ if (params.availableDisplayModes || params.safeAreaInsets) {
updateFullscreenButton();
}
};
diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css
index 008b22a61..77e3757eb 100644
--- a/examples/pdf-server/src/mcp-app.css
+++ b/examples/pdf-server/src/mcp-app.css
@@ -103,13 +103,14 @@ body {
display: flex;
align-items: center;
justify-content: space-between;
- padding: 0.5rem 1rem;
+ padding: 0.5rem 0.5rem;
background: var(--bg000);
border-bottom: 1px solid var(--bg200);
flex-shrink: 0;
- gap: 0.5rem;
- height: 48px;
+ gap: 0.25rem 0.5rem;
+ min-height: 48px;
box-sizing: border-box;
+ flex-wrap: wrap;
}
.toolbar-left {
@@ -261,7 +262,6 @@ body {
--min-font-size-inv: calc(1 / var(--min-font-size, 1));
}
-
.text-layer :is(span, br) {
color: transparent;
position: absolute;
@@ -306,7 +306,13 @@ body {
overflow: hidden; /* No scrolling on main - only canvas-container scrolls */
border-radius: 0;
border: none;
- padding: 0 !important; /* Ignore safe area insets in fullscreen */
+ padding: 0 !important; /* Background extends edge-to-edge */
+}
+
+.main.fullscreen .toolbar {
+ padding-top: calc(0.5rem + var(--safe-top, 0px));
+ padding-left: calc(0.5rem + var(--safe-left, 0px));
+ padding-right: calc(0.5rem + var(--safe-right, 0px));
}
.main.fullscreen .viewer {
@@ -468,3 +474,35 @@ body {
.loading-indicator.error .loading-indicator-arc {
stroke: #e74c3c;
}
+
+/* Compact toolbar on narrow screens */
+@media (max-width: 480px) {
+ .toolbar-left {
+ display: none;
+ }
+ .toolbar {
+ justify-content: center;
+ }
+ .nav-btn,
+ .zoom-btn,
+ .search-btn,
+ .fullscreen-btn {
+ width: 28px;
+ height: 28px;
+ font-size: 0.85rem;
+ }
+ .page-input {
+ width: 40px;
+ padding: 0.2rem 0.3rem;
+ font-size: 0.8rem;
+ }
+ .total-pages,
+ .zoom-level {
+ font-size: 0.75rem;
+ min-width: 36px;
+ }
+ .toolbar-center,
+ .toolbar-right {
+ gap: 0.2rem;
+ }
+}
diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts
index 4278d9285..8b06a36c8 100644
--- a/examples/pdf-server/src/mcp-app.ts
+++ b/examples/pdf-server/src/mcp-app.ts
@@ -109,6 +109,36 @@ const loadingIndicatorArc = loadingIndicatorEl.querySelector(
// Track current display mode
let currentDisplayMode: "inline" | "fullscreen" = "inline";
+// Whether the user has manually zoomed (disables auto fit-to-width)
+let userHasZoomed = false;
+
+/**
+ * Compute a scale that fits the PDF page width to the available container width.
+ * Returns null if the container isn't visible or the page width is unavailable.
+ */
+async function computeFitToWidthScale(): Promise {
+ if (!pdfDocument) return null;
+
+ try {
+ const page = await pdfDocument.getPage(currentPage);
+ const naturalViewport = page.getViewport({ scale: 1.0 });
+ const pageWidth = naturalViewport.width;
+
+ const container = canvasContainerEl as HTMLElement;
+ const containerStyle = getComputedStyle(container);
+ const paddingLeft = parseFloat(containerStyle.paddingLeft);
+ const paddingRight = parseFloat(containerStyle.paddingRight);
+ const availableWidth = container.clientWidth - paddingLeft - paddingRight;
+
+ if (availableWidth <= 0 || pageWidth <= 0) return null;
+ if (availableWidth >= pageWidth) return null; // Already fits
+
+ return availableWidth / pageWidth;
+ } catch {
+ return null;
+ }
+}
+
/**
* Request the host to resize the app to fit the current PDF page.
* Only applies in inline mode - fullscreen mode uses scrolling.
@@ -783,16 +813,19 @@ function nextPage() {
}
function zoomIn() {
+ userHasZoomed = true;
scale = Math.min(scale + 0.25, 3.0);
renderPage();
}
function zoomOut() {
+ userHasZoomed = true;
scale = Math.max(scale - 0.25, 0.5);
renderPage();
}
function resetZoom() {
+ userHasZoomed = false;
scale = 1.0;
renderPage();
}
@@ -1281,6 +1314,14 @@ app.ontoolresult = async (result: CallToolResult) => {
loadingIndicatorEl.style.display = "none";
showViewer();
+
+ // Compute fit-to-width scale for narrow containers (e.g. mobile)
+ const fitScale = await computeFitToWidthScale();
+ if (fitScale !== null) {
+ scale = fitScale;
+ log.info("Fit-to-width scale:", scale);
+ }
+
renderPage();
// Start background preloading of all pages for text extraction
startPreloading();
@@ -1308,17 +1349,30 @@ function handleHostContextChanged(ctx: McpUiHostContext) {
applyHostStyleVariables(ctx.styles.variables);
}
- // Apply safe area insets
+ // Apply safe area insets — set CSS custom properties for use in both
+ // inline mode (padding on .main) and fullscreen mode (padding on .toolbar)
if (ctx.safeAreaInsets) {
- mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
- mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
- mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
- mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
+ const { top, right, bottom, left } = ctx.safeAreaInsets;
+ mainEl.style.setProperty("--safe-top", `${top}px`);
+ mainEl.style.setProperty("--safe-right", `${right}px`);
+ mainEl.style.setProperty("--safe-bottom", `${bottom}px`);
+ mainEl.style.setProperty("--safe-left", `${left}px`);
+ mainEl.style.paddingTop = `${top}px`;
+ mainEl.style.paddingRight = `${right}px`;
+ mainEl.style.paddingBottom = `${bottom}px`;
+ mainEl.style.paddingLeft = `${left}px`;
}
- // Log containerDimensions for debugging
- if (ctx.containerDimensions) {
- log.info("Container dimensions:", ctx.containerDimensions);
+ // Recompute fit-to-width when container dimensions change
+ if (ctx.containerDimensions && pdfDocument && !userHasZoomed) {
+ log.info("Container dimensions changed:", ctx.containerDimensions);
+ computeFitToWidthScale().then((fitScale) => {
+ if (fitScale !== null && Math.abs(fitScale - scale) > 0.01) {
+ scale = fitScale;
+ log.info("Recomputed fit-to-width scale:", scale);
+ renderPage();
+ }
+ });
}
// Handle display mode changes
diff --git a/examples/shadertoy-server/src/mcp-app.css b/examples/shadertoy-server/src/mcp-app.css
index 0449ea5ea..0d5b6f114 100644
--- a/examples/shadertoy-server/src/mcp-app.css
+++ b/examples/shadertoy-server/src/mcp-app.css
@@ -1,4 +1,5 @@
-html, body {
+html,
+body {
margin: 0;
width: 100%;
height: 100%;
@@ -23,6 +24,7 @@ html, body {
width: 100%;
height: 100%;
display: block;
+ touch-action: none;
}
#canvas.hidden {
@@ -68,7 +70,9 @@ html, body {
display: none; /* Hidden by default, shown when fullscreen available */
align-items: center;
justify-content: center;
- transition: background 0.2s, opacity 0.2s;
+ transition:
+ background 0.2s,
+ opacity 0.2s;
opacity: 0; /* Initially invisible, shown on View hover */
z-index: 100;
}
diff --git a/examples/threejs-server/src/global.css b/examples/threejs-server/src/global.css
index c969ccc41..1542cecac 100644
--- a/examples/threejs-server/src/global.css
+++ b/examples/threejs-server/src/global.css
@@ -92,6 +92,16 @@ body {
opacity: 0.7;
}
+/* Always show button on touch devices */
+.threejs-container.touch-device .fullscreen-btn.available {
+ opacity: 0.7;
+}
+
+/* In fullscreen, canvas goes edge-to-edge; only the button respects safe area */
+.threejs-container.fullscreen {
+ padding: 0 !important;
+}
+
/* Fullscreen mode: swap icons and remove border radius */
.threejs-container.fullscreen .fullscreen-btn .expand-icon {
display: none;
diff --git a/examples/threejs-server/src/mcp-app-wrapper.tsx b/examples/threejs-server/src/mcp-app-wrapper.tsx
index f9dd263e5..9ef24d263 100644
--- a/examples/threejs-server/src/mcp-app-wrapper.tsx
+++ b/examples/threejs-server/src/mcp-app-wrapper.tsx
@@ -5,7 +5,7 @@
* props to the actual view component.
*/
import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
-import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
+import { useApp } from "@modelcontextprotocol/ext-apps/react";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { StrictMode, useState, useEffect } from "react";
import { createRoot } from "react-dom/client";
@@ -66,14 +66,39 @@ function McpAppWrapper() {
setToolResult(params as CallToolResult);
};
// Host context changes (theme, dimensions, etc.)
+ // Note: we handle styles here instead of using useHostStyles,
+ // because useHostStyles overwrites onhostcontextchanged.
app.onhostcontextchanged = (params) => {
+ const root = document.documentElement;
+ if (params.theme) {
+ root.setAttribute("data-theme", params.theme);
+ root.style.colorScheme = params.theme;
+ }
+ if (params.styles?.variables) {
+ for (const [k, v] of Object.entries(params.styles.variables)) {
+ if (v !== undefined) root.style.setProperty(k, v as string);
+ }
+ }
setHostContext((prev) => ({ ...prev, ...params }));
};
},
});
- // Apply host styling (theme, CSS variables, fonts)
- useHostStyles(app);
+ // Apply initial host styles
+ useEffect(() => {
+ if (!app) return;
+ const ctx = app.getHostContext();
+ const root = document.documentElement;
+ if (ctx?.theme) {
+ root.setAttribute("data-theme", ctx.theme);
+ root.style.colorScheme = ctx.theme;
+ }
+ if (ctx?.styles?.variables) {
+ for (const [k, v] of Object.entries(ctx.styles.variables)) {
+ if (v !== undefined) root.style.setProperty(k, v as string);
+ }
+ }
+ }, [app]);
// Get initial host context after connection
useEffect(() => {
diff --git a/examples/threejs-server/src/threejs-app.tsx b/examples/threejs-server/src/threejs-app.tsx
index 235931641..1de585f9e 100644
--- a/examples/threejs-server/src/threejs-app.tsx
+++ b/examples/threejs-server/src/threejs-app.tsx
@@ -85,6 +85,7 @@ function LoadingShimmer({ height, code }: { height: number; code?: string }) {
display: "flex",
flexDirection: "column",
overflow: "hidden",
+ touchAction: "none",
background:
"linear-gradient(135deg, var(--color-background-secondary, light-dark(#f0f0f5, #2a2a3c)) 0%, var(--color-background-tertiary, light-dark(#e5e5ed, #1e1e2e)) 100%)",
}}
@@ -226,6 +227,8 @@ export default function ThreeJSApp({
const canFullscreen =
hostContext?.availableDisplayModes?.includes("fullscreen") ?? false;
const isFullscreen = currentDisplayMode === "fullscreen";
+ const dims = hostContext?.containerDimensions;
+ const hostHeight = dims && "height" in dims ? dims.height : 0;
// Sync display mode from host context
useEffect(() => {
@@ -272,25 +275,51 @@ export default function ThreeJSApp({
return () => observer.disconnect();
}, []);
+ // Track container width for resize handling
+ const [containerWidth, setContainerWidth] = useState(0);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+ const observer = new ResizeObserver((entries) => {
+ const w = Math.round(entries[0].contentRect.width);
+ if (w > 0) setContainerWidth(w);
+ });
+ observer.observe(containerRef.current);
+ return () => observer.disconnect();
+ }, []);
+
useEffect(() => {
if (!code || !canvasRef.current || !containerRef.current) return;
+ // Prevent touch events from propagating to the parent scroll view.
+ // Three.js OrbitControls uses pointer events, which don't suppress native
+ // scroll gesture recognition on touch devices.
+ const canvas = canvasRef.current;
+ const preventDefault = (e: TouchEvent) => e.preventDefault();
+ canvas.addEventListener("touchstart", preventDefault, { passive: false });
+ canvas.addEventListener("touchmove", preventDefault, { passive: false });
+
// Cleanup previous animation
animControllerRef.current?.cleanup();
animControllerRef.current = createAnimationController();
setError(null);
- const width = containerRef.current.offsetWidth || 800;
+ const w = containerWidth || containerRef.current.offsetWidth || 800;
+ const h = isFullscreen && hostHeight > 0 ? hostHeight : height;
executeThreeCode(
code,
canvasRef.current,
- width,
- height,
+ w,
+ h,
animControllerRef.current.visibilityAwareRAF,
).catch((e) => setError(e instanceof Error ? e.message : "Unknown error"));
- return () => animControllerRef.current?.cleanup();
- }, [code, height]);
+ return () => {
+ canvas.removeEventListener("touchstart", preventDefault);
+ canvas.removeEventListener("touchmove", preventDefault);
+ animControllerRef.current?.cleanup();
+ };
+ }, [code, height, containerWidth, isFullscreen, hostHeight]);
if (isStreaming || !code) {
return (
@@ -303,7 +332,7 @@ export default function ThreeJSApp({
return (