Skip to main content
All posts

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

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkHost ContextSafe AreasResponsive Design
MCP App host context tells a resource how it is being rendered, styled, sized, and localized inside the host.

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.

FieldMeaningTypical use
toolInfoThe tool definition and optional JSON-RPC ID that opened the ViewIdentify the launch tool without copying tool metadata
themelight or darkTheme-aware colors and browser color scheme
stylesStandard host CSS variables and optional font CSSMatch host color, type, radius, and spacing tokens
displayModeinline, fullscreen, or pipChange interaction structure for the current presentation
availableDisplayModesModes the Host says it supportsDecide whether an expand or picture-in-picture action is valid
containerDimensionsOne fixed or maximum width plus one fixed or maximum heightRespect host-controlled sizing and overflow limits
localeBCP 47 language and region, such as en-USFormat dates, numbers, currency, and lists
timeZoneIANA time zone, such as America/ChicagoFormat server timestamps for the user
platformweb, desktop, or mobileHandle platform-specific interaction limits
deviceCapabilitiesOptional touch and hover booleansChoose tap targets and avoid hover-only controls
safeAreaInsetsTop, right, bottom, and left pixels that should remain clearKeep content away from host controls and system UI
userAgentHost application identifierDiagnostics 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:

ShapeMeaning
{ 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.

AxisMinimum useful cases
HostChatGPT and Claude replicas
ThemeLight and dark
Display modeInline and fullscreen; picture-in-picture when supported
WidthNarrow mobile, conversation column, wide fullscreen
Height ruleContent-sized with maxHeight, fixed fullscreen height
Safe areaZero insets and a mobile top/bottom inset
InputTouch without hover, pointer with hover
LocalizationDefault locale plus one long-label or different-number-format case
Context patchTheme, 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 hostContext is 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 availableDisplayModes first.
  • 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

Documentation →
npx sunpeak new

Further Reading

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.