| name | create-sunpeak-app |
| description | Use when working with sunpeak, or when the user asks to "build an MCP App", "build a ChatGPT App", "add a UI to an MCP tool", "create an interactive resource for Claude or ChatGPT", "build a React UI for an MCP server", or needs guidance on MCP App resources, tool-to-UI data flow, simulation files, host context, platform-specific ChatGPT/Claude features, or end-to-end testing of MCP App UIs. |
Create Sunpeak App
Sunpeak is a React framework built on @modelcontextprotocol/ext-apps for building MCP Apps with interactive UIs that run inside AI chat hosts (ChatGPT, Claude). It provides React hooks, a dev simulator, a CLI (sunpeak dev / sunpeak build), and a structured project convention.
Getting Reference Code
Clone the sunpeak repo for working examples:
git clone --depth 1 https://github.com/Sunpeak-AI/sunpeak /tmp/sunpeak
Template app lives at /tmp/sunpeak/packages/sunpeak/template/. This is the canonical project structure — read it first.
Project Structure
my-sunpeak-app/
├── src/
│ ├── resources/
│ │ └── {name}/
│ │ └── {name}-resource.tsx # Resource component + ResourceConfig export
│ └── styles/
│ └── globals.css # Tailwind imports
├── tests/
│ ├── simulations/
│ │ └── {name}/
│ │ └── {name}-{scenario}-simulation.json # Simulation fixture files
│ └── e2e/
│ └── {name}.spec.ts # Playwright tests
├── package.json
└── (vite.config.ts, tsconfig.json, etc. managed by sunpeak CLI)
Discovery is convention-based:
- Resources:
src/resources/{name}/{name}-resource.tsx
- Simulations:
tests/simulations/{name}/{name}-{scenario}-simulation.json
Resource Component Pattern
Every resource file exports two things:
resource — A ResourceConfig object with MCP metadata
- A named React component — The UI (
{Name}Resource)
import { useToolData, useHostContext, useDisplayMode, SafeArea } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';
export const resource: ResourceConfig = {
name: 'weather',
title: 'Weather',
description: 'Show current weather conditions',
mimeType: 'text/html;profile=mcp-app',
_meta: {
ui: {
csp: {
resourceDomains: ['https://cdn.example.com'],
},
},
},
};
interface WeatherInput {
city: string;
units?: 'metric' | 'imperial';
}
interface WeatherOutput {
temperature: number;
condition: string;
humidity: number;
}
export function WeatherResource() {
const { input, output, isLoading } = useToolData<, >();
context = ();
displayMode = ();
(isLoading) ;
isFullscreen = displayMode === ;
hasTouch = context?.?. ?? ;
(
);
}
Rules:
- Always wrap in
<SafeArea> to respect host insets
- Use MCP standard CSS variables via Tailwind arbitrary values:
text-[var(--color-text-primary)], text-[var(--color-text-secondary)], bg-[var(--color-background-primary)], border-[var(--color-border-tertiary)]
useToolData<TInput, TOutput>() — provide types for both input and output
- All hooks must be called before any early
return (React rules of hooks)
- Do NOT mutate
app directly inside hooks — use eslint-disable-next-line react-hooks/immutability for class setters
Simulation Files
Simulations are JSON fixtures that power the dev simulator and MCP server. Place them at:
tests/simulations/{name}/{name}-{scenario}-simulation.json
{
"userMessage": "Show me the weather in Austin, TX.",
"tool": {
"name": "show-weather",
"description": "Show current weather conditions",
"inputSchema": {
"type": "object",
"properties": {
"city": { "type": "string" },
"units": { "type": "string", "enum": ["metric", "imperial"] }
},
"required": ["city"],
"additionalProperties"
Key fields:
userMessage — Decorative text shown in simulator (no functional purpose)
tool — Full MCP Tool definition (used in tools/list)
toolInput — Arguments sent to the tool (shown as input to useToolData)
toolResult.structuredContent — The data rendered by useToolData().output
toolResult.content[] — Text fallback for non-UI hosts
hostContext — Optional overrides for McpUiHostContext (theme, locale, etc.)
Multiple simulations per resource are supported: review-diff-simulation.json, review-post-simulation.json sharing the same resource.
Core Hooks Reference
All hooks are imported from sunpeak:
| Hook | Returns | Description |
|---|
useToolData<TIn, TOut>() | { input, inputPartial, output, isLoading, isError, isCancelled } | Reactive tool data from host |
useHostContext() | McpUiHostContext | null | Host context (theme, locale, capabilities, etc.) |
useTheme() | 'light' | 'dark' | undefined | Current theme |
useDisplayMode() | 'inline' | 'pip' | 'fullscreen' | Current display mode (defaults to 'inline') |
useSafeArea() | { top, right, bottom, left } | Safe area insets |
useLocale() | string | undefined | Host locale (e.g. 'en-US') |
useViewport() | { width, height } | Viewport dimensions |
useIsMobile() | boolean | True if viewport is mobile-sized |
useApp() | App | null | Raw MCP App instance for direct SDK calls |
useCallServerTool() | (params) => Promise<result> | Returns a function to call a server-side tool by name |
useSendMessage() | (params) => Promise<void> | Returns a function to send a message to the conversation |
useOpenLink() | (params) => Promise<void> | Returns a function to open a URL through the host |
useRequestDisplayMode() | { requestDisplayMode, availableModes } | Request 'inline', 'pip', or 'fullscreen'; check availableModes first |
useRequestDisplayMode details
const { requestDisplayMode, availableModes } = useRequestDisplayMode();
if (availableModes?.includes('fullscreen')) {
await requestDisplayMode('fullscreen');
}
if (availableModes?.includes('pip')) {
await requestDisplayMode('pip');
}
useCallServerTool details
const callTool = useCallServerTool();
const result = await callTool({ name: 'get-weather', arguments: { city: 'Austin' } });
useSendMessage details
const sendMessage = useSendMessage();
await sendMessage({
role: 'user',
content: [{ type: 'text', text: 'Please refresh the data.' }],
});
useAppState details
State is preserved in React and automatically sent to the host via updateModelContext() after each update, so the LLM can see the current UI state in its context window.
const [state, setState] = useAppState<{ decision: 'accepted' | 'rejected' | null }>({
decision: null,
});
setState({ decision: 'accepted' });
useToolData details
const {
input,
inputPartial,
output,
isLoading,
isError,
isCancelled,
cancelReason,
} = useToolData<MyInput, MyOutput>(defaultInput, defaultOutput);
Use inputPartial for progressive rendering during LLM generation. Use output for the final data.
Commands
pnpm dev
pnpm build
pnpm test
pnpm test:e2e
The sunpeak dev command starts both the Vite dev server and the MCP server together. The simulator runs at http://localhost:3000. Connect ChatGPT to http://localhost:8000/mcp (or use ngrok for remote testing).
Production Build Output
sunpeak build generates optimized bundles in dist/, one folder per resource:
dist/
├── weather/
│ ├── weather.html # Self-contained bundle (JS + CSS inlined)
│ └── weather.json # ResourceConfig with generated uri for cache-busting
├── review/
│ ├── review.html
│ └── review.json
└── ...
The .json file contains the ResourceConfig extracted from your .tsx file and a generated uri (e.g. ui://weather?v=abc123). Host both files and reference the .html in your production MCP server's registerAppResource call.
Platform Detection
import { isChatGPT, isClaude, detectPlatform } from 'sunpeak/platform';
function MyResource() {
const platform = detectPlatform();
if (isChatGPT()) {
}
}
ChatGPT-Specific Hooks
Import from sunpeak/platform/chatgpt. Always feature-detect before use.
import { useUploadFile, useRequestModal, useRequestCheckout } from 'sunpeak/platform/chatgpt';
import { isChatGPT } from 'sunpeak/platform';
function MyResource() {
const { upload } = useUploadFile();
const { open } = useRequestModal();
const { checkout } = useRequestCheckout();
}
| Hook | Description |
|---|
useUploadFile() | Upload a file to ChatGPT, returns file ID |
useGetFileDownloadUrl(fileId) | Get a download URL for an uploaded file |
useRequestModal(params) | Open a host-native modal dialog |
useRequestCheckout(session) | Trigger ChatGPT instant checkout |
SafeArea Component
Always wrap resource content in <SafeArea> to respect host insets:
import { SafeArea } from 'sunpeak';
export function MyResource() {
return (
<SafeArea>
{/* your content */}
</SafeArea>
);
}
SafeArea applies padding equal to useSafeArea() insets automatically.
Styling with MCP Standard Variables
Use MCP standard CSS variables via Tailwind arbitrary values instead of raw colors. These variables adapt automatically to each host's theme (ChatGPT, Claude):
| Tailwind Class | CSS Variable | Usage |
|---|
text-[var(--color-text-primary)] | --color-text-primary | Primary text |
text-[var(--color-text-secondary)] | --color-text-secondary | Secondary/muted text |
bg-[var(--color-background-primary)] | --color-background-primary | Card/surface background |
bg-[var(--color-background-secondary)] | --color-background-secondary | Secondary/nested surface background |
bg-[var(--color-background-tertiary)] | --color-background-tertiary | Tertiary background |
bg-[var(--color-ring-primary)] | --color-ring-primary | Primary action color (e.g. badge fill) |
border-[var(--color-border-tertiary)] | --color-border-tertiary | Subtle border |
border-[var(--color-border-primary)] | --color-border-primary | Default border |
dark: variant | — | Dark mode via [data-theme="dark"] |
These variables use CSS light-dark() so they respond to theme changes automatically. The dark: Tailwind variant also works via [data-theme="dark"].
E2E Tests with Playwright
Critical: all resource content renders inside an <iframe>. Always use page.frameLocator('iframe') for resource elements. Only the simulator chrome (header, #root) uses page.locator() directly.
import { test, expect } from '@playwright/test';
import { createSimulatorUrl } from 'sunpeak/chatgpt';
test('renders weather card', async ({ page }) => {
await page.goto(createSimulatorUrl({ simulation: 'weather-show', theme: 'light' }));
const iframe = page.frameLocator('iframe');
await expect(iframe.locator('h1')).toHaveText('Austin');
});
test('loads without console errors', async ({ page }) => {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
await page.goto(createSimulatorUrl({ simulation: 'weather-show', theme: 'dark' }));
iframe = page.();
(iframe.()).();
unexpectedErrors = errors.(
!e.() &&
!e.() &&
!e.() &&
!e.()
);
(unexpectedErrors).();
});
createSimulatorUrl(params) builds the URL for a simulation. Full params:
| Param | Type | Description |
|---|
simulation | string | Simulation name without -simulation.json (e.g. 'carousel-show') |
host | 'chatgpt' | 'claude' | Host shell (default: 'chatgpt') |
theme | 'light' | 'dark' | Color theme (default: 'dark') |
displayMode | 'inline' | 'pip' | 'fullscreen' | Display mode (default: 'inline') |
locale | string | Locale string, e.g. 'en-US' |
deviceType | 'mobile' | 'tablet' | 'desktop' | Device type preset |
touch | boolean | Enable touch capability |
hover | boolean | Enable hover capability |
safeAreaTop/Bottom/Left/Right | number | Safe area insets in pixels |
ResourceConfig Fields
import type { ResourceConfig } from 'sunpeak';
export const resource: ResourceConfig = {
name: 'my-resource',
title: 'My Resource',
description: 'What it shows',
mimeType: 'text/html;profile=mcp-app',
_meta: {
ui: {
csp: {
resourceDomains: ['https://cdn.example.com'],
connectDomains: ['https://api.example.com'],
},
},
},
};
AppProvider (Library Use)
When using sunpeak as a library (without the CLI framework), wrap your app in AppProvider to establish the MCP connection:
import { AppProvider, useApp } from 'sunpeak';
createRoot(document.getElementById('root')!).render(
<AppProvider appInfo={{ name: 'MyApp', version: '1.0.0' }} capabilities={{}}>
<MyApp />
</AppProvider>
);
function MyApp() {
const app = useApp();
if (!app) return <div>Connecting...</div>;
return <div>Connected!</div>;
}
When using the sunpeak CLI (sunpeak dev / sunpeak build), AppProvider wrapping is handled automatically by the framework's resource loader.
Common Mistakes
- Hooks before early returns — All hooks must run unconditionally. Move
useMemo/useEffect above any if (...) return blocks.
- Missing
<SafeArea> — Always wrap content in <SafeArea> to respect host safe area insets.
- Wrong Playwright locator — Use
page.frameLocator('iframe').locator(...) for resource content, never page.locator(...).
- Hardcoded colors — Use MCP standard CSS variables via Tailwind arbitrary values (
text-[var(--color-text-primary)], bg-[var(--color-background-primary)]) not raw colors.
- Simulation name mismatch — The simulation key is the filename without
-simulation.json: carousel-show-simulation.json → carousel-show.
- Mutating hook params — Use
eslint-disable-next-line react-hooks/immutability for app.onteardown = ... (class setter, not a mutation).
- Forgetting text fallback — Include
toolResult.content[] in simulations for non-UI hosts.
References