create-sunpeak-app

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Create Sunpeak App

创建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
/
sunpeak start
), and a structured project convention.
Sunpeak 是基于
@modelcontextprotocol/ext-apps
构建的React框架,用于开发可在AI聊天宿主(ChatGPT、Claude)中运行的带交互式UI的MCP App。它提供React钩子、开发模拟器、CLI(
sunpeak dev
/
sunpeak build
/
sunpeak start
)以及结构化的项目约定。

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.
克隆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:
    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)
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:
  1. resource
    — A
    ResourceConfig
    object with MCP resource metadata (name is auto-derived from directory)
  2. 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
    <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
每个资源文件需要导出两个内容:
  1. resource
    — 包含MCP资源元数据的
    ResourceConfig
    对象(名称自动从目录名推导,例如
    src/resources/weather/
  2. 命名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>()
    — 为输入和输出提供类型定义
  • 所有钩子必须在任何提前
    return
    之前调用(遵循React钩子规则)
  • 不要在钩子内部直接修改
    app
    — 对于类属性设置器,使用
    eslint-disable-next-line react-hooks/immutability

Tool Files

工具文件

Each tool
.ts
file exports metadata, a Zod schema, and a handler. The
resource
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,
    },
  };
}
每个工具
.ts
文件导出元数据、Zod schema和处理器。
resource
字段用于关联工具与对应的UI — 纯数据工具可省略该字段:
ts
// 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
structuredContent
includes a
reviewTool
field. The resource component reads it and calls the backend tool via
useCallServerTool
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
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 simulator returns mock simulation data for
callServerTool
calls, matching real host behavior. See the template's
review
resource for the full implementation.
常见模式是将UI工具(用于审核)与仅后端工具(用于执行)配对使用。UI工具的
structuredContent
包含
reviewTool
字段。资源组件读取该字段,并在用户确认时通过
useCallServerTool
调用后端工具:
ts
// 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工具在响应中返回
reviewTool
,资源组件在用户接受/拒绝时调用
useCallServerTool
。工具同时返回
content
(供宿主模型读取的人类可读文本)和
structuredContent
(包含供UI使用的
status
message
)。资源组件读取
structuredContent.status
来确定成功/错误样式,并显示
structuredContent.message
。单个
review
工具通过
action
字段处理所有审核场景(购买、差异对比、发布等)。模拟器为
callServerTool
调用返回模拟数据,与真实宿主行为一致。请查看模板中的
review
资源获取完整实现。

Simulation Files

模拟文件

Simulations are JSON fixtures that power the dev simulator. Place them in
tests/simulations/
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:
  • tool
    — String referencing a tool filename in
    src/tools/
    (without
    .ts
    )
  • userMessage
    — Decorative text shown in simulator (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):
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:
review-diff.json
,
review-post.json
sharing the same resource via the same tool's
resource
field.
模拟文件是为开发模拟器提供数据的JSON固定文件。将它们放在
tests/simulations/
目录下作为平级JSON文件:
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
    渲染的数据
  • toolResult.content[]
    — 供非UI宿主使用的文本回退内容
  • serverTools
    — 为
    callServerTool
    调用提供的模拟响应。键为工具名称,值可以是单个
    CallToolResult
    (始终返回)或
    { when, result }
    条目数组,用于根据调用参数进行条件匹配。
包含
serverTools
的示例(适用于调用仅后端工具的资源):
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." } } }
    ]
  }
}
单个工具支持多个模拟文件:
review-diff.json
review-post.json
通过同一工具的
resource
字段共享同一个资源。

Core Hooks Reference

核心钩子参考

