Add safeAreaInsets support to all example apps (#202)

Apply host-provided safe area insets as padding on main containers
across all examples. This ensures content doesn't render under
system UI elements like phone notches.

React apps use `useState` + `onhostcontextchanged` handler with
initial fetch via `getHostContext()` in `useEffect`. Vanilla JS
apps apply padding in the handler and invoke it after `connect()`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Hefner 2026-01-08 12:03:13 -06:00 • committed by GitHub
commit 79670b4dcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 267 additions and 48 deletions

View file

@ -1,7 +1,7 @@
/**
* @file App that demonstrates a few features using MCP Apps SDK + React.
*/
import type { App } from "@modelcontextprotocol/ext-apps";
import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { useApp } from "@modelcontextprotocol/ext-apps/react";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { StrictMode, useCallback, useEffect, useState } from "react";
@ -27,6 +27,7 @@ function extractTime(callToolResult: CallToolResult): string {
function GetTimeApp() {
const [toolResult, setToolResult] = useState<CallToolResult | null>(null);
const [hostContext, setHostContext] = useState<McpUiHostContext | undefined>();
const { app, error } = useApp({
appInfo: IMPLEMENTATION,
capabilities: {},
@ -45,21 +46,32 @@ function GetTimeApp() {
};
app.onerror = log.error;
app.onhostcontextchanged = (params) => {
setHostContext((prev) => ({ ...prev, ...params }));
};
},
});
useEffect(() => {
if (app) {
setHostContext(app.getHostContext());
}
}, [app]);
if (error) return <div><strong>ERROR:</strong> {error.message}</div>;
if (!app) return <div>Connecting...</div>;
return <GetTimeAppInner app={app} toolResult={toolResult} />;
return <GetTimeAppInner app={app} toolResult={toolResult} hostContext={hostContext} />;
}
interface GetTimeAppInnerProps {
app: App;
toolResult: CallToolResult | null;
hostContext?: McpUiHostContext;
}
function GetTimeAppInner({ app, toolResult }: GetTimeAppInnerProps) {
function GetTimeAppInner({ app, toolResult, hostContext }: GetTimeAppInnerProps) {
const [serverTime, setServerTime] = useState("Loading...");
const [messageText, setMessageText] = useState("This is message text.");
const [logText, setLogText] = useState("This is log text.");
@ -109,7 +121,15 @@ function GetTimeAppInner({ app, toolResult }: GetTimeAppInnerProps) {
}, [app, linkUrl]);
return (
<main className={styles.main}>
<main
className={styles.main}
style={{
paddingTop: hostContext?.safeAreaInsets?.top,
paddingRight: hostContext?.safeAreaInsets?.right,
paddingBottom: hostContext?.safeAreaInsets?.bottom,
paddingLeft: hostContext?.safeAreaInsets?.left,
}}
>
<p className={styles.notice}>Watch activity in the DevTools console!</p>
<div className={styles.action}>

View file

@ -1,7 +1,7 @@
/**
* @file App that demonstrates a few features using MCP Apps SDK with vanilla JS.
*/
import { App } from "@modelcontextprotocol/ext-apps";
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import "./global.css";
import "./mcp-app.css";
@ -21,6 +21,7 @@ function extractTime(result: CallToolResult): string {
// Get element references
const mainEl = document.querySelector(".main") as HTMLElement;
const serverTimeEl = document.getElementById("server-time")!;
const getTimeBtn = document.getElementById("get-time-btn")!;
const messageText = document.getElementById("message-text") as HTMLTextAreaElement;
@ -30,6 +31,15 @@ const sendLogBtn = document.getElementById("send-log-btn")!;
const linkUrl = document.getElementById("link-url") as HTMLInputElement;
const openLinkBtn = document.getElementById("open-link-btn")!;
function handleHostContextChanged(ctx: McpUiHostContext) {
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`;
}
}
// Create app instance
const app = new App({ name: "Get Time App", version: "1.0.0" });
@ -51,6 +61,8 @@ app.ontoolresult = (result) => {
app.onerror = log.error;
app.onhostcontextchanged = handleHostContextChanged;
// Add event listeners
getTimeBtn.addEventListener("click", async () => {
@ -92,4 +104,9 @@ openLinkBtn.addEventListener("click", async () => {
// Connect to host
app.connect();
app.connect().then(() => {
const ctx = app.getHostContext();
if (ctx) {
handleHostContextChanged(ctx);
}
});

View file

@ -1,7 +1,7 @@
/**
* Budget Allocator App - Interactive budget allocation with real-time visualization
*/
import { App } from "@modelcontextprotocol/ext-apps";
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { Chart, registerables } from "chart.js";
import "./global.css";
import "./mcp-app.css";
@ -87,6 +87,7 @@ const state: AppState = {
// DOM References
// ---------------------------------------------------------------------------
const appContainer = document.querySelector(".app-container") as HTMLElement;
const budgetSelector = document.getElementById(
"budget-selector",
) as HTMLSelectElement;
@ -620,6 +621,17 @@ app.ontoolresult = (result) => {
app.onerror = log.error;
function handleHostContextChanged(ctx: McpUiHostContext) {
if (ctx.safeAreaInsets) {
appContainer.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
appContainer.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
appContainer.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
appContainer.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
}
}
app.onhostcontextchanged = handleHostContextChanged;
// Handle theme changes
window
.matchMedia("(prefers-color-scheme: dark)")
@ -631,4 +643,9 @@ window
});
// Connect to host
app.connect();
app.connect().then(() => {
const ctx = app.getHostContext();
if (ctx) {
handleHostContextChanged(ctx);
}
});

View file

@ -4,7 +4,7 @@
* Interactive cohort retention analysis heatmap showing customer retention
* over time by signup month. Hover for details, click to drill down.
*/
import type { App } from "@modelcontextprotocol/ext-apps";
import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { useApp } from "@modelcontextprotocol/ext-apps/react";
import { StrictMode, useCallback, useEffect, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
@ -65,19 +65,39 @@ function formatNumber(n: number): string {
// Main App Component
function CohortHeatmapApp() {
const [hostContext, setHostContext] = useState<
McpUiHostContext | undefined
>();
const { app, error } = useApp({
appInfo: IMPLEMENTATION,
capabilities: {},
onAppCreated: (app) => {
app.onhostcontextchanged = (params) => {
setHostContext((prev) => ({ ...prev, ...params }));
};
},
});
useEffect(() => {
if (app) {
setHostContext(app.getHostContext());
}
}, [app]);
if (error) return <div className={styles.error}>ERROR: {error.message}</div>;
if (!app) return <div className={styles.loading}>Connecting...</div>;
return <CohortHeatmapInner app={app} />;
return <CohortHeatmapInner app={app} hostContext={hostContext} />;
}
// Inner App with state management
function CohortHeatmapInner({ app }: { app: App }) {
function CohortHeatmapInner({
app,
hostContext,
}: {
app: App;
hostContext?: McpUiHostContext;
}) {
const [data, setData] = useState<CohortData | null>(null);
const [loading, setLoading] = useState(true);
const [selectedMetric, setSelectedMetric] = useState<MetricType>("retention");
@ -143,7 +163,15 @@ function CohortHeatmapInner({ app }: { app: App }) {
}, []);
return (
<main className={styles.container}>
<main
className={styles.container}
style={{
paddingTop: hostContext?.safeAreaInsets?.top,
paddingRight: hostContext?.safeAreaInsets?.right,
paddingBottom: hostContext?.safeAreaInsets?.bottom,
paddingLeft: hostContext?.safeAreaInsets?.left,
}}
>
<Header
selectedMetric={selectedMetric}
selectedPeriodType={selectedPeriodType}

View file

@ -6,6 +6,7 @@ import {
applyHostStyleVariables,
applyHostFonts,
applyDocumentTheme,
type McpUiHostContext,
} from "@modelcontextprotocol/ext-apps";
import { Chart, registerables } from "chart.js";
import "./global.css";
@ -22,6 +23,7 @@ const log = {
};
// DOM element references
const mainEl = document.querySelector(".main") as HTMLElement;
const xAxisSelect = document.getElementById("x-axis") as HTMLSelectElement;
const yAxisSelect = document.getElementById("y-axis") as HTMLSelectElement;
const sizeMetricSelect = document.getElementById(
@ -449,34 +451,35 @@ applyDocumentTheme(systemDark ? "dark" : "light");
app.onerror = log.error;
// Handle host context changes (theme, styles, and fonts from host)
app.onhostcontextchanged = (params) => {
if (params.theme) {
applyDocumentTheme(params.theme);
function handleHostContextChanged(ctx: McpUiHostContext) {
if (ctx.theme) {
applyDocumentTheme(ctx.theme);
}
if (params.styles?.variables) {
applyHostStyleVariables(params.styles.variables);
if (ctx.styles?.variables) {
applyHostStyleVariables(ctx.styles.variables);
}
if (params.styles?.css?.fonts) {
applyHostFonts(params.styles.css.fonts);
if (ctx.styles?.css?.fonts) {
applyHostFonts(ctx.styles.css.fonts);
}
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`;
}
// Recreate chart to pick up new colors
if (state.chart && (params.theme || params.styles?.variables)) {
if (state.chart && (ctx.theme || ctx.styles?.variables)) {
state.chart.destroy();
state.chart = initChart();
}
};
}
app.onhostcontextchanged = handleHostContextChanged;
app.connect().then(() => {
// Apply initial host context after connection
const ctx = app.getHostContext();
if (ctx?.theme) {
applyDocumentTheme(ctx.theme);
}
if (ctx?.styles?.variables) {
applyHostStyleVariables(ctx.styles.variables);
}
if (ctx?.styles?.css?.fonts) {
applyHostFonts(ctx.styles.css.fonts);
if (ctx) {
handleHostContextChanged(ctx);
}
});

View file

@ -1,7 +1,7 @@
/**
* @file App that demonstrates a few features using MCP Apps SDK + React.
*/
import type { App } from "@modelcontextprotocol/ext-apps";
import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { useApp } from "@modelcontextprotocol/ext-apps/react";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { StrictMode, useCallback, useEffect, useState } from "react";
@ -29,6 +29,9 @@ function extractTime(callToolResult: CallToolResult): string {
function GetTimeApp() {
const [toolResult, setToolResult] = useState<CallToolResult | null>(null);
const [hostContext, setHostContext] = useState<
McpUiHostContext | undefined
>();
const { app, error } = useApp({
appInfo: IMPLEMENTATION,
capabilities: {},
@ -49,9 +52,19 @@ function GetTimeApp() {
};
app.onerror = log.error;
app.onhostcontextchanged = (params) => {
setHostContext((prev) => ({ ...prev, ...params }));
};
},
});
useEffect(() => {
if (app) {
setHostContext(app.getHostContext());
}
}, [app]);
if (error)
return (
<div>
@ -60,14 +73,25 @@ function GetTimeApp() {
);
if (!app) return <div>Connecting...</div>;
return <GetTimeAppInner app={app} toolResult={toolResult} />;
return (
<GetTimeAppInner
app={app}
toolResult={toolResult}
hostContext={hostContext}
/>
);
}
interface GetTimeAppInnerProps {
app: App;
toolResult: CallToolResult | null;
hostContext?: McpUiHostContext;
}
function GetTimeAppInner({ app, toolResult }: GetTimeAppInnerProps) {
function GetTimeAppInner({
app,
toolResult,
hostContext,
}: GetTimeAppInnerProps) {
const [serverTime, setServerTime] = useState("Loading...");
const [messageText, setMessageText] = useState("This is message text.");
const [logText, setLogText] = useState("This is log text.");
@ -120,7 +144,15 @@ function GetTimeAppInner({ app, toolResult }: GetTimeAppInnerProps) {
}, [app, linkUrl]);
return (
<main className={styles.main}>
<main
className={styles.main}
style={{
paddingTop: hostContext?.safeAreaInsets?.top,
paddingRight: hostContext?.safeAreaInsets?.right,
paddingBottom: hostContext?.safeAreaInsets?.bottom,
paddingLeft: hostContext?.safeAreaInsets?.left,
}}
>
<p className={styles.notice}>Watch activity in the DevTools console!</p>
<div className={styles.action}>

View file

@ -47,7 +47,22 @@
}
};
function handleHostContextChanged(ctx) {
if (ctx.safeAreaInsets) {
document.body.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
document.body.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
document.body.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
document.body.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
}
}
app.onhostcontextchanged = handleHostContextChanged;
await app.connect();
const ctx = app.getHostContext();
if (ctx) {
handleHostContextChanged(ctx);
}
</script>
</body>
</html>

View file

@ -1,6 +1,7 @@
import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { useApp } from "@modelcontextprotocol/ext-apps/react";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { StrictMode, useState, useMemo, useCallback } from "react";
import { StrictMode, useState, useMemo, useCallback, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { SliderRow } from "./components/SliderRow.tsx";
import { MetricCard } from "./components/MetricCard.tsx";
@ -38,6 +39,15 @@ function extractResultData(result: CallToolResult): CallToolResultData {
const APP_INFO = { name: "SaaS Scenario Modeler", version: "1.0.0" };
function getSafeAreaPaddingStyle(hostContext?: McpUiHostContext) {
return {
paddingTop: hostContext?.safeAreaInsets?.top,
paddingRight: hostContext?.safeAreaInsets?.right,
paddingBottom: hostContext?.safeAreaInsets?.bottom,
paddingLeft: hostContext?.safeAreaInsets?.left,
};
}
// Local defaults for immediate render (should match server's DEFAULT_INPUTS)
const FALLBACK_INPUTS: ScenarioInputs = {
startingMRR: 50000,
@ -51,6 +61,9 @@ function ScenarioModeler() {
const [templates, setTemplates] = useState<ScenarioTemplate[]>([]);
const [defaultInputs, setDefaultInputs] =
useState<ScenarioInputs>(FALLBACK_INPUTS);
const [hostContext, setHostContext] = useState<
McpUiHostContext | undefined
>();
const { app, error } = useApp({
appInfo: APP_INFO,
@ -61,12 +74,21 @@ function ScenarioModeler() {
if (templates) setTemplates(templates);
if (defaultInputs) setDefaultInputs(defaultInputs);
};
app.onhostcontextchanged = (params) => {
setHostContext((prev) => ({ ...prev, ...params }));
};
},
});
useEffect(() => {
if (app) {
setHostContext(app.getHostContext());
}
}, [app]);
if (error) {
return (
<main className="main">
<main className="main" style={getSafeAreaPaddingStyle(hostContext)}>
<div className="loading">Error: {error.message}</div>
</main>
);
@ -74,25 +96,31 @@ function ScenarioModeler() {
if (!app) {
return (
<main className="main">
<main className="main" style={getSafeAreaPaddingStyle(hostContext)}>
<div className="loading">Connecting...</div>
</main>
);
}
return (
<ScenarioModelerInner templates={templates} defaultInputs={defaultInputs} />
<ScenarioModelerInner
templates={templates}
defaultInputs={defaultInputs}
hostContext={hostContext}
/>
);
}
interface ScenarioModelerInnerProps {
templates: ScenarioTemplate[];
defaultInputs: ScenarioInputs;
hostContext?: McpUiHostContext;
}
function ScenarioModelerInner({
templates,
defaultInputs,
hostContext,
}: ScenarioModelerInnerProps) {
const [inputs, setInputs] = useState<ScenarioInputs>(FALLBACK_INPUTS);
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
@ -141,7 +169,7 @@ function ScenarioModelerInner({
}, [selectedTemplate]);
return (
<main className="main">
<main className="main" style={getSafeAreaPaddingStyle(hostContext)}>
{/* Header */}
<header className="header">
<h1 className="header-title">SaaS Scenario Modeler</h1>

View file

@ -1,7 +1,7 @@
/**
* @file System Monitor App - displays real-time OS metrics with Chart.js
*/
import { App } from "@modelcontextprotocol/ext-apps";
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { Chart, registerables } from "chart.js";
import "./global.css";
import "./mcp-app.css";
@ -40,6 +40,7 @@ interface SystemStats {
}
// DOM element references
const mainEl = document.querySelector(".main") as HTMLElement;
const pollToggleBtn = document.getElementById("poll-toggle-btn")!;
const statusIndicator = document.getElementById("status-indicator")!;
const statusText = document.getElementById("status-text")!;
@ -360,7 +361,23 @@ window
// Register handlers and connect
app.onerror = log.error;
app.connect();
function handleHostContextChanged(ctx: McpUiHostContext) {
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`;
}
}
app.onhostcontextchanged = handleHostContextChanged;
app.connect().then(() => {
const ctx = app.getHostContext();
if (ctx) {
handleHostContextChanged(ctx);
}
});
// Auto-start polling after a short delay
setTimeout(startPolling, 500);

View file

@ -7,7 +7,7 @@
import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { useApp } from "@modelcontextprotocol/ext-apps/react";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { StrictMode, useState, useCallback } from "react";
import { StrictMode, useState, useCallback, useEffect } from "react";
import { createRoot } from "react-dom/client";
import ThreeJSApp from "./threejs-app.tsx";
import "./global.css";
@ -67,11 +67,21 @@ function McpAppWrapper() {
};
// Host context changes (theme, dimensions, etc.)
app.onhostcontextchanged = (params) => {
setHostContext(params);
setHostContext((prev) => ({ ...prev, ...params }));
};
},
});
// Get initial host context after connection
useEffect(() => {
if (app) {
const ctx = app.getHostContext();
if (ctx) {
setHostContext(ctx);
}
}
}, [app]);
// Memoized callbacks that forward to app methods
const callServerTool = useCallback<App["callServerTool"]>(
(params, options) => app!.callServerTool(params, options),

View file

@ -140,7 +140,7 @@ export default function ThreeJSApp({
toolInputs,
toolInputsPartial,
toolResult: _toolResult,
hostContext: _hostContext,
hostContext,
callServerTool: _callServerTool,
sendMessage: _sendMessage,
openLink: _openLink,
@ -155,6 +155,14 @@ export default function ThreeJSApp({
const partialCode = toolInputsPartial?.code;
const isStreaming = !toolInputs && !!toolInputsPartial;
const safeAreaInsets = hostContext?.safeAreaInsets;
const containerStyle = {
paddingTop: safeAreaInsets?.top,
paddingRight: safeAreaInsets?.right,
paddingBottom: safeAreaInsets?.bottom,
paddingLeft: safeAreaInsets?.left,
};
useEffect(() => {
if (!code || !canvasRef.current || !containerRef.current) return;
@ -166,11 +174,19 @@ export default function ThreeJSApp({
}, [code, height]);
if (isStreaming || !code) {
return <LoadingShimmer height={height} code={partialCode} />;
return (
<div style={containerStyle}>
<LoadingShimmer height={height} code={partialCode} />
</div>
);
}
return (
<div ref={containerRef} className="threejs-container">
<div
ref={containerRef}
className="threejs-container"
style={containerStyle}
>
<canvas
ref={canvasRef}
style={{

View file

@ -1,7 +1,7 @@
/**
* Wiki Explorer - Force-directed graph visualization of Wikipedia link networks
*/
import { App } from "@modelcontextprotocol/ext-apps";
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import {
forceCenter,
@ -366,5 +366,21 @@ app.onerror = (err) => {
console.error("[Wiki Explorer] App error:", err);
};
function handleHostContextChanged(ctx: McpUiHostContext) {
if (ctx.safeAreaInsets) {
document.body.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
document.body.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
document.body.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
document.body.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
}
}
app.onhostcontextchanged = handleHostContextChanged;
// Connect to host
app.connect();
app.connect().then(() => {
const ctx = app.getHostContext();
if (ctx) {
handleHostContextChanged(ctx);
}
});