MCP App Host Context: Theme, Locale, Viewport, and Safe Areas (August 2026)

MCP App host context tells a resource how it is being rendered, styled, sized, and localized inside the host.
An MCP App View runs inside a host-controlled iframe. Your code owns the content, but the host owns the frame around it: theme, available space, presentation mode, localization settings, and the edges that are safe to use.
Host context is how those two sides coordinate. Using it well keeps one View readable in an inline ChatGPT result, a fullscreen Claude Connector, a narrow mobile frame, and a local test runner without maintaining a layout for each product.
TL;DR: Treat host context as optional, reactive input. Use CSS for most responsive layout, containerDimensions for host sizing rules, and safeAreaInsets once at the outer shell. Merge partial context updates instead of replacing the whole object. Prefer standard MCP Apps fields over host-name checks, then test the same View across host, theme, mode, width, locale, and safe-area states.
What the Stable Host Context Contains
MCP Apps has a stable extension version dated 2026-01-26. During ui/initialize, the Host should return hostContext with the current environment. Every field inside it is optional.
| Field | Meaning | Typical use |
|---|---|---|
toolInfo | The tool definition and optional JSON-RPC ID that opened the View | Identify the launch tool without copying tool metadata |
theme | light or dark | Theme-aware colors and browser color scheme |
styles | Standard host CSS variables and optional font CSS | Match host color, type, radius, and spacing tokens |
displayMode | inline, fullscreen, or pip | Change interaction structure for the current presentation |
availableDisplayModes | Modes the Host says it supports | Decide whether an expand or picture-in-picture action is valid |
containerDimensions | One fixed or maximum width plus one fixed or maximum height | Respect host-controlled sizing and overflow limits |
locale | BCP 47 language and region, such as en-US | Format dates, numbers, currency, and lists |
timeZone | IANA time zone, such as America/Chicago | Format server timestamps for the user |
platform | web, desktop, or mobile | Handle platform-specific interaction limits |
deviceCapabilities | Optional touch and hover booleans | Choose tap targets and avoid hover-only controls |
safeAreaInsets | Top, right, bottom, and left pixels that should remain clear | Keep content away from host controls and system UI |
userAgent | Host application identifier | Diagnostics and a last-resort compatibility workaround |
The object is intentionally open to extension, so a host may add namespaced fields. Portable code should ignore unknown fields and provide defaults for missing standard fields.
Host context is not authorization data. A View should never use userAgent, toolInfo, locale, or any other host-supplied UI field to decide which records a user may access. The MCP server must authenticate and authorize each tool call.
Read the Initial Snapshot and Later Patches
The first host context snapshot arrives in the result of the ui/initialize handshake. Later changes arrive through ui/notifications/host-context-changed.
That notification is a partial update. If the Host sends { theme: "dark" }, it has not cleared the current locale, safe area, or dimensions. Replacing the previous object with that patch loses valid state.
The current @modelcontextprotocol/ext-apps App class merges patches into its internal snapshot. Register the event callback before connecting, then read the full snapshot when the callback runs:
import {
App,
applyDocumentTheme,
applyHostFonts,
applyHostStyleVariables,
type McpUiHostContext,
} from '@modelcontextprotocol/ext-apps';
const app = new App({ name: 'order-review', version: '1.0.0' });
function applyContext(ctx: McpUiHostContext | undefined) {
if (ctx?.theme) applyDocumentTheme(ctx.theme);
if (ctx?.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
if (ctx?.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
document.documentElement.lang = ctx?.locale ?? 'en-US';
}
app.onhostcontextchanged = () => {
applyContext(app.getHostContext());
};
await app.connect();
applyContext(app.getHostContext());
This order covers both the initial snapshot and later changes. It also avoids hand-maintaining a second merge implementation.
In React with sunpeak, useHostContext() subscribes to the same current snapshot:
import { SafeArea, useHostContext, useToolData } from 'sunpeak';
interface ReportData {
title: string;
updatedAt: string;
}
export function ReportResource() {
const { output } = useToolData<unknown, ReportData>(undefined, undefined);
const ctx = useHostContext();
if (!output) return null;
const locale = ctx?.locale ?? 'en-US';
const timeZone = ctx?.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
const updatedAt = new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
}).format(new Date(output.updatedAt));
return (
<SafeArea data-testid="app-shell" className="font-sans">
<div className="p-4">
<p className="text-sm text-[var(--color-text-secondary)]">Updated {updatedAt}</p>
<h1 className="mt-2 text-lg font-semibold">{output.title}</h1>
</div>
</SafeArea>
);
}
useHostContext() returns null before the View connects. Focused hooks provide useful defaults, which makes them a better fit when a component needs only one field:
import {
useDeviceCapabilities,
useDisplayMode,
useLocale,
useTheme,
useTimeZone,
useViewport,
} from 'sunpeak';
const theme = useTheme();
const mode = useDisplayMode();
const viewport = useViewport();
const locale = useLocale();
const timeZone = useTimeZone();
const { touch = false, hover = true } = useDeviceCapabilities();
Use useHostContext() when several fields must be evaluated together or you need styles, toolInfo, userAgent, or another less common field.
Read Container Dimensions as a Constraint
containerDimensions does not promise a { width, height } viewport. The stable type combines one horizontal rule with one vertical rule:
type ContainerDimensions = ({ width: number } | { maxWidth?: number }) &
({ height: number } | { maxHeight?: number });
The four useful combinations are:
| Shape | Meaning |
|---|---|
{ width, height } | Both axes are fixed by the Host |
{ width, maxHeight } | Fixed width; content chooses height up to the cap |
{ maxWidth, height } | Content chooses width up to the cap; fixed height |
{ maxWidth, maxHeight } | Content can size naturally within both caps |
A common inline View has a fixed or capped conversation-column width and a content-sized height. Fullscreen more often has fixed width and height. Picture-in-picture may use fixed height with a maximum width. These are patterns, not guarantees.
Use the values as constraints:
import type { McpUiHostContext } from '@modelcontextprotocol/ext-apps';
function getContainerRules(ctx: McpUiHostContext | null) {
const dims = ctx?.containerDimensions;
return {
width: dims && 'width' in dims ? dims.width : undefined,
height: dims && 'height' in dims ? dims.height : undefined,
maxWidth: dims && 'maxWidth' in dims ? dims.maxWidth : undefined,
maxHeight: dims && 'maxHeight' in dims ? dims.maxHeight : undefined,
};
}
Do not turn maxWidth into an exact width. A maximum of 720 pixels says the View may be narrower. The same distinction matters for height because pinning a content-sized inline View to maxHeight creates empty space or scroll bugs.
Prefer CSS for visual breakpoints
CSS responds to the actual rendered box, so it should own most layout changes. Make the app shell a container and branch with container queries:
.app-shell {
container-type: inline-size;
}
.results {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
@container (min-width: 36rem) {
.results {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@container (min-width: 56rem) {
.results {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
Use host dimensions in JavaScript when behavior changes, such as limiting a virtualized list to a fixed host height or moving controls when fullscreen is available. Avoid a JavaScript width breakpoint when CSS can make the same decision without a React render.
Avoid resize feedback loops
The MCP Apps SDK can report View size changes to the Host. If the Host gives a content-sized inline View { maxHeight: 600 } and the View sets height: 600px, its reported content height becomes 600. The Host may then keep that size even when the content needs only 180 pixels.
For a naturally sized axis, leave the exact size unset. Apply only the maximum. For a fixed axis, it is valid to fill the provided size and put overflow on an intentional inner region.
export function FullscreenLayout() {
return (
<SafeArea className="flex min-h-0 flex-col">
<header className="shrink-0 border-b p-4">Project review</header>
<main className="min-h-0 flex-1 overflow-auto p-4">{/* long content */}</main>
<footer className="shrink-0 border-t p-4">{/* actions */}</footer>
</SafeArea>
);
}
sunpeak’s SafeArea uses natural content height in inline and picture-in-picture modes. In fullscreen it fills the iframe with 100dvh, then applies any host maximum width or height constraints.
Apply Safe Areas Once
safeAreaInsets contains four pixel values:
interface SafeAreaInsets {
top: number;
right: number;
bottom: number;
left: number;
}
These values can protect content from mobile system UI, rounded corners, a host composer, floating controls, or other host-owned edges. They are different from browser CSS values such as env(safe-area-inset-bottom) because the browser sees the iframe, while the MCP Host knows what it overlays around that iframe.
Apply host insets at the outer boundary. Keep your normal design padding inside it:
import { SafeArea } from 'sunpeak';
export function SettingsResource() {
return (
<SafeArea data-testid="app-shell" className="flex min-h-0 flex-col">
<header className="border-b p-4">
<h1 className="text-base font-semibold">Settings</h1>
</header>
<main className="min-h-0 flex-1 overflow-auto p-4">{/* fields */}</main>
<footer className="border-t p-4">{/* actions */}</footer>
</SafeArea>
);
}
This separation matters because a nonzero inline paddingTop overrides a Tailwind p-4 class on the same element. Nesting the design padding means a 59-pixel safe top remains 59 pixels, then the header adds its own 16-pixel visual spacing.
For a floating control, read the inset directly:
import { useSafeArea } from 'sunpeak';
export function FloatingActions() {
const safe = useSafeArea();
return (
<div
className="fixed right-3 flex gap-2"
style={{ bottom: `calc(${safe.bottom}px + 0.75rem)` }}
>
<button type="button">Cancel</button>
<button type="button">Apply</button>
</div>
);
}
Do not wrap the same subtree in two full safe-area components. Do not add browser and host insets together unless testing proves the browser inset is separate in that host. Double application pushes controls too far inward and can hide useful space on smaller screens.
Let Host Styles Own the Surface
Host context can include styles.variables and styles.css.fonts. The stable SDK exports helpers that apply both safely to the document. A framework can do that work during connection, as sunpeak does in useHostContext().
Once applied, use the standard variables with CSS fallbacks:
:root {
color-scheme: light dark;
}
.app-shell {
color: var(--color-text-primary, CanvasText);
background: var(--color-background-primary, Canvas);
font-family: var(--font-sans, system-ui, sans-serif);
}
.secondary {
color: var(--color-text-secondary, color-mix(in srgb, CanvasText 68%, transparent));
}
Use theme for behavior that cannot be expressed with host variables, such as selecting a map tile set. Do not maintain a separate hardcoded palette for every host.
Host font CSS may load an external font URL. The View’s resource metadata still needs a Content Security Policy that allows the required resource domain. If font loading fails, the fallback font in var(--font-sans, system-ui, sans-serif) keeps the View usable.
Format Canonical Data with Locale and Time Zone
Return canonical values from tools: ISO timestamps, integer minor currency units, stable IDs, and machine-readable status codes. Format them in the View because host context belongs to the user-facing runtime.
import { useLocale, useTimeZone } from 'sunpeak';
export function InvoiceTotal({ cents, dueAt }: { cents: number; dueAt: string }) {
const locale = useLocale();
const timeZone = useTimeZone();
const amount = new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'USD',
}).format(cents / 100);
const due = new Intl.DateTimeFormat(locale, {
dateStyle: 'long',
timeStyle: 'short',
timeZone,
}).format(new Date(dueAt));
return (
<p>
{amount}, due <time dateTime={dueAt}>{due}</time>
</p>
);
}
The fallback should match your product semantics. A calendar may prefer the browser’s local time zone. An audit log may deliberately fall back to UTC and label it. Do not silently format an invalid or unknown time zone; catch RangeError and use a documented fallback.
Locale also affects text length and reading direction. Intl handles formatting, but it does not translate UI labels. Test long labels, right-to-left layout if supported, and number formats that use different grouping or decimal separators.
Use Capabilities Before Host Names
platform and deviceCapabilities are hints for interaction design:
- Increase target size when touch is available.
- Keep controls visible when hover is absent.
- Preserve keyboard and screen-reader access in every branch.
- Avoid drag-only workflows on devices that may not support precise pointers.
availableDisplayModes is a stronger signal than assuming a host supports fullscreen or picture-in-picture. Check the mode before offering the control, then request the change through the MCP Apps bridge.
import { useHostContext, useRequestDisplayMode } from 'sunpeak';
export function ExpandButton() {
const ctx = useHostContext();
const { availableModes, requestDisplayMode } = useRequestDisplayMode();
const canFullscreen = availableModes?.includes('fullscreen') ?? false;
if (!canFullscreen || ctx?.displayMode === 'fullscreen') return null;
return (
<button type="button" onClick={() => void requestDisplayMode('fullscreen')}>
Expand
</button>
);
}
Host-specific APIs still exist. OpenAI’s current plugin guidance says new ChatGPT UI should use the MCP Apps fields and ui/* bridge first, then feature-detect window.openai only for ChatGPT-only capabilities. The same rule applies to context: branch on dimensions, modes, and capabilities before checking a product name.
Test a Deliberate Context Matrix
One light, inline desktop render covers only a small part of the contract. Build a matrix around the states that can change your View’s behavior.
| Axis | Minimum useful cases |
|---|---|
| Host | ChatGPT and Claude replicas |
| Theme | Light and dark |
| Display mode | Inline and fullscreen; picture-in-picture when supported |
| Width | Narrow mobile, conversation column, wide fullscreen |
| Height rule | Content-sized with maxHeight, fixed fullscreen height |
| Safe area | Zero insets and a mobile top/bottom inset |
| Input | Touch without hover, pointer with hover |
| Localization | Default locale plus one long-label or different-number-format case |
| Context patch | Theme, dimensions, or safe area changes while the View is mounted |
The sunpeak Inspector exposes host, theme, display mode, device presets, dimensions, locale, time zone, touch, hover, and each safe-area edge. It sends those values through the same host-context bridge as a real MCP Apps Host, so you can reproduce layout states without using host credits.
Automate the states that have caused regressions:
import { expect, test } from 'sunpeak/test';
test('keeps controls inside a mobile safe area', async ({ inspector }) => {
const result = await inspector.renderTool('show-orders', undefined, {
theme: 'dark',
displayMode: 'fullscreen',
devicePreset: 'iphone-15',
safeAreaTop: 59,
safeAreaBottom: 34,
});
const app = result.app();
const shell = app.getByTestId('app-shell');
await expect(app.getByRole('heading', { name: 'Orders' })).toBeVisible();
await expect(shell).toHaveCSS('padding-top', '59px');
await expect(shell).toHaveCSS('padding-bottom', '34px');
});
test('uses the expanded result grid in fullscreen', async ({ inspector }) => {
const result = await inspector.renderTool('show-orders', undefined, {
theme: 'light',
displayMode: 'fullscreen',
devicePreset: 'ipad',
});
const app = result.app();
await expect(app.getByTestId('results-grid')).toBeVisible();
await expect(app.getByTestId('results-grid')).toHaveCSS('grid-template-columns', /.+ .+/);
});
Run those specs in both sunpeak Playwright projects. If a mode is not supported by one host, skip that host-mode pair explicitly and keep the reason in the test.
For pure formatting or branching helpers, unit tests are faster. For CSS, safe areas, host fonts, iframe dimensions, and runtime updates, use the real inspector because mocked hooks cannot prove that the bridge and browser layout agree.
Before release, keep one smoke test in each production host you support. The stable contract defines field shapes, but hosts may omit optional fields or roll out support on different schedules. Local replicas make the full matrix fast; the real-host test catches deployment, account-policy, and host-version differences.
A Production Checklist
- The View renders when
hostContextis absent. - Partial context notifications preserve unchanged fields.
- CSS owns visual breakpoints and responds to the actual container.
- Fixed dimensions and maximum dimensions are handled differently.
- Content-sized modes do not pin themselves to a maximum height.
- Safe-area insets are applied once, outside ordinary design padding.
- Fixed-height layouts have one intentional scroll region.
- Host CSS variables include browser-safe fallbacks.
- Locale and time zone fallbacks match product semantics.
- Touch and hover hints never remove keyboard or screen-reader access.
- Display mode controls check
availableDisplayModesfirst. - Host-specific APIs are feature-detected after the standard path works.
- Automated tests cover both hosts, two themes, narrow and wide layouts, safe-area insets, and at least one mounted context change.
Where sunpeak Fits
The stable MCP Apps SDK provides App.connect(), getHostContext(), host-context notifications, theme helpers, and the View-to-Host bridge. sunpeak wraps those primitives in React hooks and SafeArea, then adds local ChatGPT and Claude replicas where each context field can be changed on demand.
That combination is useful because host context bugs are visual and stateful. A typed hook helps you read the contract, but the inspector and Playwright fixture prove that the View still works when the theme changes, the frame narrows, fullscreen opens, safe areas appear, or a host omits an optional value. Start with npx sunpeak new, or run npx sunpeak inspect --server <url> against an existing MCP server.
Get Started
npx sunpeak newFurther Reading
- MCP App lifecycle and the View-to-Host bridge
- MCP App styling with host CSS variables
- Request display mode changes from an MCP App
- Build a cross-host MCP App test matrix
- Implement an MCP App host
- MCP App framework
- MCP App testing framework
- MCP App inspector
- MCP Apps overview
- MCP Apps stable specification
- MCP Apps host context patterns
- OpenAI: Add UI to an MCP server
- sunpeak quickstart
Frequently Asked Questions
What is MCP App host context?
MCP App host context is optional runtime data that an MCP Apps host sends to a rendered View during ui/initialize and later context-change notifications. The stable contract can include theme, host CSS variables and fonts, display mode, available display modes, fixed or maximum container dimensions, locale, time zone, platform, touch and hover capabilities, safe area insets, user agent, and information about the tool call that opened the View.
How do I read host context in an MCP App?
With the low-level MCP Apps SDK, register app.onhostcontextchanged, call app.connect(), and read the initial snapshot with app.getHostContext(). In a sunpeak React resource, use useHostContext() for the full reactive object or focused hooks such as useTheme(), useViewport(), useSafeArea(), useLocale(), useTimeZone(), useDisplayMode(), and useDeviceCapabilities().
Are MCP App host context fields required?
No. The host should include hostContext in its initialization result, but every individual field is optional. A View must still work when a field, or the whole object, is absent. Use portable defaults, CSS fallbacks, and capability checks instead of assuming one host always sends a particular shape.
What is the difference between width and maxWidth in containerDimensions?
width means the host gives the View a fixed container width. maxWidth means the View may size naturally up to that limit. Height follows the same rule: height is fixed, while maxHeight caps a content-sized View. A host supplies one width form and one height form, so code must not assume width and height are always present together.
What are safe area insets in an MCP App?
safeAreaInsets are top, right, bottom, and left pixel values supplied by the host for edges covered by host controls, rounded corners, mobile system UI, or other unusable space. Apply them once at the outer app shell. In sunpeak, SafeArea handles the host insets and viewport constraints; put ordinary design padding in a nested element so both spacing systems remain independent.
Does MCP App host context update after the View renders?
Yes. ui/notifications/host-context-changed carries a partial context update, so a theme change may omit dimensions and a resize may omit locale. The MCP Apps App class keeps the merged current snapshot. Read app.getHostContext() when you need the full state, or use reactive hooks that re-render from the current snapshot.
Should an MCP App use window.innerWidth or containerDimensions?
Use CSS container queries or responsive CSS for visual layout because they react to the actual iframe size. Use containerDimensions for logic that needs the host contract, especially fixed height, maximum height, and mode-aware behavior. window.innerWidth remains a useful browser measurement, but it does not describe safe areas, host limits, available modes, or other host context.
How do I test MCP App host context across ChatGPT and Claude?
Run the same resource in local ChatGPT and Claude host replicas, then vary theme, inline and fullscreen modes, device presets, safe area insets, locale, and time zone. With sunpeak, use the inspector for manual checks and inspector.renderTool() in Playwright tests for repeatable states. Keep a small real-host smoke test because supported fields and values can differ by host version and device.