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 Apps SDK template:
npx create-mcp-use-app my-chatgpt-app --template apps-sdk
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
Creating Widgets
Simple Widget (Single File)
Create resources/weather-display.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):
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:
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: Apps SDK metadata
appsSdkMetadata: {
'openai/widgetDescription': 'Interactive weather display',
'openai/toolInvocation/invoking': 'Loading weather...',
'openai/toolInvocation/invoked': 'Weather loaded',
'openai/widgetCSP': {
connect_domains: ['https://api.weather.com'],
resource_domains: ['https://cdn.weather.com'],
},
},
};
Important:
description: Used for tool and resource descriptionsprops: Zod schema defines widget input parametersexposeAsTool: Set tofalseif only using widget via custom tools- Default Apps SDK metadata is auto-generated if not specified
useWidget Hook
The useWidget hook provides everything you need:
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:
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:
const { state, setState } = useWidget();
// Save state (persists in ChatGPT localStorage)
const addFavorite = async (city: string) => {
await setState({
favorites: [...(state?.favorites || []), city]
});
};
// Update with function
await setState(prev => ({
...prev,
count: (prev?.count || 0) + 1
}));
Calling MCP Tools
Widgets can call other tools:
const { callTool } = useWidget();
const refreshData = async () => {
try {
const result = await callTool('get-weather', {
city: 'Tokyo'
});
console.log('Result:', result.content);
} catch (error) {
console.error('Tool call failed:', error);
}
};
Display Mode Control
Request different display modes:
const { displayMode, requestDisplayMode } = useWidget();
const goFullscreen = async () => {
await requestDisplayMode('fullscreen');
};
// Current mode: 'inline' | 'pip' | 'fullscreen'
console.log(displayMode);
Custom Tools with Widgets
Create tools that return widgets:
import { MCPServer, widget, text } from 'mcp-use/server';
import { z } from 'zod';
const server = new MCPServer({
name: 'weather-app',
version: '1.0.0',
});
server.tool({
name: 'get-weather',
description: 'Get current weather for a city',
schema: z.object({
city: z.string().describe('City name')
}),
// Widget config (registration-time metadata)
widget: {
name: 'weather-display', // Must match widget in resources/
invoking: 'Fetching weather...',
invoked: 'Weather data loaded'
}
}, async ({ city }) => {
// Fetch data from API
const data = await fetchWeatherAPI(city);
// Return widget with runtime data
return widget({
props: {
city,
temperature: data.temp,
conditions: data.conditions,
humidity: data.humidity
},
output: text(`Weather in ${city}: ${data.temp}°C`),
message: `Current weather for ${city}`
});
});
server.listen();
Key Points:
widget: { name, invoking, invoked }on tool definitionwidget({ props, output })helper returns runtime datapropspassed to widget,outputshown to model- Widget must exist in
resources/folder
Static Assets
Use the public/ folder for images, fonts, etc:
my-app/
├── resources/
├── public/ # Static assets
│ ├── images/
│ │ ├── logo.svg
│ │ └── banner.png
│ └── fonts/
└── index.ts
Using assets in widgets:
import { Image } from 'mcp-use/react';
function MyWidget() {
return (
<div>
{/* Paths relative to public/ folder */}
<Image src="/images/logo.svg" alt="Logo" />
<img src={window.__getFile?.('images/banner.png')} alt="Banner" />
</div>
);
}
Components
McpUseProvider
Unified provider combining all common setup:
import { McpUseProvider } from 'mcp-use/react';
function MyWidget() {
return (
<McpUseProvider
autoSize // Auto-resize widget
viewControls // Add debug/fullscreen buttons
debug // Show debug info
>
<div>Widget content</div>
</McpUseProvider>
);
}
Image Component
Handles both data URLs and public paths:
import { Image } from 'mcp-use/react';
function MyWidget() {
return (
<div>
<Image src="/images/photo.jpg" alt="Photo" />
<Image src="data:image/png;base64,..." alt="Data URL" />
</div>
);
}
ErrorBoundary
Graceful error handling:
import { ErrorBoundary } from 'mcp-use/react';
function MyWidget() {
return (
<ErrorBoundary
fallback={<div>Something went wrong</div>}
onError={(error) => console.error(error)}
>
<MyComponent />
</ErrorBoundary>
);
}
Testing
Using the Inspector
Start development server:
yarn devOpen Inspector:
- Navigate to
http://localhost:3000/inspector
- Navigate to
Test widgets:
- Click Tools tab
- Find your widget tool
- Enter test parameters
- Execute to see widget render
Debug interactions:
- Use browser console
- Check RPC logs
- Test state persistence
- Verify tool calls
Testing in ChatGPT
Enable Developer Mode:
- Settings → Connectors → Advanced → Developer mode
Add your server:
- Go to Connectors tab
- Add remote MCP server URL
Test in conversation:
- Select Developer Mode from Plus menu
- Choose your connector
- Ask ChatGPT to use your tools
Prompting tips:
- Be explicit: "Use the weather-app connector's get-weather tool..."
- Disallow alternatives: "Do not use built-in tools, only use my connector"
- Specify input: "Call get-weather with { city: 'Tokyo' }"
Best Practices
Schema Design
Use descriptive schemas:
// ✅ Good
const schema = z.object({
city: z.string().describe('City name (e.g., Tokyo, Paris)'),
temperature: z.number().min(-50).max(60).describe('Temp in Celsius'),
});
// ❌ Bad
const schema = z.object({
city: z.string(),
temp: z.number(),
});
Theme Support
Always support both themes:
const { theme } = useWidget();
const bgColor = theme === 'dark' ? 'bg-gray-900' : 'bg-white';
const textColor = theme === 'dark' ? 'text-white' : 'text-gray-900';
Loading States
Always check isPending first:
const { props, isPending } = useWidget<MyProps>();
if (isPending) {
return <LoadingSpinner />;
}
// Now safe to access props.field
return <div>{props.field}</div>;
Widget Focus
Keep widgets focused:
// ✅ Good: Single purpose
export const widgetMetadata: WidgetMetadata = {
description: 'Display weather for a city',
props: z.object({ city: z.string() }),
};
// ❌ Bad: Too many responsibilities
export const widgetMetadata: WidgetMetadata = {
description: 'Weather, forecast, map, news, and more',
props: z.object({ /* many fields */ }),
};
Error Handling
Handle errors gracefully:
const { callTool } = useWidget();
const fetchData = async () => {
try {
const result = await callTool('fetch-data', { id: '123' });
if (result.isError) {
console.error('Tool returned error');
}
} catch (error) {
console.error('Tool call failed:', error);
}
};
Configuration
Production Setup
Set base URL for production:
const server = new MCPServer({
name: 'my-app',
version: '1.0.0',
baseUrl: process.env.MCP_URL || 'https://myserver.com'
});
Environment Variables
# Server URL
MCP_URL=https://myserver.com
# For static deployments
MCP_SERVER_URL=https://myserver.com/api
CSP_URLS=https://cdn.example.com,https://api.example.com
Variable usage:
MCP_URL: Base URL for widget assets and CSPMCP_SERVER_URL: MCP server URL for tool calls (static deployments)CSP_URLS: Additional domains for Content Security Policy
Deployment
Deploy to mcp-use Cloud
# Login
npx mcp-use login
# Deploy
yarn deploy
Build for Production
# Build
yarn build
# Start
yarn start
Build process:
- Compiles TypeScript
- Bundles React widgets
- Optimizes assets
- Generates production HTML
Common Patterns
Data Fetching Widget
const DataWidget: React.FC = () => {
const { props, isPending, callTool } = useWidget();
if (isPending) {
return <div>Loading...</div>;
}
const refresh = async () => {
await callTool('fetch-data', { id: props.id });
};
return (
<div>
<h1>{props.title}</h1>
<button onClick={refresh}>Refresh</button>
</div>
);
};
Stateful Widget
const CounterWidget: React.FC = () => {
const { state, setState } = useWidget();
const increment = async () => {
await setState({
count: (state?.count || 0) + 1
});
};
return (
<div>
<p>Count: {state?.count || 0}</p>
<button onClick={increment}>+1</button>
</div>
);
};
Themed Widget
const ThemedWidget: React.FC = () => {
const { theme } = useWidget();
return (
<div className={theme === 'dark' ? 'dark-theme' : 'light-theme'}>
Content
</div>
);
};
Troubleshooting
Widget Not Appearing
Problem: Widget file exists but tool doesn't appear
Solutions:
- Ensure
.tsxextension - Export
widgetMetadataobject - Export default React component
- Check server logs for errors
- Verify widget name matches file/folder name
Props Not Received
Problem: Component receives empty props
Solutions:
- Check
isPendingfirst (props empty while pending) - Use
useWidget()hook (not React props) - Verify
widgetMetadata.propsis valid Zod schema - Check tool parameters match schema
CSP Errors
Problem: Widget loads but assets fail
Solutions:
- Set
baseUrlin server config - Add domains to CSP via
appsSdkMetadata - Use HTTPS for all resources
- Check browser console for CSP violations
Learn More
- Documentation: https://docs.mcp-use.com
- Widget Guide: https://docs.mcp-use.com/typescript/server/ui-widgets
- Apps SDK Tutorial: https://docs.mcp-use.com/typescript/server/creating-apps-sdk-server
- ChatGPT Apps Flow: https://docs.mcp-use.com/guides/chatgpt-apps-flow
- Inspector Debugging: https://docs.mcp-use.com/inspector/debugging-chatgpt-apps
- GitHub: https://github.com/mcp-use/mcp-use
Quick Reference
Commands:
npx create-mcp-use-app my-app --template apps-sdk- Bootstrapyarn dev- Development with hot reloadyarn build- Build for productionyarn start- Run production serveryarn deploy- Deploy to mcp-use Cloud
Widget structure:
resources/widget-name.tsx- Single file widgetresources/widget-name/widget.tsx- Folder-based widget entrypublic/- Static assets
Widget metadata:
description- Widget descriptionprops- Zod schema for inputexposeAsTool- Auto-register as tool (default: true)appsSdkMetadata- Apps SDK configuration
useWidget hook:
props- Widget input parametersisPending- Loading state flagstate, setState- Persistent statecallTool- Call other toolstheme- Current theme (light/dark)displayMode, requestDisplayMode- Display control