MCP App Host Context: Theme, Locale, Viewport, and Safe Areas

MCP App host context tells a resource how it is being rendered, styled, sized, and localized inside the host.
Most MCP App tutorials show a clean path: a tool runs, the host renders a resource, and the resource reads structuredContent. That is enough for a static card.
Real apps need more host awareness. A table needs to know if it is inline or fullscreen. A chart needs to know how wide the container is. A form needs safe padding near host chrome. A date needs the user’s locale and time zone. A button may need a touch layout instead of a hover-only affordance.
That is what host context is for.
TL;DR: MCP App host context is the runtime snapshot the host sends to your iframe: theme, styles, display mode, available modes, viewport/container dimensions, locale, time zone, platform, device capabilities, safe area insets, user agent, and current tool info. In React, use useHostContext() for the full object or focused hooks like useTheme(), useViewport(), useSafeArea(), useLocale(), and useDeviceCapabilities(). Treat every field as host-supplied and possibly missing. Test layouts across host, theme, display mode, and device width before shipping.
What Host Context Contains
The MCP Apps getHostContext docs describe host context as the current host environment for a rendered app. It is populated during the initialization handshake and can update while the iframe is mounted.
The fields developers use most:
| Field | Use it for |
|---|---|
theme | Light/dark mode decisions |
styles | Host CSS variables and font CSS |
displayMode | Inline, fullscreen, or picture-in-picture layout |
availableDisplayModes | Whether to show expand, pop out, or close controls |
containerDimensions | Responsive layout based on host container size |
locale | Number, date, and currency formatting |
timeZone | Date and time display |
platform | Web, desktop, or mobile branches |
deviceCapabilities | Touch and hover behavior |
safeAreaInsets | Padding around unusable edges |
userAgent | Host identification when you need host-specific fallback behavior |
toolInfo | Current tool metadata when the resource needs to inspect how it was launched |
Host context is not the same as browser globals. navigator.language, window.innerWidth, and CSS media queries still exist, but they only see the iframe’s browser environment. Host context tells you what the AI host decided about your resource.
That distinction matters because the host controls the iframe. It can resize it, move it into fullscreen, switch theme, change available display modes, or add safe insets without changing your app code.
Read It in React
In a sunpeak resource, the direct hook is useHostContext():
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 ?? 'UTC';
const updatedAt = new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
}).format(new Date(output.updatedAt));
return (
<SafeArea className="font-sans p-4">
<p className="text-sm opacity-70">Updated {updatedAt}</p>
<h1 className="mt-2 text-lg font-semibold">{output.title}</h1>
<p className="mt-3 text-xs opacity-70">Running in {ctx?.displayMode ?? 'unknown'} mode</p>
</SafeArea>
);
}
useHostContext() returns null before the app is connected, so code should use defaults. That is not just defensive programming. Different hosts may omit optional fields, and your app should still render.
For most components, the focused hooks are cleaner:
import { SafeArea, useDeviceCapabilities, useLocale, useTimeZone, useViewport } from 'sunpeak';
export function CompactHeader({ total }: { total: number }) {
const viewport = useViewport();
const locale = useLocale();
const timeZone = useTimeZone();
const { touch = false, hover = true } = useDeviceCapabilities();
const compact = (viewport?.width ?? 0) < 420;
const totalLabel = new Intl.NumberFormat(locale).format(total);
const nowLabel = new Intl.DateTimeFormat(locale, {
hour: 'numeric',
minute: '2-digit',
timeZone,
}).format(new Date());
return (
<SafeArea className="font-sans p-4">
<div className={compact ? 'space-y-2' : 'flex items-center justify-between gap-4'}>
<div>
<p className="text-xs opacity-70">Rows</p>
<p className="text-lg font-semibold">{totalLabel}</p>
</div>
<button
className={touch || !hover ? 'min-h-11 px-4 text-base' : 'min-h-8 px-3 text-sm'}
type="button"
>
Refresh
</button>
</div>
<p className="mt-3 text-xs opacity-60">Local time {nowLabel}</p>
</SafeArea>
);
}
The rule is simple. Use a focused hook when the field has a clear job. Reach for useHostContext() when you need several fields or a less common property.
Read It with the Low-Level App API
If you are not using React, call getHostContext() after connect():
import { App } from '@modelcontextprotocol/ext-apps';
const app = new App({ name: 'report-view', version: '1.0.0' });
await app.connect();
const ctx = app.getHostContext();
const locale = ctx?.locale ?? 'en-US';
const timeZone = ctx?.timeZone ?? 'UTC';
document.documentElement.dataset.theme = ctx?.theme ?? 'light';
render({
displayMode: ctx?.displayMode ?? 'inline',
locale,
timeZone,
});
getHostContext() returns undefined before connect() completes. The context can also change after the first render. In vanilla JavaScript, handle that with onhostcontextchanged:
app.onhostcontextchanged = (patch) => {
if (patch.theme) {
document.documentElement.dataset.theme = patch.theme;
}
const ctx = app.getHostContext();
resizeLayout(ctx?.containerDimensions);
};
The context change notification is a partial update. Read the latest full context from getHostContext() when your layout logic needs more than the changed fields.
The protocol reference lists ui/notifications/host-context-changed as a host-to-view notification, which is why React hooks can re-render without your component wiring a message listener by hand.
Safe Areas: Do Not Hand-Place Padding First
Safe areas are easy to ignore until your app renders under host chrome, a mobile home indicator, or rounded edges. The host supplies safeAreaInsets with top, right, bottom, and left values in pixels.
In sunpeak, the first choice is SafeArea:
import { SafeArea } from 'sunpeak';
export function SettingsResource() {
return (
<SafeArea className="flex h-full flex-col font-sans">
<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">{/* form fields */}</main>
<footer className="border-t p-4">
<button type="button" className="w-full rounded-md px-4 py-2">
Save
</button>
</footer>
</SafeArea>
);
}
SafeArea applies the safe padding and viewport constraints for the common case. It also helps fullscreen layouts fill the iframe viewport so sticky headers, scrollable content, and sticky footers can work as normal flex layouts.
Use useSafeArea() when you need manual control:
import { useSafeArea } from 'sunpeak';
export function FloatingToolbar() {
const safe = useSafeArea();
return (
<div
className="fixed left-3 right-3 flex justify-end gap-2"
style={{ bottom: safe.bottom + 12 }}
>
<button type="button">Cancel</button>
<button type="button">Apply</button>
</div>
);
}
Do not combine SafeArea with a second full set of manual safe padding unless you mean to double the inset.
Viewport and Display Mode Are Separate Signals
Display mode tells you the host’s presentation mode. Viewport tells you the size you have inside that mode.
Use both:
import { SafeArea, useDisplayMode, useViewport } from 'sunpeak';
export function ResultsGrid({ items }: { items: Array<{ id: string; title: string }> }) {
const displayMode = useDisplayMode();
const viewport = useViewport();
const width = viewport?.width ?? 360;
const columns = displayMode === 'fullscreen' && width >= 900 ? 3 : width >= 560 ? 2 : 1;
return (
<SafeArea className="font-sans p-4">
<div
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
>
{items.map((item) => (
<article key={item.id} className="rounded-md border p-3">
<h2 className="text-sm font-medium">{item.title}</h2>
</article>
))}
</div>
</SafeArea>
);
}
Avoid branching only on host name. Two hosts can give the same display mode different usable widths. The same host can give fullscreen very different sizes on desktop and mobile. Layout should usually branch on actual dimensions first, display mode second, and host name only when a feature is truly host-specific.
Locale and Time Zone: Format for the User, Not the Server
Tool handlers often run on servers in UTC. Users do not think in UTC. Host context gives the resource enough information to format dates and numbers in a way that matches the user’s host session.
import { useLocale, useTimeZone } from 'sunpeak';
export function DueDate({ iso }: { iso: string }) {
const locale = useLocale();
const timeZone = useTimeZone();
const label = new Intl.DateTimeFormat(locale, {
dateStyle: 'full',
timeStyle: 'short',
timeZone,
}).format(new Date(iso));
return <time dateTime={iso}>{label}</time>;
}
Keep canonical values in your tool result. Store ISO timestamps, integer cents, stable IDs, and machine-readable codes in structuredContent. Format them in the resource with host context.
This keeps the model and app working from the same stable data while still giving the user local formatting.
Device Capabilities: Touch and Hover
The browser can tell you about media features, but host context can also report device capabilities. Use this for interaction patterns, not for broad product decisions.
Good uses:
- Increase tap target size when
touchis true. - Avoid hover-only controls when
hoveris false. - Show inline action labels when icons alone would be hard to use on touch screens.
- Keep keyboard and screen reader access regardless of touch or hover values.
Example:
import { useDeviceCapabilities } from 'sunpeak';
export function RowActions() {
const { touch = false, hover = true } = useDeviceCapabilities();
const showLabels = touch || !hover;
return (
<div className="flex gap-2">
<button type="button" className={showLabels ? 'min-h-11 px-4' : 'size-8'}>
{showLabels ? 'Open' : 'Go'}
</button>
<button type="button" className={showLabels ? 'min-h-11 px-4' : 'size-8'}>
{showLabels ? 'Archive' : 'OK'}
</button>
</div>
);
}
Do not hide critical actions behind hover. Host context is a signal, not a substitute for accessible controls.
Common Mistakes
| Mistake | Fix |
|---|---|
| Reading context once during module load | Read it inside a component or after connect() |
| Assuming every host sends every field | Use defaults and feature checks |
Using window.innerWidth as the only layout input | Use useViewport() and safe areas |
| Hardcoding light theme colors | Use host CSS variables or useTheme() |
| Showing hover-only controls on touch devices | Use useDeviceCapabilities() and keep controls keyboard-accessible |
| Formatting dates in the tool handler | Return ISO data, format with useLocale() and useTimeZone() |
| Requesting fullscreen without checking support | Read available modes before showing mode-change controls |
| Writing host-specific branches too early | Branch on dimensions, capabilities, and display mode first |
The last point is the one that saves the most time. Host names are a weak layout primitive. Capabilities and dimensions are stronger because they describe the actual runtime condition.
Test Host Context Locally
Host context bugs are hard to catch by staring at one happy-path render. Add a small matrix.
Manual check in the sunpeak Inspector:
- Run
pnpm dev. - Open the inspector.
- Switch between ChatGPT and Claude host modes.
- Toggle light and dark theme.
- Test inline and fullscreen display modes.
- Resize the device width.
- Check the app with long translated labels and large numbers.
Automated check with the inspector fixture:
import { expect, test } from 'sunpeak/test';
test('dashboard adapts to inline dark mode', async ({ inspector }) => {
const result = await inspector.renderTool(
'show-dashboard',
{ accountId: 'acct_123' },
{ theme: 'dark', displayMode: 'inline' }
);
const app = result.app();
await expect(app.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(app.getByTestId('compact-nav')).toBeVisible();
});
test('dashboard uses expanded layout in fullscreen', async ({ inspector }) => {
const result = await inspector.renderTool(
'show-dashboard',
{ accountId: 'acct_123' },
{ theme: 'light', displayMode: 'fullscreen' }
);
const app = result.app();
await expect(app.getByTestId('expanded-grid')).toBeVisible();
});
For context-heavy components, add a unit test that mocks the hooks. That catches pure rendering logic without starting a browser:
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { CompactHeader } from './compact-header';
vi.mock('sunpeak', async () => {
const actual = await vi.importActual<typeof import('sunpeak')>('sunpeak');
return {
...actual,
SafeArea: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
useViewport: () => ({ width: 360, height: 640 }),
useLocale: () => 'en-US',
useTimeZone: () => 'America/Chicago',
useDeviceCapabilities: () => ({ touch: true, hover: false }),
};
});
describe('CompactHeader', () => {
it('formats the total and renders a touch-friendly button', () => {
render(<CompactHeader total={12345} />);
expect(screen.getByText('12,345')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument();
});
});
Use unit tests for formatting and branch logic. Use inspector tests for the real iframe, host styles, display mode behavior, and safe area layout.
A Practical Build Order
When building a new resource:
- Start with
SafeAreaas the root. - Render tool data without host branches.
- Use host CSS variables for color and font.
- Add
useViewport()for layout breakpoints. - Add
useDisplayMode()only where the mode changes the actual interaction. - Add
useLocale()anduseTimeZone()for user-facing formatted values. - Add
useDeviceCapabilities()for touch and hover behavior. - Use
useHostContext()directly only when the focused hooks are not enough. - Test the host, theme, display mode, and width combinations that matter.
That order keeps the app portable. It also keeps host context from becoming a dumping ground for premature branches.
Where sunpeak Fits
The low-level MCP Apps API gives you App.connect(), getHostContext(), and onhostcontextchanged. That is enough if you want to wire the runtime yourself.
sunpeak wraps those pieces in React hooks and an inspector so you can build and test normal resource components. SafeArea, useHostContext, useViewport, useSafeArea, useLocale, useTimeZone, useDeviceCapabilities, useTheme, and useDisplayMode all sit on top of the same MCP Apps runtime. The MCP App framework also gives you the local host replicas and test fixtures needed to exercise those context states without paid host accounts or AI credits.
The important part is the model: your resource is not a normal web page. It is an iframe inside an AI host. Host context is the contract that tells it where it is, how much room it has, what style system it should use, and what the user environment looks like.
Get Started
npx sunpeak newFurther Reading
- MCP App lifecycle - how host context arrives during App.connect()
- MCP App styling - host CSS variables, theme, and native-looking UIs
- Requesting display mode transitions in MCP Apps
- Cross-host testing MCP Apps - build a host, theme, and display mode matrix
- MCP App error handling - loading, cancelled, error, and context change states
- MCP App framework
- MCP App inspector
- sunpeak docs - getHostContext
- sunpeak docs - MCP Apps protocol reference
- MCP Apps announcement - App API and security model
Frequently Asked Questions
What is MCP App host context?
MCP App host context is the runtime environment data the host sends to a rendered app resource. It includes theme, host styles, display mode, available display modes, container dimensions, locale, time zone, platform, device capabilities, safe area insets, user agent, and current tool information.
How do I read host context in an MCP App?
In the low-level MCP Apps SDK, call app.getHostContext() after app.connect() completes. In React with sunpeak, use useHostContext() for the full context object or convenience hooks such as useTheme, useDisplayMode, useViewport, useSafeArea, useLocale, useTimeZone, usePlatform, and useDeviceCapabilities for individual fields.
When should I use useHostContext instead of useTheme or useViewport?
Use specific hooks when you only need one field, such as useTheme for dark mode or useViewport for container dimensions. Use useHostContext when your component needs several fields at once, when you are passing context into a helper function, or when you need less common fields such as userAgent, styles, toolInfo, platform, or safeAreaInsets.
What are safe area insets in MCP Apps?
Safe area insets are padding values supplied by the host so your app avoids host chrome, rounded corners, mobile home indicators, notches, and other unusable edges. In sunpeak, wrap your resource in SafeArea for the common case. Use useSafeArea when you need to manually apply top, right, bottom, and left inset values.
Does host context update after an MCP App has rendered?
Yes. Host context is populated during the initialization handshake and can update while the app is mounted. Theme, display mode, viewport dimensions, safe area insets, and platform context can change. In React, useHostContext and the convenience hooks re-render when the context changes. In vanilla JavaScript, register an onhostcontextchanged handler.
Should I use window.innerWidth or host context container dimensions?
Prefer host context container dimensions for layout decisions inside MCP Apps because the host controls the iframe and display mode. window.innerWidth only tells you the iframe window size. The host context can also include maxWidth, maxHeight, safe area insets, platform, and device capability information that plain browser APIs do not provide.
How do I test host context in MCP Apps?
Use the sunpeak inspector locally to switch host, theme, display mode, and device width while viewing the same resource. For automated tests, render the resource through the inspector fixture with explicit options for theme and displayMode, then add assertions for compact layouts, safe spacing, localized formatting, and feature gates such as touch versus hover behavior.
Can ChatGPT Apps and Claude Connectors use the same host context code?
Yes. Host context is part of the MCP Apps runtime, so portable code can read the same core fields in ChatGPT Apps and interactive Claude Connectors. Hosts may provide different values or omit optional fields, so write defaults and fallbacks, then test the same resource in both host modes before shipping.