create-sunpeak-app
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseCreate Sunpeak App
创建Sunpeak App
Sunpeak is a React framework built on 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.
@modelcontextprotocol/ext-appssunpeak devsunpeak buildsunpeak startSunpeak 是基于 构建的React框架,用于开发可在AI聊天宿主(ChatGPT、Claude)中运行的带交互式UI的MCP App。它提供React钩子、开发模拟器、CLI( / / )以及结构化的项目约定。
@modelcontextprotocol/ext-appssunpeak devsunpeak buildsunpeak startGetting Reference Code
获取参考代码
Clone the sunpeak repo for working examples:
bash
git clone --depth 1 https://github.com/Sunpeak-AI/sunpeak /tmp/sunpeakTemplate app lives at . This is the canonical project structure — read it first.
/tmp/sunpeak/packages/sunpeak/template/克隆Sunpeak仓库获取可用示例:
bash
git clone --depth 1 https://github.com/Sunpeak-AI/sunpeak /tmp/sunpeak模板应用位于 。这是标准的项目结构,请先阅读该目录内容。
/tmp/sunpeak/packages/sunpeak/template/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: (name derived from directory)
src/resources/{name}/{name}.tsx - Tools: (name derived from filename)
src/tools/{name}.ts - Simulations: (flat directory,
tests/simulations/*.jsonstring references tool filename)"tool"
my-sunpeak-app/
├── src/
│ ├── resources/
│ │ └── {name}/
│ │ └── {name}.tsx # 资源组件 + ResourceConfig 导出
│ ├── tools/
│ │ └── {name}.ts # 工具元数据、Zod schema、处理器
│ ├── server.ts # 可选的服务端入口(鉴权、配置)
│ └── styles/
│ └── globals.css # Tailwind 导入文件
├── tests/
│ ├── simulations/
│ │ └── *.json # 模拟测试文件(平级目录)
│ └── e2e/
│ └── {name}.spec.ts # Playwright 测试用例
├── package.json
└── (vite.config.ts, tsconfig.json 等由sunpeak CLI管理)项目采用约定式发现机制:
- 资源:(名称自动从目录名推导)
src/resources/{name}/{name}.tsx - 工具:(名称自动从文件名推导)
src/tools/{name}.ts - 模拟文件:(平级目录,
tests/simulations/*.json字段关联工具文件名)"tool"
Resource Component Pattern
资源组件模式
Every resource file exports two things:
- — A
resourceobject with MCP resource metadata (name is auto-derived from directory)ResourceConfig - A named React component — The UI ()
{Name}Resource
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
<SafeArea> - 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)] - — provide types for both input and output
useToolData<TInput, TOutput>() - All hooks must be called before any early (React rules of hooks)
return - Do NOT mutate directly inside hooks — use
appfor class setterseslint-disable-next-line react-hooks/immutability
每个资源文件需要导出两个内容:
- — 包含MCP资源元数据的
resource对象(名称自动从目录名推导,例如ResourceConfig)src/resources/weather/ - 命名React组件 — UI部分(命名格式为)
{Name}Resource
tsx
import { useToolData, useHostContext, useDisplayMode, SafeArea } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';
// MCP资源元数据(名称自动从目录名推导: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'],
},
},
},
};
// 类型定义
interface WeatherInput {
city: string;
units?: 'metric' | 'imperial';
}
interface WeatherOutput {
temperature: number;
condition: string;
humidity: number;
}
// React组件
export function WeatherResource() {
// 所有钩子必须在任何提前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>
);
}规则:
- 始终使用包裹内容,以适配宿主的安全区域内边距
<SafeArea> - 通过Tailwind任意值使用MCP标准CSS变量:、
text-[var(--color-text-primary)]、text-[var(--color-text-secondary)]、bg-[var(--color-background-primary)]border-[var(--color-border-tertiary)] - — 为输入和输出提供类型定义
useToolData<TInput, TOutput>() - 所有钩子必须在任何提前之前调用(遵循React钩子规则)
return - 不要在钩子内部直接修改— 对于类属性设置器,使用
appeslint-disable-next-line react-hooks/immutability
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:
.tsresourcets
// 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,
},
};
}每个工具文件导出元数据、Zod schema和处理器。字段用于关联工具与对应的UI — 纯数据工具可省略该字段:
.tsresourcets
// src/tools/show-weather.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
// 1. 工具元数据(resource字段关联到src/resources/weather/ — 无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(自动转换为MCP的JSON Schema)
export const schema = {
city: z.string().describe('City name'),
units: z.enum(['metric', 'imperial']).describe('Temperature units'),
};
// 3. 处理器 — 为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:
structuredContentreviewTooluseCallServerToolts
// 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 . 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.
reviewTooluseCallServerToolcontentstructuredContentstatusmessagestructuredContent.statusstructuredContent.messagereviewactioncallServerToolreview常见模式是将UI工具(用于审核)与仅后端工具(用于执行)配对使用。UI工具的包含字段。资源组件读取该字段,并在用户确认时通过调用后端工具:
structuredContentreviewTooluseCallServerToolts
// src/tools/review.ts — 无resource字段,所有审核场景共享
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.' },
};
}UI工具在响应中返回,资源组件在用户接受/拒绝时调用。工具同时返回(供宿主模型读取的人类可读文本)和(包含供UI使用的和)。资源组件读取来确定成功/错误样式,并显示。单个工具通过字段处理所有审核场景(购买、差异对比、发布等)。模拟器为调用返回模拟数据,与真实宿主行为一致。请查看模板中的资源获取完整实现。
reviewTooluseCallServerToolcontentstructuredContentstatusmessagestructuredContent.statusstructuredContent.messagereviewactioncallServerToolreviewSimulation Files
模拟文件
Simulations are JSON fixtures that power the dev simulator. Place them in as flat JSON files:
tests/simulations/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
tool(withoutsrc/tools/).ts - — Decorative text shown in simulator (no functional purpose)
userMessage - — Arguments sent to the tool (shown as input to
toolInput)useToolData - — The data rendered by
toolResult.structuredContentuseToolData().output - — Text fallback for non-UI hosts
toolResult.content[] - — Mock responses for
serverToolscalls. Keys are tool names. Values are either a singlecallServerTool(always returned) or an array ofCallToolResultentries for conditional matching against call arguments.{ when, result }
Example with (for resources that call backend-only tools):
serverToolsjson
{
"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.
review-diff.jsonreview-post.jsonresource模拟文件是为开发模拟器提供数据的JSON固定文件。将它们放在目录下作为平级JSON文件:
tests/simulations/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
}
}
}关键字段:
- — 关联
tool目录下工具文件名的字符串(不含src/tools/后缀).ts - — 模拟器中显示的装饰性文本(无实际功能)
userMessage - — 发送给工具的参数(作为
toolInput的输入)useToolData - — 由
toolResult.structuredContent渲染的数据useToolData().output - — 供非UI宿主使用的文本回退内容
toolResult.content[] - — 为
serverTools调用提供的模拟响应。键为工具名称,值可以是单个callServerTool(始终返回)或CallToolResult条目数组,用于根据调用参数进行条件匹配。{ when, result }
包含的示例(适用于调用仅后端工具的资源):
serverToolsjson
{
"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." } } }
]
}
}单个工具支持多个模拟文件:、通过同一工具的字段共享同一个资源。
review-diff.jsonreview-post.jsonresourceCore Hooks Reference
核心钩子参考
All hooks are imported from :
sunpeak| Hook | Returns | Description |
|---|---|---|
| | Reactive tool data from host |
| | Host context (theme, locale, capabilities, etc.) |
| | Current theme |
| | Current display mode (defaults to |
| | Host locale (e.g. |
| | IANA time zone (falls back to browser time zone) |
| | Host-reported platform type |
| | Device input capabilities |
| | Host application identifier |
| | Host style configuration (CSS variables, fonts) |
| | Metadata about the tool call that created this app |
| | Safe area insets (px) |
| | Container dimensions (px) |
| | True if viewport is mobile-sized |
| | Raw MCP App instance for direct SDK calls |
| | Returns a function to call a server-side tool by name |
| | Returns a function to send a message to the conversation |
| | Returns a function to open a URL through the host |
| | Request |
| | Download files through the host (works cross-platform) |
| | Read a resource from the MCP server by URI |
| | List available resources on the MCP server |
| | Push state to the host's model context directly |
| | Send debug log to host |
| | 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 |
所有钩子从导入:
sunpeak| 钩子 | 返回值 | 描述 |
|---|---|---|
| | 来自宿主的响应式工具数据 |
| | 宿主上下文(主题、区域设置、功能等) |
| | 当前主题 |
| | 当前显示模式(默认为 |
| | 宿主区域设置(例如 |
| | IANA时区(回退到浏览器时区) |
| | 宿主报告的平台类型 |
| | 设备输入功能 |
| | 宿主应用标识符 |
| | 宿主样式配置(CSS变量、字体) |
| | 创建此应用的工具调用元数据 |
| | 安全区域内边距(像素) |
| | 容器尺寸(像素) |
| | 如果视口为移动尺寸则返回true |
| | 原始MCP App实例,用于直接调用SDK |
| | 返回一个用于调用服务端工具的函数 |
| | 返回一个用于向对话发送消息的函数 |
| | 返回一个用于通过宿主打开URL的函数 |
| | 请求 |
| | 通过宿主下载文件(跨平台可用) |
| | 通过URI从MCP服务器读取资源 |
| | 列出MCP服务器上的可用资源 |
| | 直接向宿主的模型上下文推送状态 |
| | 向宿主发送调试日志 |
| | 宿主名称、版本和支持的功能 |
| | 注册一个销毁处理程序 |
| | 注册应用提供给宿主的工具(双向工具调用) |
| | React状态,通过 |
useRequestDisplayMode
details
useRequestDisplayModeuseRequestDisplayMode
详情
useRequestDisplayModetsx
const { requestDisplayMode, availableModes } = useRequestDisplayMode();
// Always check availability before requesting
if (availableModes?.includes('fullscreen')) {
await requestDisplayMode('fullscreen');
}
if (availableModes?.includes('pip')) {
await requestDisplayMode('pip');
}tsx
const { requestDisplayMode, availableModes } = useRequestDisplayMode();
// 请求前始终检查可用性
if (availableModes?.includes('fullscreen')) {
await requestDisplayMode('fullscreen');
}
if (availableModes?.includes('pip')) {
await requestDisplayMode('pip');
}useCallServerTool
details
useCallServerTooluseCallServerTool
详情
useCallServerTooltsx
const callTool = useCallServerTool();
const result = await callTool({ name: 'get-weather', arguments: { city: 'Austin' } });
// result: { content?: [...], isError?: boolean }tsx
const callTool = useCallServerTool();
const result = await callTool({ name: 'get-weather', arguments: { city: 'Austin' } });
// result: { content?: [...], isError?: boolean }useSendMessage
details
useSendMessageuseSendMessage
详情
useSendMessagetsx
const sendMessage = useSendMessage();
await sendMessage({
role: 'user',
content: [{ type: 'text', text: 'Please refresh the data.' }],
});tsx
const sendMessage = useSendMessage();
await sendMessage({
role: 'user',
content: [{ type: 'text', text: 'Please refresh the data.' }],
});useAppState
details
useAppStateuseAppState
详情
useAppStateState 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.
updateModelContext()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' });状态在React中保留,并在每次更新后通过自动发送到宿主,因此LLM可以在其上下文窗口中看到当前UI状态。
updateModelContext()tsx
const [state, setState] = useAppState<{ decision: 'accepted' | 'rejected' | null }>({
decision: null,
});
// setState触发重新渲染并将状态推送到模型上下文
setState({ decision: 'accepted' });useToolData
details
useToolDatauseToolData
详情
useToolDatatsx
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.
inputPartialoutputtsx
const {
input, // TInput | null — 最终工具输入参数
inputPartial, // TInput | null — 生成过程中的部分(流式)输入
output, // TOutput | null — 工具结果(优先取structuredContent,其次是content)
isLoading, // boolean — 直到第一个toolResult到达前为true
isError, // boolean — 如果工具返回错误则为true
isCancelled, // boolean — 如果工具被取消则为true
cancelReason, // string | null
} = useToolData<MyInput, MyOutput>(defaultInput, defaultOutput);在LLM生成过程中,使用进行渐进式渲染。使用获取最终数据。
inputPartialoutputuseDownloadFile
details
useDownloadFileuseDownloadFile
详情
useDownloadFiletsx
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,
},
}],
});tsx
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
useReadServerResourceuseListServerResourcesuseReadServerResource
/ useListServerResources
详情
useReadServerResourceuseListServerResourcestsx
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' });tsx
const readResource = useReadServerResource();
const listResources = useListServerResources();
// 列出可用资源
const result = await listResources();
for (const resource of result?.resources ?? []) {
console.log(resource.name, resource.uri);
}
// 通过URI读取特定资源
const content = await readResource({ uri: 'videos://bunny-1mb' });useAppTools
details
useAppToolsuseAppTools
详情
useAppToolsRegister tools the app provides to the host for bidirectional tool calling. Requires capability.
toolstsx
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 }],
}),
}],
});
}注册应用提供给宿主的工具,实现双向工具调用。需要宿主支持功能。
toolstsx
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 testsThe command starts both the Vite dev server and the MCP server together. The simulator runs at . Connect ChatGPT to (or use ngrok for remote testing).
sunpeak devhttp://localhost:3000http://localhost:8000/mcpUse to test production behavior locally with real handlers instead of simulation fixtures.
sunpeak build && sunpeak startThe command supports two orthogonal flags for testing different combinations:
sunpeak dev- — Route
--prod-toolsto real tool handlers instead of simulation mockscallServerTool - — Serve production-built HTML from
--prod-resourcesinstead of Vite HMRdist/ - — Full smoke test: production bundles with real handlers
--prod-tools --prod-resources
bash
pnpm dev # 启动开发服务器(Vite + MCP服务器,端口3000用于Web / 8000用于MCP)
pnpm build # 构建资源并将工具编译到dist/目录
pnpm start # 启动生产环境MCP服务器(真实处理器、鉴权、Zod验证)
pnpm test # 运行单元测试(vitest)
pnpm test:e2e # 运行Playwright端到端测试sunpeak devhttp://localhost:3000http://localhost:8000/mcp使用在本地测试生产环境行为,使用真实处理器而非模拟固定数据。
sunpeak build && sunpeak startsunpeak dev- — 将
--prod-tools路由到真实工具处理器,而非模拟数据callServerTool - — 从
--prod-resources提供生产环境构建的HTML,而非Vite热更新版本dist/ - — 完整冒烟测试:生产环境包 + 真实处理器
--prod-tools --prod-resources
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 variablesThe production server provides:
- — Health check endpoint (
/health) for load balancer probes and monitoring{"status":"ok","uptime":N} - — MCP Streamable HTTP endpoint
/mcp - Graceful shutdown on SIGTERM/SIGINT (5-second drain)
- Structured JSON logging () for log aggregation (Datadog, CloudWatch, etc.)
--json-logs
bash
sunpeak start # 默认:端口8000,监听所有接口
sunpeak start --port 3000 # 自定义端口
sunpeak start --host 127.0.0.1 # 仅绑定到本地主机
sunpeak start --json-logs # 结构化JSON日志
PORT=3000 HOST=127.0.0.1 sunpeak start # 通过环境变量配置生产服务器提供:
- — 健康检查端点(返回
/health),用于负载均衡探测和监控{"status":"ok","uptime":N} - — MCP流式HTTP端点
/mcp - 收到SIGTERM/SIGINT时优雅关闭(5秒排空时间)
- 结构化JSON日志(),用于日志聚合(Datadog、CloudWatch等)
--json-logs
Production Build Output
生产构建输出
sunpeak builddist/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 startdist/src/server.tssunpeak builddist/dist/
├── weather/
│ ├── weather.html # 自包含包(JS + CSS内联)
│ └── weather.json # 包含生成的uri用于缓存破环的ResourceConfig
├── tools/
│ ├── show-weather.js # 编译后的工具处理器 + Zod schema
│ └── ...
├── server.js # 编译后的服务端入口(如果存在src/server.ts)
└── ...sunpeak startdist/src/server.tsPlatform 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
}
}tsx
import { isChatGPT, isClaude, detectPlatform } from 'sunpeak/platform';
// 在资源组件中
function MyResource() {
const platform = detectPlatform(); // 'chatgpt' | 'claude' | 'unknown'
if (isChatGPT()) {
// 可以安全使用ChatGPT特定钩子
}
}ChatGPT-Specific Hooks
ChatGPT特定钩子
Import from . Always feature-detect before use.
sunpeak/platform/chatgpttsx
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 |
| Deprecated — use |
| Open a host-native modal dialog |
| Trigger ChatGPT instant checkout |
从导入。使用前始终进行功能检测。
sunpeak/platform/chatgpttsx
import { useUploadFile, useRequestModal, useRequestCheckout } from 'sunpeak/platform/chatgpt';
import { isChatGPT } from 'sunpeak/platform';
function MyResource() {
// 仅在ChatGPT环境中调用这些钩子
const { upload } = useUploadFile();
const { open } = useRequestModal();
const { checkout } = useRequestCheckout();
}| 钩子 | 描述 |
|---|---|
| 上传文件到ChatGPT,返回文件ID |
| 已废弃 — 改用 |
| 打开宿主原生模态对话框 |
| 触发ChatGPT即时结账 |
SafeArea Component
SafeArea组件
Always wrap resource content in to respect host insets:
<SafeArea>tsx
import { SafeArea } from 'sunpeak';
export function MyResource() {
return (
<SafeArea>
{/* your content */}
</SafeArea>
);
}SafeAreapaddinguseSafeArea()始终使用包裹资源内容,以适配宿主的安全区域内边距:
<SafeArea>tsx
import { SafeArea } from 'sunpeak';
export function MyResource() {
return (
<SafeArea>
{/* 你的内容 */}
</SafeArea>
);
}SafeAreauseSafeArea()paddingStyling with MCP Standard Variables
使用MCP标准变量进行样式设计
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 |
|---|---|---|
| | Primary text |
| | Secondary/muted text |
| | Card/surface background |
| | Secondary/nested surface background |
| | Tertiary background |
| | Primary action color (e.g. badge fill) |
| | Subtle border |
| | Default border |
| — | Dark mode via |
These variables use CSS so they respond to theme changes automatically. The Tailwind variant also works via .
light-dark()dark:[data-theme="dark"]通过Tailwind任意值使用MCP标准CSS变量,而非硬编码颜色。这些变量会自动适配每个宿主的主题(ChatGPT、Claude):
| Tailwind类 | CSS变量 | 用途 |
|---|---|---|
| | 主要文本 |
| | 次要/弱化文本 |
| | 卡片/表面背景 |
| | 次要/嵌套表面背景 |
| | 三级背景 |
| | 主要操作颜色(例如徽章填充) |
| | 细微边框 |
| | 默认边框 |
| — | 通过 |
这些变量使用CSS ,因此会自动响应主题变化。 Tailwind变体也可通过生效。
light-dark()dark:[data-theme="dark"]E2E Tests with Playwright
使用Playwright进行端到端测试
Critical: all resource content renders inside an . Always use for resource elements. Only the simulator chrome (, ) uses directly.
<iframe>page.frameLocator('iframe')header#rootpage.locator()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)| Param | Type | Description |
|---|---|---|
| | Simulation filename without |
| | Host shell (default: |
| | Color theme (default: |
| | Display mode (default: |
| | Locale string, e.g. |
| | Device type preset |
| | Enable touch capability |
| | Enable hover capability |
| | Safe area insets in pixels |
关键注意事项:所有资源内容渲染在内部。对于资源元素,始终使用。只有模拟器的Chrome部分(、)直接使用。
<iframe>page.frameLocator('iframe')header#rootpage.locator()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' }));
// 访问资源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' }));
// 等待内容渲染
const iframe = page.frameLocator('iframe');
await expect(iframe.locator('h1')).toBeVisible();
// 过滤预期的MCP握手日志
const unexpectedErrors = errors.filter(
(e) =>
!e.includes('[IframeResource]') &&
!e.includes('mcp') &&
!e.includes('PostMessage') &&
!e.includes('connect')
);
expect(unexpectedErrors).toHaveLength(0);
});createSimulatorUrl(params)| 参数 | 类型 | 描述 |
|---|---|---|
| | 模拟文件名(不含 |
| | 宿主外壳(默认: |
| | 颜色主题(默认: |
| | 显示模式(默认: |
| | 区域设置字符串,例如 |
| | 设备类型预设 |
| | 启用触摸功能 |
| | 启用悬停功能 |
| | 安全区域内边距(像素) |
ResourceConfig Fields
ResourceConfig字段
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
},
},
},
};typescript
import type { ResourceConfig } from 'sunpeak';
// name自动从目录名推导(src/resources/my-resource/)
export const resource: ResourceConfig = {
title: 'My Resource', // 人类可读标题
description: 'What it shows', // 供MCP宿主使用的描述
mimeType: 'text/html;profile=mcp-app', // MCP App资源必填
_meta: {
ui: {
csp: {
resourceDomains: ['https://cdn.example.com'], // 图片/脚本CDN
connectDomains: ['https://api.example.com'], // API请求目标
},
},
},
};Common Mistakes
常见错误
- Hooks before early returns — All hooks must run unconditionally. Move /
useMemoabove anyuseEffectblocks.if (...) return - Missing — Always wrap content in
<SafeArea>to respect host safe area insets.<SafeArea> - Wrong Playwright locator — Use for resource content, never
page.frameLocator('iframe').locator(...).page.locator(...) - Hardcoded colors — Use MCP standard CSS variables via Tailwind arbitrary values (,
text-[var(--color-text-primary)]) not raw colors.bg-[var(--color-background-primary)] - Simulation tool mismatch — The field in simulation JSON must match a tool filename in
"tool"(e.g.src/tools/matches"tool": "show-weather").src/tools/show-weather.ts - Mutating hook params — Use for
eslint-disable-next-line react-hooks/immutability(class setter, not a mutation).app.onteardown = ... - Forgetting text fallback — Include in simulations for non-UI hosts.
toolResult.content[]
- 钩子在提前return之后调用 — 所有钩子必须无条件执行。将/
useMemo移到任何useEffect代码块之前。if (...) return - 缺少— 始终使用
<SafeArea>包裹内容,以适配宿主的安全区域内边距。<SafeArea> - 错误的Playwright定位器 — 对于资源内容,使用,而非
page.frameLocator('iframe').locator(...)。page.locator(...) - 硬编码颜色 — 通过Tailwind任意值使用MCP标准CSS变量(、
text-[var(--color-text-primary)]),而非硬编码颜色。bg-[var(--color-background-primary)] - 模拟文件工具不匹配 — 模拟JSON中的字段必须与
"tool"中的工具文件名匹配(例如src/tools/对应"tool": "show-weather")。src/tools/show-weather.ts - 修改钩子参数 — 对于(类属性设置器,而非修改),使用
app.onteardown = ...。eslint-disable-next-line react-hooks/immutability - 忘记文本回退内容 — 在模拟文件中包含,以供非UI宿主使用。
toolResult.content[]