- name
- chatgpt-app-builder
- description
- Build ChatGPT apps with interactive widgets using mcp-use and OpenAI Apps SDK. Use when creating ChatGPT apps, building MCP servers with widgets, defining React widgets, working with Apps SDK, or when user mentions ChatGPT widgets, mcp-use widgets, or Apps SDK development.
# ChatGPT App Builder
Build production-ready ChatGPT apps with interactive widgets using the mcp-use framework and OpenAI Apps SDK. This skill provides zero-config widget development with automatic registration and built-in React hooks.
## Quick Start
**Always bootstrap with the MCP Apps template:**
```bash
npx create-mcp-use-app my-chatgpt-app --template mcp-apps
cd my-chatgpt-app
yarn install
yarn dev
```
This creates a project structure:
```
my-chatgpt-app/
├── resources/ # React widgets (auto-registered!)
│ ├── display-weather.tsx # Example widget
│ └── product-card.tsx # Another widget
├── public/ # Static assets
│ └── images/
├── index.ts # MCP server entry
├── package.json
├── tsconfig.json
└── README.md
```
## Why mcp-use for ChatGPT Apps?
Traditional OpenAI Apps SDK requires significant manual setup:
- Separate project structure (server/ and web/ folders)
- Manual esbuild/webpack configuration
- Custom useWidgetState hook implementation
- Manual React mounting code
- Manual CSP configuration
- Manual widget registration
**mcp-use simplifies everything:**
- ✅ Single command setup
- ✅ Drop widgets in `resources/` folder - auto-registered
- ✅ Built-in `useWidget()` hook with state, props, tool calls
- ✅ Automatic bundling with hot reload
- ✅ Automatic CSP configuration
- ✅ Built-in Inspector for testing
- ✅ Dual-protocol support (works with ChatGPT AND MCP Apps clients)
## MCP Apps vs ChatGPT Apps SDK
mcp-use supports multiple widget protocols, giving you maximum compatibility:
| Protocol | Use Case | Compatibility | Status |
| ---------------------------------------- | ---------------------- | ----------------------------- | --------------- |
| **MCP Apps** (`type: "mcpApps"`) | Maximum compatibility | ✅ ChatGPT + MCP Apps clients | **Recommended** |
| **ChatGPT Apps SDK** (`type: "appsSdk"`) | ChatGPT-only features | ✅ ChatGPT only | Supported |
| **MCP-UI** | Simple, static content | ✅ MCP clients only | Specialized |
### Why MCP Apps?
MCP Apps is the **official standard** (SEP-1865) for interactive widgets in the Model Context Protocol:
- **Universal**: Works with ChatGPT, Claude Desktop, Goose, and all MCP Apps clients
- **Future-proof**: Based on open specification, ensuring long-term compatibility
- **Secure**: Double-iframe sandbox with granular CSP control
- **Zero config**: With `type: "mcpApps"`, mcp-use automatically generates metadata for BOTH protocols
**Key Point**: When you use `type: "mcpApps"` in your server configuration, your widgets automatically work with both ChatGPT (Apps SDK protocol) and MCP Apps clients. You write the widget once, and mcp-use handles the protocol translation.
## Creating Widgets
### Simple Widget (Single File)
Create `resources/weather-display.tsx`:
```tsx
import { McpUseProvider, useWidget, type WidgetMetadata } from "mcp-use/react";
import { z } from "zod";
// Define widget metadata
export const widgetMetadata: WidgetMetadata = {
description: "Display current weather for a city",
props: z.object({
city: z.string().describe("City name"),
temperature: z.number().describe("Temperature in Celsius"),
conditions: z.string().describe("Weather conditions"),
humidity: z.number().describe("Humidity percentage"),
}),
};
const WeatherDisplay: React.FC = () => {
const { props, isPending } = useWidget();
// Always handle loading state first
if (isPending) {
return (
<McpUseProvider autoSize>
<div className="animate-pulse p-4">Loading weather...</div>
</McpUseProvider>
);
}
return (
<McpUseProvider autoSize>
<div className="weather-card p-4 rounded-lg shadow">
<h2 className="text-2xl font-bold">{props.city}</h2>
<div className="temp text-4xl">{props.temperature}°C</div>
<p className="conditions">{props.conditions}</p>
<p className="humidity">Humidity: {props.humidity}%</p>
</div>
</McpUseProvider>
);
};
export default WeatherDisplay;
```
That's it! The widget is automatically:
- Registered as MCP tool `weather-display`
- Registered as MCP resource `ui://widget/weather-display.html`
- Bundled for Apps SDK compatibility
- Ready to use in ChatGPT
### Complex Widget (Folder Structure)
For widgets with multiple components:
```
resources/
└── product-search/
├── widget.tsx # Entry point (required name)
├── components/
│ ├── ProductCard.tsx
│ └── FilterBar.tsx
├── hooks/
│ └── useFilter.ts
├── types.ts
└── constants.ts
```
**Entry point (`widget.tsx`):**
```tsx
import { McpUseProvider, useWidget, type WidgetMetadata } from "mcp-use/react";
import { z } from "zod";
import { ProductCard } from "./components/ProductCard";
import { FilterBar } from "./components/FilterBar";
export const widgetMetadata: WidgetMetadata = {
description: "Display product search results with filtering",
props: z.object({
products: z.array(
z.object({
id: z.string(),
name: z.string(),
price: z.number(),
image: z.string(),
})
),
query: z.string(),
}),
};
const ProductSearch: React.FC = () => {
const { props, isPending, state, setState } = useWidget();
if (isPending) {
return (
<McpUseProvider autoSize>
<div>Loading...</div>
</McpUseProvider>
);
}
return (
<McpUseProvider autoSize>
<div>
<h1>Search: {props.query}</h1>
<FilterBar onFilter={(filters) => setState({ filters })} />
<div className="grid grid-cols-3 gap-4">
{props.products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
</div>
</McpUseProvider>
);
};
export default ProductSearch;
```
## Widget Metadata
Required metadata for automatic registration:
```typescript
export const widgetMetadata: WidgetMetadata = {
// Required: Human-readable description
description: "Display weather information",
// Required: Zod schema for widget props
props: z.object({
city: z.string().describe("City name"),
temperature: z.number(),
}),
// Optional: Disable automatic tool registration
exposeAsTool: true, // default
// Optional: Unified metadata (works for BOTH ChatGPT and MCP Apps)
metadata: {
csp: {
connectDomains: ["https://api.weather.com"],
resourceDomains: ["https://cdn.weather.com"],
},
prefersBorder: true,
autoResize: true,
widgetDescription: "Interactive weather display",
},
};
```
**Important:**
- `description`: Used for tool and resource descriptions
- `props`: Zod schema defines widget input parameters
- `exposeAsTool`: Set to `false` if only using widget via custom tools
- `metadata`: Unified configuration that works for both protocols (recommended)
## Content Security Policy (CSP)
Control what external resources your widget can access using CSP configuration:
```typescript
export const widgetMetadata: WidgetMetadata = {
description: "Weather widget",
props: z.object({ city: z.string() }),
metadata: {
csp: {
// APIs your widget needs to call
connectDomains: ["https://api.weather.com", "https://weather-backup.com"],
// Static assets (images, fonts, stylesheets)
resourceDomains: ["https://cdn.weather.com"],
// External content to embed in iframes
frameDomains: ["https://embed.weather.com"],
// Script CSP directives (use carefully!)
scriptDirectives: ["'unsafe-inline'"],
},
},
};
```
**CSP Field Reference:**
- **`connectDomains`**: APIs to call via fetch, WebSocket, XMLHttpRequest
- **`resourceDomains`**: Load images, fonts, stylesheets, videos
- **`frameDomains`**: Embed external content in iframes
- **`scriptDirectives`**: Script-src CSP directives (avoid `'unsafe-eval'` in production)
**Security Best Practices:**
- ✅ Specify exact domains: `https://api.weather.com`
- ❌ Avoid wildcards: `https://*.weather.com` (less secure)
- ❌ Never use `'unsafe-eval'` unless absolutely necessary
- ✅ Test CSP in development before deploying
## Metadata Configuration Options
### Modern Unified Approach (Recommended)
Use the `metadata` field for dual-protocol support:
```typescript
export const widgetMetadata: WidgetMetadata = {
description: "Weather widget",
props: propSchema,
metadata: {
// Works for BOTH MCP Apps AND ChatGPT
csp: {
connectDomains: ["https://api.weather.com"],
resourceDomains: ["https://cdn.weather.com"],
},
prefersBorder: true,
autoResize: true,
widgetDescription: "Displays current weather",
},
};
```
### Legacy Apps SDK Approach (Deprecated)
The old ChatGPT-only format (still supported but not recommended):
```typescript
export const widgetMetadata: WidgetMetadata = {
description: "Weather widget",
props: propSchema,
appsSdkMetadata: {
// ChatGPT only - snake_case with openai/ prefix
"openai/widgetCSP": {
connect_domains: ["https://api.weather.com"],
resource_domains: ["https://cdn.weather.com"],
},
"openai/widgetPrefersBorder": true,
"openai/toolInvocation/invoking": "Loading...",
"openai/toolInvocation/invoked": "Loaded",
},
};
```
**Migration Note**: The old format uses `appsSdkMetadata` with `openai/` prefixes and snake_case (e.g., `connect_domains`). The new format uses `metadata` with camelCase (e.g., `connectDomains`) and works for both protocols.
### Using Both for Custom ChatGPT Features
You can combine both fields to use standard metadata plus ChatGPT-specific overrides:
```typescript
export const widgetMetadata: WidgetMetadata = {
description: "Weather widget",
props: propSchema,
// Unified metadata (dual-protocol)
metadata: {
csp: { connectDomains: ["https://api.weather.com"] },
prefersBorder: true,
},
// ChatGPT-specific overrides/additions
appsSdkMetadata: {
"openai/widgetDescription": "ChatGPT-specific description",
"openai/customFeature": "some-value", // Any custom OpenAI metadata
"openai/locale": "en-US",
},
};
```
**Use Case**: When you need to pass custom OpenAI-specific metadata that doesn't exist in the unified format, add it to `appsSdkMetadata`. The fields will be passed directly to ChatGPT with the `openai/` prefix
## useWidget Hook
The `useWidget` hook provides everything you need:
```tsx
const {
// Widget props from tool input
props,
// Loading state (true = tool still executing)
isPending,
// Persistent widget state
state,
setState,
// Theme from host (light/dark)
theme,
// Call other MCP tools
callTool,
// Display mode control
displayMode,
requestDisplayMode,
// Additional tool output
output,
} = useWidget<MyPropsType, MyOutputType>();
```
### Props and Loading States
**Critical:** Widgets render BEFORE tool execution completes. Always handle `isPending`:
```tsx
const { props, isPending } = useWidget<WeatherProps>();
// Pattern 1: Early return
if (isPending) {
return <div>Loading...</div>;
}
// Now props are safe to use
// Pattern 2: Conditional rendering
return <div>{isPending ? <LoadingSpinner /> : <div>{props.city}</div>}</div>;
// Pattern 3: Optional chaining (partial UI)
return (
<div>
<h1>{props.city ?? "Loading..."}</h1>
</div>
);
```
### Widget State
Persist data across widget interactions:
```tsx
const { state, setState } = useWidget();
// Save state (persists in ChatGPT localStorage)
const addFavorite = async (city: string) => {
await setState({
favorites: [...(state?.favorites || []), city],
});
};
// Update with function
View on GitHub