Loading...
Loading...
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.
npx skill4agent add mcp-use/skills chatgpt-app-buildernpx create-mcp-use-app my-chatgpt-app --template mcp-apps
cd my-chatgpt-app
yarn install
yarn devmy-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.mdresources/useWidget()| Protocol | Use Case | Compatibility | Status |
|---|---|---|---|
MCP Apps ( | Maximum compatibility | ✅ ChatGPT + MCP Apps clients | Recommended |
ChatGPT Apps SDK ( | ChatGPT-only features | ✅ ChatGPT only | Supported |
| MCP-UI | Simple, static content | ✅ MCP clients only | Specialized |
type: "mcpApps"type: "mcpApps"resources/weather-display.tsximport { 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;weather-displayui://widget/weather-display.htmlresources/
└── product-search/
├── widget.tsx # Entry point (required name)
├── components/
│ ├── ProductCard.tsx
│ └── FilterBar.tsx
├── hooks/
│ └── useFilter.ts
├── types.ts
└── constants.tswidget.tsximport { 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",
},
};descriptionpropsexposeAsToolfalsemetadataexport 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'"],
},
},
};connectDomainsresourceDomainsframeDomainsscriptDirectives'unsafe-eval'https://api.weather.comhttps://*.weather.com'unsafe-eval'metadataexport 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",
},
};appsSdkMetadataopenai/connect_domainsmetadataconnectDomainsexport 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",
},
};appsSdkMetadataopenai/useWidgetconst {
// 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>();isPendingconst { 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();baseUrlwidget: { name, invoking, invoked }widget({ props, output })propsoutputresources/public/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>
);
}yarn devhttp://localhost:3000/inspectortype: "mcpApps"// ✅ 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(),
});const { theme } = useWidget();
const bgColor = theme === "dark" ? "bg-gray-900" : "bg-white";
const textColor = theme === "dark" ? "text-white" : "text-gray-900";isPendingconst { props, isPending } = useWidget<MyProps>();
if (isPending) {
return <LoadingSpinner />;
}
// Now safe to access props.field
return <div>{props.field}</div>;// ✅ 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 */
}),
};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);
}
};const server = new MCPServer({
name: "my-app",
version: "1.0.0",
baseUrl: process.env.MCP_URL || "https://myserver.com",
});# 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.comMCP_URLMCP_SERVER_URLCSP_URLS# Login
npx mcp-use login
# Deploy
yarn deploy# Build
yarn build
# Start
yarn startconst 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>
);
};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>
);
};const ThemedWidget: React.FC = () => {
const { theme } = useWidget();
return (
<div className={theme === "dark" ? "dark-theme" : "light-theme"}>
Content
</div>
);
};.tsxwidgetMetadataisPendinguseWidget()widgetMetadata.propsbaseUrlmetadata.cspappsSdkMetadata['openai/widgetCSP']metadata: {
csp: {
connectDomains: ['https://api.example.com'], // Add missing API domain
resourceDomains: ['https://cdn.example.com'], // Add missing CDN domain
}
}CSP_URLStype: "mcpApps"baseUrlmetadataappsSdkMetadatatype: "mcpApps"type: "appsSdk"npx create-mcp-use-app my-app --template mcp-appsyarn devyarn buildyarn startyarn deployresources/widget-name.tsxresources/widget-name/widget.tsxpublic/descriptionpropsexposeAsToolmetadatametadata.cspappsSdkMetadataconnectDomainsresourceDomainsframeDomainsscriptDirectivespropsisPendingstate, setStatecallToolthemedisplayMode, requestDisplayMode