All hooks are imported from
sunpeak
:
HookReturnsDescription
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
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
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)
useAppState(initial)
[state, setState]
React state that auto-syncs to host model context via
updateModelContext()
所有钩子从
sunpeak
导入:
钩子返回值描述
useToolData<TIn, TOut>()
{ input, inputPartial, output, isLoading, isError, isCancelled }
来自宿主的响应式工具数据
useHostContext()
McpUiHostContext | null
宿主上下文(主题、区域设置、功能等)
useTheme()
'light' | 'dark' | undefined
当前主题
useDisplayMode()
'inline' | 'pip' | 'fullscreen'
当前显示模式(默认为
'inline'
useLocale()
string
宿主区域设置(例如
'en-US'
,默认为
'en-US'
useTimeZone()
string
IANA时区(回退到浏览器时区)
usePlatform()
'web' | 'desktop' | 'mobile' | undefined
宿主报告的平台类型
useDeviceCapabilities()
{ touch?, hover? }
设备输入功能
useUserAgent()
string | undefined
宿主应用标识符
useStyles()
McpUiHostStyles | undefined
宿主样式配置(CSS变量、字体)
useToolInfo()
{ id?, tool } | undefined
创建此应用的工具调用元数据
useSafeArea()
{ top, right, bottom, left }
安全区域内边距(像素)
useViewport()
{ width, height, maxWidth, maxHeight }
容器尺寸(像素)
useIsMobile()
boolean
如果视口为移动尺寸则返回true
useApp()
App | null
原始MCP App实例,用于直接调用SDK
useCallServerTool()
(params) => Promise<result>
返回一个用于调用服务端工具的函数
useSendMessage()
(params) => Promise<void>
返回一个用于向对话发送消息的函数
useOpenLink()
(params) => Promise<void>
返回一个用于通过宿主打开URL的函数
useRequestDisplayMode()
{ requestDisplayMode, availableModes }
请求
'inline'
'pip'
'fullscreen'
模式;先检查
availableModes
useDownloadFile()
(params) => Promise<result>
通过宿主下载文件(跨平台可用)
useReadServerResource()
(params) => Promise<result>
通过URI从MCP服务器读取资源
useListServerResources()
(params?) => Promise<result>
列出MCP服务器上的可用资源
useUpdateModelContext()
(params) => Promise<void>
直接向宿主的模型上下文推送状态
useSendLog()
(params) => Promise<void>
向宿主发送调试日志
useHostInfo()
{ hostVersion, hostCapabilities }
宿主名称、版本和支持的功能
useTeardown(fn)
void
注册一个销毁处理程序
useAppTools(config)
void
注册应用提供给宿主的工具(双向工具调用)
useAppState(initial)
[state, setState]
React状态,通过
updateModelContext()
自动同步到宿主模型上下文

useRequestDisplayMode
details

useRequestDisplayMode
详情

tsx
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

useCallServerTool
详情

tsx
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

useSendMessage
详情

tsx
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

useAppState
详情

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.
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中保留,并在每次更新后通过
updateModelContext()
自动发送到宿主,因此LLM可以在其上下文窗口中看到当前UI状态。
tsx
const [state, setState] = useAppState<{ decision: 'accepted' | 'rejected' | null }>({
  decision: null,
});
// setState触发重新渲染并将状态推送到模型上下文
setState({ decision: 'accepted' });

useToolData
details

useToolData
详情

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
inputPartial
for progressive rendering during LLM generation. Use
output
for the final data.
tsx
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生成过程中,使用
inputPartial
进行渐进式渲染。使用
output
获取最终数据。

useDownloadFile
details

useDownloadFile
详情

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,
    },
  }],
});
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

useReadServerResource
/
useListServerResources
详情

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' });
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

useAppTools
详情

Register tools the app provides to the host for bidirectional tool calling. Requires
tools
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 }],
      }),
    }],
  });
}
注册应用提供给宿主的工具,实现双向工具调用。需要宿主支持
tools
功能。
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
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).
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
bash
pnpm dev      # 启动开发服务器(Vite + MCP服务器,端口3000用于Web / 8000用于MCP)
pnpm build    # 构建资源并将工具编译到dist/目录
pnpm start    # 启动生产环境MCP服务器(真实处理器、鉴权、Zod验证)
pnpm test     # 运行单元测试(vitest)
pnpm test:e2e # 运行Playwright端到端测试
sunpeak dev
命令同时启动Vite开发服务器和MCP服务器。模拟器运行在
http://localhost:3000
。将ChatGPT连接到
http://localhost:8000/mcp
(或使用ngrok进行远程测试)。
使用
sunpeak build && sunpeak start
在本地测试生产环境行为,使用真实处理器而非模拟固定数据。
sunpeak dev
命令支持两个正交标志,用于测试不同组合:
  • --prod-tools
    — 将
    callServerTool
    路由到真实工具处理器,而非模拟数据
  • --prod-resources
    — 从
    dist/
    提供生产环境构建的HTML,而非Vite热更新版本
  • --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 variables
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.)
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
    — MCP流式HTTP端点
  • 收到SIGTERM/SIGINT时优雅关闭(5秒排空时间)
  • 结构化JSON日志(
    --json-logs
    ),用于日志聚合(Datadog、CloudWatch等)

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
.
sunpeak build
dist/
目录生成优化后的包:
dist/
├── weather/
│   ├── weather.html   # 自包含包(JS + CSS内联)
│   └── weather.json   # 包含生成的uri用于缓存破环的ResourceConfig
├── tools/
│   ├── show-weather.js  # 编译后的工具处理器 + Zod schema
│   └── ...
├── server.js          # 编译后的服务端入口(如果存在src/server.ts)
└── ...
sunpeak start
dist/
加载所有内容,并启动生产环境MCP服务器,包含真实工具处理器、Zod输入验证,以及
src/server.ts
中的可选鉴权。

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
  }
}
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
sunpeak/platform/chatgpt
. 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();
}
HookDescription
useUploadFile()
Upload a file to ChatGPT, returns file ID
useGetFileDownloadUrl(fileId)
Deprecated — use
useDownloadFile()
from
sunpeak
instead
useRequestModal(params)
Open a host-native modal dialog
useRequestCheckout(session)
Trigger ChatGPT instant checkout
sunpeak/platform/chatgpt
导入。使用前始终进行功能检测。
tsx
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();
}
钩子描述
useUploadFile()
上传文件到ChatGPT,返回文件ID
useGetFileDownloadUrl(fileId)
已废弃 — 改用
sunpeak
中的
useDownloadFile()
useRequestModal(params)
打开宿主原生模态对话框
useRequestCheckout(session)
触发ChatGPT即时结账

