| 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 Connector 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 production builds. For testing (e2e, visual regression, live tests, evals), see the test-mcp-server skill. |
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 inspector, a CLI (sunpeak dev / sunpeak build / sunpeak start), 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
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, identity, icons, instructions)
โ โโโ styles/
โ โโโ globals.css # Tailwind imports
โโโ tests/
โ โโโ simulations/
โ โ โโโ *.json # Simulation fixture files (flat directory)
โ โโโ e2e/
โ โ โโโ {name}.spec.ts # Playwright inspector tests
โ โโโ evals/
โ โ โโโ eval.config.ts # Eval config (models, runs, defaults)
โ โ โโโ .env # API keys (gitignored)
โ โ โโโ {name}.eval.ts # Eval specs (one per resource or tool)
โ โโโ live/
โ โโโ playwright.config.ts # Live test config (long timeouts, single worker)
โ โโโ {name}.spec.ts # Live tests against real ChatGPT (one per resource)
โโโ 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:
src/tools/{name}.ts (name derived from filename)
- Simulations:
tests/simulations/*.json (flat directory, "tool" string references tool filename)
Resource Component Pattern
Every resource file exports two things:
resource โ A ResourceConfig object with MCP resource metadata (name is auto-derived from directory)
- A named React component โ The UI (
{Name}Resource)
import { useToolData, useHostContext, useDisplayMode, SafeArea } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';
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'],
},
},
},
};
interface WeatherInput {
city: string;
units?: 'metric' | 'imperial';
}
interface WeatherOutput {
temperature: number;
condition: string;
humidity: number;
}
export function WeatherResource() {
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
<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
Tool Files
Each tool .ts file exports metadata, a Zod schema, an optional output schema, and a handler. The resource field links a tool to its UI โ omit it for data-only tools:
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
resource: 'weather',
title: 'Show Weather',
description: 'Show current weather conditions',
annotations: { readOnlyHint: true },
_meta: { ui: { visibility: ['model', 'app'] } },
};
export const schema = {
city: z.string().describe('City name'),
units: z.enum(['metric', 'imperial']).describe('Temperature units'),
};
export const outputSchema = {
temperature: z.number(),
condition: z.string(),
humidity: z.number(),
};
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 structuredContent includes a reviewTool field. The resource component reads it and calls the backend tool via useCallServerTool when the user confirms:
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 reviewTool in its response, and the resource calls useCallServerTool on accept/reject. The tool returns both content (human-readable text for the host model) and structuredContent (with status and message for the UI). The resource reads structuredContent.status to determine success/error styling and displays structuredContent.message. One review tool handles all review variants (purchases, diffs, posts) via the action field. The inspector returns mock simulation data for callServerTool calls, matching real host behavior. See the template's review resource for the full implementation.
Simulation Files
Simulations are JSON fixtures that power the dev inspector. Place them in tests/simulations/ as flat JSON files:
{
"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:
tool โ String referencing a tool filename in src/tools/ (without .ts)
userMessage โ Decorative text shown in inspector (no functional purpose)
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
serverTools โ Mock responses for callServerTool calls. Keys are tool names. Values are either a single CallToolResult (always returned) or an array of { when, result } entries for conditional matching against call arguments.
Example with serverTools (for resources that call backend-only tools):
{
"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: review-diff.json, review-post.json sharing the same resource via the same tool's resource field.
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') |
useLocale() | string | Host locale (e.g. 'en-US', defaults to 'en-US') |
useTimeZone() | string | IANA time zone (falls back to browser time zone) |
usePlatform() | 'web' | 'desktop' | 'mobile' | undefined | Host-reported platform type |
useDeviceCapabilities() | { touch?, hover? } | Device input capabilities |
useUserAgent() | string | undefined | Host application identifier |
useStyles() | McpUiHostStyles | undefined | Host style configuration (CSS variables, fonts) |
useToolInfo() | { id?, tool } | undefined | Metadata about the tool call that created this app |
useSafeArea() | { top, right, bottom, left } | Safe area insets (px) |
useViewport() | { width, height, maxWidth, maxHeight } | Container dimensions (px) |
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 |
useCreateSamplingMessage() | (params) => Promise<result> | Request LLM completions from the host via sampling/createMessage |
useRegisterTool() | (name, config, cb) => handle | Register app-side tools the host can call; returns handle with enable/disable/remove |
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 |
useDownloadFile() | (params) => Promise<result> | Download files through the host (works cross-platform) |
useReadServerResource() | (params) => Promise<result> | Read a resource from the MCP server by URI |
useListServerResources() | (params?) => Promise<result> | List available resources on the MCP server |
useUpdateModelContext() | (params) => Promise<void> | Push state to the host's model context directly |
useSendLog() | (params) => Promise<void> | Send debug log to host |
useSendToolListChanged() | () => Promise<void> | Notify host that app's tool list changed (after register/remove/enable/disable) |
useHostInfo() | { hostVersion, hostCapabilities } | Host name, version, and supported capabilities |
useTeardown(fn) | void | Register a teardown handler |
useAppTools(config) | void | Register tools the app provides to the host (bidirectional tool calling) |
useRequestTeardown() | () => Promise<void> | Request the host to tear down this app instance |
useAppState(initial) | [state, setState] | React state that auto-syncs to host model context via updateModelContext() |
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. For model evals, seed the same state with the eval case appContext field so follow-up prompts such as "Book this one" can be tested against the selected app state.
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.
useDownloadFile details
const downloadFile = useDownloadFile();
await downloadFile({
contents: [{
type: 'resource',
resource: {
uri: 'file:///export.json',
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
},
}],
});
await downloadFile({
contents: [{
type: 'resource',
resource: {
uri: 'file:///image.png',
mimeType: 'image/png',
blob: base64EncodedPng,
},
}],
});
useReadServerResource / useListServerResources details
const readResource = useReadServerResource();
const listResources = useListServerResources();
const result = await listResources();
for (const resource of result?.resources ?? []) {
console.log(resource.name, resource.uri);
}
const content = await readResource({ uri: 'videos://bunny-1mb' });
useAppTools details
Register tools the app provides to the host for bidirectional tool calling. Tool metadata goes in tools[]; a single onCallTool callback dispatches every invocation.
import { useAppTools } from 'sunpeak';
function MyResource() {
useAppTools({
tools: [
{
name: 'get-selection',
description: 'Get current user selection',
inputSchema: { type: 'object', properties: {} },
},
],
onCallTool: async ({ name, arguments: args }) => {
if (name === 'get-selection') {
return { content: [{ type: 'text', text: selectedText }] };
}
return { content: [], isError: true };
},
});
}
Commands
sunpeak new
sunpeak dev
sunpeak build
sunpeak start
sunpeak upgrade
The sunpeak dev command starts both the Vite dev server and the MCP server together. The inspector runs at http://localhost:3000. 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 sunpeak dev command supports two orthogonal flags for testing different combinations:
--prod-tools โ Route callServerTool to real tool handlers instead of simulation mocks
--prod-resources โ Serve production-built HTML from dist/ instead of Vite HMR
--prod-tools --prod-resources โ Full smoke test: production bundles with real handlers
Production Server Options
sunpeak start
sunpeak start --port 3000
sunpeak start --host 127.0.0.1
sunpeak start --json-logs
PORT=3000 HOST=127.0.0.1 sunpeak start
The production server provides:
/health โ Health check endpoint ({"status":"ok","uptime":N}) for load balancer probes and monitoring
/mcp โ MCP Streamable HTTP endpoint
- Graceful shutdown on SIGTERM/SIGINT (5-second drain)
- Structured JSON logging (
--json-logs) for log aggregation (Datadog, CloudWatch, etc.)
Production Build Output
sunpeak build generates optimized bundles in dist/:
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)
โโโ ...
sunpeak start loads everything from dist/ and starts a production MCP server with real tool handlers, Zod input validation, and optional auth from src/server.ts.
Host Detection
import { isChatGPT, isClaude, detectHost } from 'sunpeak/host';
function MyResource() {
const host = detectHost();
if (isChatGPT()) {
}
}
ChatGPT-Specific Hooks
Import from sunpeak/host/chatgpt. Always feature-detect before use.
import { useUploadFile, useRequestModal, useRequestCheckout } from 'sunpeak/host/chatgpt';
import { isChatGPT } from 'sunpeak/host';
function MyResource() {
const uploadFile = useUploadFile();
const requestModal = useRequestModal();
const requestCheckout = useRequestCheckout();
}
| Hook | Description |
|---|
useUploadFile() | Returns (file: File) => Promise<{ fileId }> to upload a file to ChatGPT |
useRequestModal() | Returns (params) => Promise<void> to open a host-native modal dialog |
useRequestCheckout() | Returns (session) => Promise<...> to 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"].
Testing
For all testing capabilities (e2e tests, visual regression, live tests against real ChatGPT, multi-model evals, Playwright config), install the test-mcp-server skill:
pnpm dlx skills add Sunpeak-AI/sunpeak@test-mcp-server
The testing skill works with any MCP server (not just sunpeak projects). Simulations (above) are part of the dev workflow and defined here. Tests consume them via the mcp fixture.
For testing commands, see the test-mcp-server skill. Quick reference: sunpeak test (unit + e2e), sunpeak test --visual (visual regression), sunpeak test --live (real ChatGPT), sunpeak test --eval (multi-model evals).
ResourceConfig Fields
import type { ResourceConfig } from 'sunpeak';
export const resource: ResourceConfig = {
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'],
},
},
},
};
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.
- 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
"tool" field in simulation JSON must match a tool filename in src/tools/ (e.g. "tool": "show-weather" matches src/tools/show-weather.ts).
- 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.
Troubleshooting: App Not Rendering in ChatGPT/Claude
If the app doesn't show up after the tool is called, follow these steps:
- Check your tunnel โ verify ngrok (or equivalent) is running, pointing to the right port, and using
http not https upstream (ngrok http 8000).
- Check your dev server โ make sure
sunpeak dev is running and the MCP server started on the expected port (watch for "port was in use" messages).
- Restart
sunpeak dev โ stops the dev server (Ctrl+C) and starts fresh. This clears stale connections.
- Refresh or re-add the MCP server โ in the host's settings, click refresh on the MCP server entry, or remove and re-add it with the tunnel URL.
- Hard refresh the host page โ
Cmd+Shift+R / Ctrl+Shift+R clears cached MCP connections.
- Open a new chat โ both hosts cache iframe content per-conversation. A new chat forces a fresh connection.
Full troubleshooting guide: https://sunpeak.ai/docs/app-framework/guides/troubleshooting
Export Paths
| Import | Contents |
|---|
sunpeak | Hooks, types, SDK re-exports, SafeArea, inspector + chatgpt namespaces |
sunpeak/mcp | Server utilities (runMCPServer, createMcpHandler, createProductionMcpServer), tool types (AppToolConfig, ToolHandlerExtra), server config (ServerConfig) |
sunpeak/inspector | Generic Inspector, host shell system, infrastructure |
sunpeak/chatgpt | ChatGPT host shell + Inspector re-export |
sunpeak/claude | Claude host shell + Inspector re-export |
sunpeak/host | Host detection (isChatGPT, isClaude, detectHost) |
sunpeak/host/chatgpt | ChatGPT-specific hooks (useUploadFile, useRequestModal, useRequestCheckout) |
sunpeak/style.css | Main stylesheet |
For testing export paths (sunpeak/test, sunpeak/eval, etc.), see the test-mcp-server skill.
References