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 (
/
/
), and a structured project convention.
Getting Reference Code
Clone the sunpeak repo for working examples:
bash
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}.tsx # Resource component + ResourceConfig export
│ ├── tools/
│ │ └── {name}.ts # Tool metadata, Zod schema, handler
│ ├── server.ts # Optional server entry (auth, config)
│ └── styles/
│ └── globals.css # Tailwind imports
├── tests/
│ ├── simulations/
│ │ └── *.json # Simulation fixture files (flat directory)
│ └── 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}.tsx
(name derived from directory)
- Tools: (name derived from filename)
- Simulations: (flat directory, string references tool filename)
Resource Component Pattern
Every resource file exports two things:
- — A object with MCP resource metadata (name is auto-derived from directory)
- A named React component — The UI ()
tsx
import { useToolData, useHostContext, useDisplayMode, SafeArea } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';
// MCP resource metadata (name auto-derived from directory: src/resources/weather/)
export const resource: ResourceConfig = {
title: 'Weather',
description: 'Show current weather conditions',
mimeType: 'text/html;profile=mcp-app',
_meta: {
ui: {
csp: {
resourceDomains: ['https://cdn.example.com'],
},
},
},
};
// Type definitions
interface WeatherInput {
city: string;
units?: 'metric' | 'imperial';
}
interface WeatherOutput {
temperature: number;
condition: string;
humidity: number;
}
// React component
export function WeatherResource() {
// All hooks must be called before any early return
const { input, output, isLoading } = useToolData<WeatherInput, WeatherOutput>();
const context = useHostContext();
const displayMode = useDisplayMode();
if (isLoading) return <div className="p-4 text-[var(--color-text-secondary)]">Loading...</div>;
const isFullscreen = displayMode === 'fullscreen';
const hasTouch = context?.deviceCapabilities?.touch ?? false;
return (
<SafeArea className={isFullscreen ? 'flex flex-col h-screen' : undefined}>
<div className="p-4">
<h1 className="text-[var(--color-text-primary)] font-semibold">{input?.city}</h1>
<p className={`${hasTouch ? 'text-base' : 'text-sm'} text-[var(--color-text-secondary)]`}>
{output?.temperature}° — {output?.condition}
</p>
</div>
</SafeArea>
);
}
Rules:
- Always wrap in 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 (React rules of hooks)
- Do NOT mutate directly inside hooks — use
eslint-disable-next-line react-hooks/immutability
for class setters
Tool Files
Each tool
file exports metadata, a Zod schema, and a handler. The
field links a tool to its UI — omit it for data-only tools:
ts
// src/tools/show-weather.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
// 1. Tool metadata (resource links to src/resources/weather/ — omit for tools without a UI)
export const tool: AppToolConfig = {
resource: 'weather',
title: 'Show Weather',
description: 'Show current weather conditions',
annotations: { readOnlyHint: true },
_meta: { ui: { visibility: ['model', 'app'] } },
};
// 2. Zod schema (auto-converted to JSON Schema for MCP)
export const schema = {
city: z.string().describe('City name'),
units: z.enum(['metric', 'imperial']).describe('Temperature units'),
};
// 3. Handler — return structured data for the UI
export default async function (args: { city: string; units?: string }, extra: ToolHandlerExtra) {
return {
structuredContent: {
temperature: 72,
condition: 'Partly Cloudy',
humidity: 55,
},
};
}
Backend-Only Tools (Confirmation Loop)
A common pattern pairs a UI tool (for review) with a backend-only tool (for execution). The UI tool's
includes a
field. The resource component reads it and calls the backend tool via
when the user confirms:
ts
// src/tools/review.ts — no resource field, shared by all review variants
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
title: 'Confirm Review',
description: 'Execute or cancel a reviewed action after user approval',
annotations: { readOnlyHint: false },
_meta: { ui: { visibility: ['model', 'app'] } },
};
export const schema = {
action: z.string().describe('Action identifier (e.g., "place_order", "apply_changes")'),
confirmed: z.boolean().describe('Whether the user confirmed'),
decidedAt: z.string().describe('ISO timestamp of decision'),
payload: z.record(z.unknown()).optional().describe('Domain-specific data'),
};
type Args = z.infer<z.ZodObject<typeof schema>>;
export default async function (args: Args, _extra: ToolHandlerExtra) {
if (!args.confirmed) {
return {
content: [{ type: 'text' as const, text: 'Cancelled.' }],
structuredContent: { status: 'cancelled', message: 'Cancelled.' },
};
}
return {
content: [{ type: 'text' as const, text: 'Completed.' }],
structuredContent: { status: 'success', message: 'Completed.' },
};
}
The UI tool returns
in its response, and the resource calls
on accept/reject. The tool returns both
(human-readable text for the host model) and
(with
and
for the UI). The resource reads
to determine success/error styling and displays
structuredContent.message
. One
tool handles all review variants (purchases, diffs, posts) via the
field. The simulator returns mock simulation data for
calls, matching real host behavior. See the template's
resource for the full implementation.
Simulation Files
Simulations are JSON fixtures that power the dev simulator. Place them in
as flat JSON files:
json
{
"tool": "show-weather",
"userMessage": "Show me the weather in Austin, TX.",
"toolInput": {
"city": "Austin",
"units": "imperial"
},
"toolResult": {
"structuredContent": {
"temperature": 72,
"condition": "Partly Cloudy",
"humidity": 55
}
}
}
Key fields:
- — String referencing a tool filename in (without )
- — Decorative text shown in simulator (no functional purpose)
- — Arguments sent to the tool (shown as input to )
toolResult.structuredContent
— The data rendered by
- — Text fallback for non-UI hosts
- — Mock responses for calls. Keys are tool names. Values are either a single (always returned) or an array of entries for conditional matching against call arguments.
Example with
(for resources that call backend-only tools):
json
{
"tool": "review-purchase",
"toolResult": { "structuredContent": { "..." } },
"serverTools": {
"review": [
{ "when": { "confirmed": true }, "result": { "content": [{ "type": "text", "text": "Completed." }], "structuredContent": { "status": "success", "message": "Completed." } } },
{ "when": { "confirmed": false }, "result": { "content": [{ "type": "text", "text": "Cancelled." }], "structuredContent": { "status": "cancelled", "message": "Cancelled." } } }
]
}
}
Multiple simulations per tool are supported:
,
sharing the same resource via the same tool's
field.
Core Hooks Reference
All hooks are imported from
:
| Hook | Returns | Description |
|---|
| { input, inputPartial, output, isLoading, isError, isCancelled }
| Reactive tool data from host |
| | Host context (theme, locale, capabilities, etc.) |
| 'light' | 'dark' | undefined
| Current theme |
| 'inline' | 'pip' | 'fullscreen'
| Current display mode (defaults to ) |
| | Host locale (e.g. , defaults to ) |
| | IANA time zone (falls back to browser time zone) |
| 'web' | 'desktop' | 'mobile' | undefined
| Host-reported platform type |
| | Device input capabilities |
| | Host application identifier |
| McpUiHostStyles | undefined
| Host style configuration (CSS variables, fonts) |
| { id?, tool } | undefined
| Metadata about the tool call that created this app |
| { top, right, bottom, left }
| Safe area insets (px) |
| { width, height, maxWidth, maxHeight }
| Container dimensions (px) |
| | True if viewport is mobile-sized |
| | Raw MCP App instance for direct SDK calls |
| (params) => Promise<result>
| Returns a function to call a server-side tool by name |
| (params) => Promise<void>
| Returns a function to send a message to the conversation |
| (params) => Promise<void>
| Returns a function to open a URL through the host |
| { requestDisplayMode, availableModes }
| Request , , or ; check first |
| (params) => Promise<result>
| Download files through the host (works cross-platform) |
| (params) => Promise<result>
| Read a resource from the MCP server by URI |
| (params?) => Promise<result>
| List available resources on the MCP server |
| (params) => Promise<void>
| Push state to the host's model context directly |
| (params) => Promise<void>
| Send debug log to host |
| { hostVersion, hostCapabilities }
| Host name, version, and supported capabilities |
| | Register a teardown handler |
| | Register tools the app provides to the host (bidirectional tool calling) |
| | React state that auto-syncs to host model context via |
details
tsx
const { requestDisplayMode, availableModes } = useRequestDisplayMode();
// Always check availability before requesting
if (availableModes?.includes('fullscreen')) {
await requestDisplayMode('fullscreen');
}
if (availableModes?.includes('pip')) {
await requestDisplayMode('pip');
}
details
tsx
const callTool = useCallServerTool();
const result = await callTool({ name: 'get-weather', arguments: { city: 'Austin' } });
// result: { content?: [...], isError?: boolean }
details
tsx
const sendMessage = useSendMessage();
await sendMessage({
role: 'user',
content: [{ type: 'text', text: 'Please refresh the data.' }],
});
details
State is preserved in React and automatically sent to the host via
after each update, so the LLM can see the current UI state in its context window.
tsx
const [state, setState] = useAppState<{ decision: 'accepted' | 'rejected' | null }>({
decision: null,
});
// setState triggers a re-render AND pushes state to the model context
setState({ decision: 'accepted' });
details
tsx
const {
input, // TInput | null — final tool input arguments
inputPartial, // TInput | null — partial (streaming) input as it generates
output, // TOutput | null — tool result (structuredContent ?? content)
isLoading, // boolean — true until first toolResult arrives
isError, // boolean — true if tool returned an error
isCancelled, // boolean — true if tool was cancelled
cancelReason, // string | null
} = useToolData<MyInput, MyOutput>(defaultInput, defaultOutput);
Use
for progressive rendering during LLM generation. Use
for the final data.
details
tsx
const downloadFile = useDownloadFile();
// Download embedded text content
await downloadFile({
contents: [{
type: 'resource',
resource: {
uri: 'file:///export.json',
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
},
}],
});
// Download embedded binary content
await downloadFile({
contents: [{
type: 'resource',
resource: {
uri: 'file:///image.png',
mimeType: 'image/png',
blob: base64EncodedPng,
},
}],
});
/ details
tsx
const readResource = useReadServerResource();
const listResources = useListServerResources();
// List available resources
const result = await listResources();
for (const resource of result?.resources ?? []) {
console.log(resource.name, resource.uri);
}
// Read a specific resource by URI
const content = await readResource({ uri: 'videos://bunny-1mb' });
details
Register tools the app provides to the host for bidirectional tool calling. Requires
capability.
tsx
import { useAppTools } from 'sunpeak';
function MyResource() {
useAppTools({
tools: [{
name: 'get-selection',
description: 'Get current user selection',
inputSchema: { type: 'object', properties: {} },
handler: async () => ({
content: [{ type: 'text', text: selectedText }],
}),
}],
});
}
Commands
bash
pnpm dev # Start dev server (Vite + MCP server, port 3000 web / 8000 MCP)
pnpm build # Build resources + compile tools to dist/
pnpm start # Start production MCP server (real handlers, auth, Zod validation)
pnpm test # Run unit tests (vitest)
pnpm test:e2e # Run Playwright e2e tests
The
command starts both the Vite dev server and the MCP server together. The simulator runs at
. Connect ChatGPT to
http://localhost:8000/mcp
(or use ngrok for remote testing).
Use
sunpeak build && sunpeak start
to test production behavior locally with real handlers instead of simulation fixtures.
The
command supports two orthogonal flags for testing different combinations:
- — Route to real tool handlers instead of simulation mocks
- — Serve production-built HTML from instead of Vite HMR
--prod-tools --prod-resources
— Full smoke test: production bundles with real handlers
Production Server Options
bash
sunpeak start # Default: port 8000, all interfaces
sunpeak start --port 3000 # Custom port
sunpeak start --host 127.0.0.1 # Bind to localhost only
sunpeak start --json-logs # Structured JSON logging
PORT=3000 HOST=127.0.0.1 sunpeak start # Via environment variables
The production server provides:
- — Health check endpoint (
{"status":"ok","uptime":N}
) for load balancer probes and monitoring
- — MCP Streamable HTTP endpoint
- Graceful shutdown on SIGTERM/SIGINT (5-second drain)
- Structured JSON logging () for log aggregation (Datadog, CloudWatch, etc.)
Production Build Output
generates optimized bundles in
:
dist/
├── weather/
│ ├── weather.html # Self-contained bundle (JS + CSS inlined)
│ └── weather.json # ResourceConfig with generated uri for cache-busting
├── tools/
│ ├── show-weather.js # Compiled tool handler + Zod schema
│ └── ...
├── server.js # Compiled server entry (if src/server.ts exists)
└── ...
loads everything from
and starts a production MCP server with real tool handlers, Zod input validation, and optional auth from
.
Platform Detection
tsx
import { isChatGPT, isClaude, detectPlatform } from 'sunpeak/platform';
// In a resource component
function MyResource() {
const platform = detectPlatform(); // 'chatgpt' | 'claude' | 'unknown'
if (isChatGPT()) {
// Safe to use ChatGPT-specific hooks
}
}
ChatGPT-Specific Hooks
Import from
. Always feature-detect before use.
tsx
import { useUploadFile, useRequestModal, useRequestCheckout } from 'sunpeak/platform/chatgpt';
import { isChatGPT } from 'sunpeak/platform';
function MyResource() {
// Only call these when on ChatGPT
const { upload } = useUploadFile();
const { open } = useRequestModal();
const { checkout } = useRequestCheckout();
}
| Hook | Description |
|---|
| Upload a file to ChatGPT, returns file ID |
useGetFileDownloadUrl(fileId)
| Deprecated — use from instead |
| Open a host-native modal dialog |
useRequestCheckout(session)
| Trigger ChatGPT instant checkout |
SafeArea Component
Always wrap resource content in
to respect host insets:
tsx
import { SafeArea } from 'sunpeak';
export function MyResource() {
return (
<SafeArea>
{/* your content */}
</SafeArea>
);
}
applies
equal to
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)]
| | Primary text |
text-[var(--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)]
| | Primary action color (e.g. badge fill) |
border-[var(--color-border-tertiary)]
| | Subtle border |
border-[var(--color-border-primary)]
| | Default border |
| variant | — | Dark mode via |
These variables use CSS
so they respond to theme changes automatically. The
Tailwind variant also works via
.
E2E Tests with Playwright
Critical: all resource content renders inside an
. Always use
page.frameLocator('iframe')
for resource elements. Only the simulator chrome (
,
) uses
directly.
typescript
import { test, expect } from '@playwright/test';
import { createSimulatorUrl } from 'sunpeak/chatgpt';
test('renders weather card', async ({ page }) => {
await page.goto(createSimulatorUrl({ simulation: 'show-weather', theme: 'light' }));
// Access elements INSIDE the resource iframe
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: 'show-weather', theme: 'dark' }));
// Wait for content to render
const iframe = page.frameLocator('iframe');
await expect(iframe.locator('h1')).toBeVisible();
// Filter expected MCP handshake noise
const unexpectedErrors = errors.filter(
(e) =>
!e.includes('[IframeResource]') &&
!e.includes('mcp') &&
!e.includes('PostMessage') &&
!e.includes('connect')
);
expect(unexpectedErrors).toHaveLength(0);
});
createSimulatorUrl(params)
builds the URL for a simulation. Full params:
| Param | Type | Description |
|---|
| | Simulation filename without (e.g. ) |
| | Host shell (default: ) |
| | Color theme (default: ) |
| 'inline' | 'pip' | 'fullscreen'
| Display mode (default: ) |
| | Locale string, e.g. |
| 'mobile' | 'tablet' | 'desktop'
| Device type preset |
| | Enable touch capability |
| | Enable hover capability |
safeAreaTop/Bottom/Left/Right
| | Safe area insets in pixels |
ResourceConfig Fields
typescript
import type { ResourceConfig } from 'sunpeak';
// name is auto-derived from the directory (src/resources/my-resource/)
export const resource: ResourceConfig = {
title: 'My Resource', // Human-readable title
description: 'What it shows', // Description for MCP hosts
mimeType: 'text/html;profile=mcp-app', // Required for MCP App resources
_meta: {
ui: {
csp: {
resourceDomains: ['https://cdn.example.com'], // Image/script CDNs
connectDomains: ['https://api.example.com'], // API fetch targets
},
},
},
};
Common Mistakes
- Hooks before early returns — All hooks must run unconditionally. Move / above any blocks.
- Missing — Always wrap content in to respect host safe area insets.
- Wrong Playwright locator — Use
page.frameLocator('iframe').locator(...)
for resource content, never .
- 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 tool mismatch — The field in simulation JSON must match a tool filename in (e.g. matches
src/tools/show-weather.ts
).
- Mutating hook params — Use
eslint-disable-next-line react-hooks/immutability
for (class setter, not a mutation).
- Forgetting text fallback — Include in simulations for non-UI hosts.
References