SafeArea Component

SafeArea组件

Always wrap resource content in
<SafeArea>
to respect host insets:
tsx
import { SafeArea } from 'sunpeak';

export function MyResource() {
  return (
    <SafeArea>
      {/* your content */}
    </SafeArea>
  );
}
SafeArea
applies
padding
equal to
useSafeArea()
insets automatically.
始终使用
<SafeArea>
包裹资源内容,以适配宿主的安全区域内边距:
tsx
import { SafeArea } from 'sunpeak';

export function MyResource() {
  return (
    <SafeArea>
      {/* 你的内容 */}
    </SafeArea>
  );
}
SafeArea
会自动应用与
useSafeArea()
返回值相等的
padding

Styling 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 ClassCSS VariableUsage
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"]
.
通过Tailwind任意值使用MCP标准CSS变量,而非硬编码颜色。这些变量会自动适配每个宿主的主题(ChatGPT、Claude):
Tailwind类CSS变量用途
text-[var(--color-text-primary)]
--color-text-primary
主要文本
text-[var(--color-text-secondary)]
--color-text-secondary
次要/弱化文本
bg-[var(--color-background-primary)]
--color-background-primary
卡片/表面背景
bg-[var(--color-background-secondary)]
--color-background-secondary
次要/嵌套表面背景
bg-[var(--color-background-tertiary)]
--color-background-tertiary
三级背景
bg-[var(--color-ring-primary)]
--color-ring-primary
主要操作颜色(例如徽章填充)
border-[var(--color-border-tertiary)]
--color-border-tertiary
细微边框
border-[var(--color-border-primary)]
--color-border-primary
默认边框
dark:
变体
通过
[data-theme="dark"]
实现暗黑模式
这些变量使用CSS
light-dark()
,因此会自动响应主题变化。
dark:
Tailwind变体也可通过
[data-theme="dark"]
生效。

E2E Tests with Playwright

使用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.
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:
ParamTypeDescription
simulation
string
Simulation filename without
.json
(e.g.
'show-weather'
)
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
关键注意事项:所有资源内容渲染在
<iframe>
内部。对于资源元素,始终使用
page.frameLocator('iframe')
。只有模拟器的Chrome部分(
header
#root
)直接使用
page.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)
为模拟测试构建URL。完整参数:
参数类型描述
simulation
string
模拟文件名(不含
.json
,例如
'show-weather'
host
'chatgpt' | 'claude'
宿主外壳(默认:
'chatgpt'
theme
'light' | 'dark'
颜色主题(默认:
'dark'
displayMode
'inline' | 'pip' | 'fullscreen'
显示模式(默认:
'inline'
locale
string
区域设置字符串,例如
'en-US'
deviceType
'mobile' | 'tablet' | 'desktop'
设备类型预设
touch
boolean
启用触摸功能
hover
boolean
启用悬停功能
safeAreaTop/Bottom/Left/Right
number
安全区域内边距(像素)

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

常见错误

  1. Hooks before early returns — All hooks must run unconditionally. Move
    useMemo
    /
    useEffect
    above any
    if (...) return
    blocks.
  2. Missing
    <SafeArea>
    — Always wrap content in
    <SafeArea>
    to respect host safe area insets.
  3. Wrong Playwright locator — Use
    page.frameLocator('iframe').locator(...)
    for resource content, never
    page.locator(...)
    .
  4. Hardcoded colors — Use MCP standard CSS variables via Tailwind arbitrary values (
    text-[var(--color-text-primary)]
    ,
    bg-[var(--color-background-primary)]
    ) not raw colors.
  5. 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
    ).
  6. Mutating hook params — Use
    eslint-disable-next-line react-hooks/immutability
    for
    app.onteardown = ...
    (class setter, not a mutation).
  7. Forgetting text fallback — Include
    toolResult.content[]
    in simulations for non-UI hosts.
  1. 钩子在提前return之后调用 — 所有钩子必须无条件执行。将
    useMemo
    /
    useEffect
    移到任何
    if (...) return
    代码块之前。
  2. 缺少
    <SafeArea>
    — 始终使用
    <SafeArea>
    包裹内容,以适配宿主的安全区域内边距。
  3. 错误的Playwright定位器 — 对于资源内容,使用
    page.frameLocator('iframe').locator(...)
    ,而非
    page.locator(...)
  4. 硬编码颜色 — 通过Tailwind任意值使用MCP标准CSS变量(
    text-[var(--color-text-primary)]
    bg-[var(--color-background-primary)]
    ),而非硬编码颜色。
  5. 模拟文件工具不匹配 — 模拟JSON中的
    "tool"
    字段必须与
    src/tools/
    中的工具文件名匹配(例如
    "tool": "show-weather"
    对应
    src/tools/show-weather.ts
    )。
  6. 修改钩子参数 — 对于
    app.onteardown = ...
    (类属性设置器,而非修改),使用
    eslint-disable-next-line react-hooks/immutability
  7. 忘记文本回退内容 — 在模拟文件中包含
    toolResult.content[]
    ,以供非UI宿主使用。

References

参考资料