npx skills add ...
npx skills add mcp-use/skills --skill chatgpt-app-builder
npx skills add mcp-use/skills --skill chatgpt-app-builder
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.
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.
Always bootstrap with the MCP Apps template:
This creates a project structure:
Traditional OpenAI Apps SDK requires significant manual setup:
mcp-use simplifies everything:
resources/ folder - auto-registereduseWidget() hook with state, props, tool callsmcp-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 |
MCP Apps is the official standard (SEP-1865) for interactive widgets in the Model Context Protocol:
type: "mcpApps", mcp-use automatically generates metadata for BOTH protocolsKey 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.
Create resources/weather-display.tsx:
That's it! The widget is automatically:
weather-displayui://widget/weather-display.htmlFor widgets with multiple components:
Entry point (widget.tsx):
Required metadata for automatic registration:
Important:
description: Used for tool and resource descriptionsprops: Zod schema defines widget input parametersexposeAsTool: Set to false if only using widget via custom toolsmetadata: Unified configuration that works for both protocols (recommended)Control what external resources your widget can access using CSP configuration:
CSP Field Reference:
connectDomains: APIs to call via fetch, WebSocket, XMLHttpRequestresourceDomains: Load images, fonts, stylesheets, videosframeDomains: Embed external content in iframesscriptDirectives: Script-src CSP directives (avoid 'unsafe-eval' in production)Security Best Practices:
https://api.weather.comhttps://*.weather.com (less secure)'unsafe-eval' unless absolutely necessaryUse the metadata field for dual-protocol support:
The old ChatGPT-only format (still supported but not recommended):
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.
You can combine both fields to use standard metadata plus ChatGPT-specific overrides:
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
The useWidget hook provides everything you need:
Critical: Widgets render BEFORE tool execution completes. Always handle isPending:
Persist data across widget interactions:
Widgets can call other tools:
Request different display modes:
Create tools that return widgets with dual-protocol support:
Key Points:
baseUrl in server config enables proper asset loadingwidget: { name, invoking, invoked } on tool definitionwidget({ props, output }) helper returns runtime dataprops passed to widget, output shown to modelresources/ folderUse the public/ folder for images, fonts, etc:
Using assets in widgets:
Unified provider combining all common setup:
Handles both data URLs and public paths:
Graceful error handling:
Start development server:
Open Inspector:
http://localhost:3000/inspectorTest widgets:
Debug interactions:
Enable Developer Mode:
Add your server:
Test in conversation:
Prompting tips:
Dual-Protocol Note: When using type: "mcpApps" in your server configuration, your widgets automatically work in both ChatGPT (via Apps SDK) and MCP Apps clients (like Claude Desktop, Goose). You can test the same widget in multiple clients without any code changes!
Use descriptive schemas:
Always support both themes:
Always check isPending first:
Keep widgets focused:
Handle errors gracefully:
Set base URL for production:
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 PolicyBuild process:
Problem: Widget file exists but tool doesn't appear
Solutions:
.tsx extensionwidgetMetadata objectProblem: Component receives empty props
Solutions:
isPending first (props empty while pending)useWidget() hook (not React props)widgetMetadata.props is valid Zod schemaProblem: Widget loads but assets fail
Solutions:
baseUrl in server configmetadata.csp (modern) or appsSdkMetadata['openai/widgetCSP'] (legacy)Problem: Resources blocked by Content Security Policy in production
Solutions:
CSP_URLS environment variable with comma-separated domainsProblem: Widget works in ChatGPT but not MCP Apps clients (or vice versa)
Solutions:
type: "mcpApps" for dual-protocol support (recommended)baseUrl is set correctly in server configmetadata (camelCase) not appsSdkMetadata (snake_case) for dual-protocolWhen to use each type:
type: "mcpApps" - Maximum compatibility (recommended)type: "appsSdk" - ChatGPT only (use if you need ChatGPT-specific features not in spec)Commands:
npx create-mcp-use-app my-app --template mcp-apps - Bootstrapyarn dev - Development with hot reloadyarn build - Build for productionyarn start - Run production serveryarn deploy - Deploy to mcp-use CloudWidget structure:
resources/widget-name.tsx - Single file widgetresources/widget-name/widget.tsx - Folder-based widget entrypublic/ - Static assetsWidget metadata:
description - Widget descriptionprops - Zod schema for inputexposeAsTool - Auto-register as tool (default: true)metadata - Unified config (dual-protocol, recommended)metadata.csp - Content Security Policy configurationappsSdkMetadata - ChatGPT-specific overrides (optional)CSP fields:
connectDomains - APIs to callresourceDomains - Static assets to loadframeDomains - Iframes to embedscriptDirectives - Script policiesuseWidget hook:
props - Widget input parametersisPending - Loading state flagstate, setState - Persistent statecallTool - Call other toolstheme - Current theme (light/dark)displayMode, requestDisplayMode - Display controlimport { 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;resources/
└── product-search/
├── widget.tsx # Entry point (required name)
├── components/
│ ├── ProductCard.tsx
│ └── FilterBar.tsx
├── hooks/
│ └── useFilter.ts
├── types.ts
└── constants.tsimport { 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;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",
},
};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'"],
},
},
};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",
},
};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",
},
};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",
},
};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>();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>
);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,
}));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);
}
};const { displayMode, requestDisplayMode } = useWidget();
const goFullscreen = async () => {
await requestDisplayMode("fullscreen");
};
// Current mode: 'inline' | 'pip' | 'fullscreen'
console.log(displayMode);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();my-app/
├── resources/
├── public/ # Static assets
│ ├── images/
│ │ ├── logo.svg
│ │ └── banner.png
│ └── fonts/
└── index.tsimport { 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>
);
}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>
);
}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>
);
}import { ErrorBoundary } from "mcp-use/react";
function MyWidget() {
return (
<ErrorBoundary
fallback={<div>Something went wrong</div>}
onError={(error) => console.error(error)}
>
<MyComponent />
</ErrorBoundary>
);
}