All posts

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

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkHost ContextSafe Areas
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.

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:

FieldUse it for
themeLight/dark mode decisions
stylesHost CSS variables and font CSS
displayModeInline, fullscreen, or picture-in-picture layout
availableDisplayModesWhether to show expand, pop out, or close controls
containerDimensionsResponsive layout based on host container size
localeNumber, date, and currency formatting
timeZoneDate and time display
platformWeb, desktop, or mobile branches
deviceCapabilitiesTouch and hover behavior
safeAreaInsetsPadding around unusable edges
userAgentHost identification when you need host-specific fallback behavior
toolInfoCurrent 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 touch is true.
  • Avoid hover-only controls when hover is 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

MistakeFix
Reading context once during module loadRead it inside a component or after connect()
Assuming every host sends every fieldUse defaults and feature checks
Using window.innerWidth as the only layout inputUse useViewport() and safe areas
Hardcoding light theme colorsUse host CSS variables or useTheme()
Showing hover-only controls on touch devicesUse useDeviceCapabilities() and keep controls keyboard-accessible
Formatting dates in the tool handlerReturn ISO data, format with useLocale() and useTimeZone()
Requesting fullscreen without checking supportRead available modes before showing mode-change controls
Writing host-specific branches too earlyBranch 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:

  1. Run pnpm dev.
  2. Open the inspector.
  3. Switch between ChatGPT and Claude host modes.
  4. Toggle light and dark theme.
  5. Test inline and fullscreen display modes.
  6. Resize the device width.
  7. 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:

  1. Start with SafeArea as the root.
  2. Render tool data without host branches.
  3. Use host CSS variables for color and font.
  4. Add useViewport() for layout breakpoints.
  5. Add useDisplayMode() only where the mode changes the actual interaction.
  6. Add useLocale() and useTimeZone() for user-facing formatted values.
  7. Add useDeviceCapabilities() for touch and hover behavior.
  8. Use useHostContext() directly only when the focused hooks are not enough.
  9. 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

Documentation →
npx sunpeak new

Further Reading

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.