| name | OpenAI Apps MCP |
| description | Build ChatGPT apps using stateless MCP servers on Cloudflare Workers. This skill should be used when developing applications that integrate into the ChatGPT interface through the OpenAI Apps ecosystem. Covers MCP protocol implementation with @modelcontextprotocol/sdk, OpenAI-specific metadata formats, widget serving patterns via Workers Static Assets, and deployment to Cloudflare. Prevents common errors with CORS configuration, resource URI patterns, MIME types, and SSE transport on Workers. Use this skill when building tools that extend ChatGPT functionality, serving interactive widgets in conversations, or creating MCP servers optimized for OpenAI Apps integration. Keywords: openai apps, chatgpt app, mcp server, model context protocol, openai integration, cloudflare workers, hono routing, sse transport, widget serving, ui resources, openai metadata, stateless mcp, chatgpt tools, mcp widgets, ui://widget, text/html+skybridge, window.openai, tool invocation, outputTemplate |
| license | MIT |
| metadata | [object Object] |
| allowed-tools | Read, Write, Edit, Bash, Glob, Grep |
Building OpenAI Apps with Stateless MCP Servers
Status: Production Ready
Last Updated: 2025-11-17
Dependencies: cloudflare-worker-base, hono-routing (optional, helpful for routing patterns)
Latest Versions: @modelcontextprotocol/sdk@1.21.0, hono@4.10.2, wrangler@4.42.2
Overview
This skill provides production-tested patterns for building OpenAI Apps - applications that extend ChatGPT's functionality through the Model Context Protocol (MCP). Focus on stateless MCP servers using Cloudflare Workers, which covers 80% of OpenAI Apps use cases.
What Are OpenAI Apps?
OpenAI Apps are extensions that integrate into the ChatGPT interface, allowing users to:
- Access third-party services directly in conversations
- Display interactive widgets (maps, carousels, lists, etc.)
- Execute tools that return structured UI components
- Enhance ChatGPT with domain-specific capabilities
Architecture
ChatGPT User
↓
ChatGPT (discovers and invokes tools)
↓
MCP Server (your Cloudflare Worker)
├── Tool handlers (business logic)
├── Widget resources (HTML/JS UI)
└── OpenAI metadata (output templates)
Key Components
- MCP Server - HTTP endpoint exposing tools via Model Context Protocol
- Tool Handlers - Functions that process inputs and return results
- Widget Resources - HTML pages that render in ChatGPT's iframe
- OpenAI Metadata - Special annotations for widget routing and display
Quick Start (10 Minutes)
1. Scaffold Project
npm create cloudflare@latest my-openai-app -- --type hello-world --ts --git --deploy false
cd my-openai-app
# Install dependencies
npm install @modelcontextprotocol/sdk@1.21.0 hono@4.10.2 zod@3.25.76
npm install -D @cloudflare/vite-plugin@1.13.13 vite@7.1.9
Why this matters:
@modelcontextprotocol/sdkis the official MCP protocol implementationhonoprovides lightweight routing perfect for API endpoints- Vite + CloudFlare plugin enable building and serving widgets
2. Configure wrangler.jsonc
{
"name": "my-openai-app",
"main": "dist/index.js",
"compatibility_date": "2025-10-08",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "dist/client",
"binding": "ASSETS"
},
"observability": {
"enabled": true
}
}
CRITICAL:
nodejs_compatflag is required for MCP SDKassets.binding: "ASSETS"must match TypeScript binding nameassets.directorymust match Vite build output
3. Create MCP Server
// src/index.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
type Bindings = {
ASSETS: Fetcher;
};
const app = new Hono<{ Bindings: Bindings }>();
// CORS - must allow ChatGPT
app.use('/mcp/*', cors({
origin: 'https://chatgpt.com',
credentials: true,
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization']
}));
// Create MCP server
const mcpServer = new Server(
{ name: 'my-openai-app', version: '1.0.0' },
{ capabilities: { tools: {}, resources: {} } }
);
// Register a simple tool
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: 'hello_world',
description: 'Use this when the user wants to see a hello world message',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Name to greet' }
},
required: ['name']
},
annotations: {
openai: {
outputTemplate: 'ui://widget/hello.html'
}
}
}]
}));
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'hello_world') {
const { name } = request.params.arguments as { name: string };
return {
content: [{ type: 'text', text: `Hello, ${name}!` }],
_meta: { initialData: { name } }
};
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
// MCP endpoint
app.post('/mcp', async (c) => {
const body = await c.req.json();
const response = await mcpServer.handleRequest(body);
return c.json(response);
});
// Serve widgets
app.get('/widgets/*', async (c) => c.env.ASSETS.fetch(c.req.raw));
export default app;
The 5-Step Setup Process
Step 1: Project Scaffolding
Use Cloudflare's official scaffolding:
npm create cloudflare@latest my-openai-app -- --type hello-world --ts --git --deploy false
Key Points:
- Creates Workers project with TypeScript
- Includes wrangler.jsonc
- Initializes git repository
Step 2: Install Dependencies
npm install @modelcontextprotocol/sdk@1.21.0 hono@4.10.2 zod@3.25.76
npm install -D @cloudflare/vite-plugin@1.13.13 vite@7.1.9
What each package does:
@modelcontextprotocol/sdk- Official MCP protocol (Anthropic)hono- Fast, lightweight routing frameworkzod- Runtime type validation for tool inputs@cloudflare/vite-plugin- Build tool for Workers + static assetsvite- Frontend build tool
Step 3: Configure Build System
Create vite.config.ts:
import { defineConfig } from 'vite';
import { cloudflareDevProxyVitePlugin as cloudflare } from '@cloudflare/vite-plugin';
export default defineConfig({
plugins: [
cloudflare({
configPath: 'wrangler.jsonc',
persist: { path: '.wrangler/state' }
})
],
build: {
outDir: 'dist',
rollupOptions: {
input: {
worker: './src/index.ts'
},
output: {
entryFileNames: (chunkInfo) => {
if (chunkInfo.name === 'worker') return 'index.js';
return 'client/[name]-[hash].js';
}
}
}
}
});
Why this matters:
- Builds both worker code and static assets
- Proper output structure for Workers + ASSETS binding
- Content hashing for cache busting
Step 4: Create Widget HTML
mkdir -p src/widgets
Create src/widgets/hello.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello Widget</title>
<style>
body {
margin: 0;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--background);
color: var(--foreground);
}
.greeting {
font-size: 24px;
font-weight: 600;
}
</style>
</head>
<body>
<div class="greeting" id="greeting">Loading...</div>
<script>
// Access initial data from tool handler
if (window.openai && window.openai.getInitialData) {
const data = window.openai.getInitialData();
document.getElementById('greeting').textContent = `Hello, ${data.name}! 👋`;
}
</script>
</body>
</html>
What to avoid:
- Don't use third-party CDN scripts (CSP may block)
- Don't use custom fonts (use system fonts)
- Don't make external API calls without CORS
Step 5: Deploy and Test
# Build
npm run build
# Deploy to Cloudflare
npx wrangler deploy
# Test with MCP Inspector
npx @modelcontextprotocol/inspector https://my-openai-app.workers.dev/mcp
Critical Rules
Always Do
✅ Set CORS to allow https://chatgpt.com
✅ Use resource URI pattern ui://widget/ for widgets
✅ Set MIME type to text/html+skybridge for HTML resources
✅ Include _meta.initialData in tool responses for widget initialization
✅ Use action-oriented tool descriptions ("Use this when...")
✅ Validate tool inputs with Zod schemas
✅ Test with MCP Inspector before deploying to ChatGPT
Never Do
❌ Use custom MIME types (must be text/html+skybridge)
❌ Forget CORS configuration (ChatGPT won't connect)
❌ Use resource URIs without ui://widget/ prefix
❌ Bundle widgets in worker code (use ASSETS binding)
❌ Skip input validation (tools receive untrusted data)
❌ Use SSE without heartbeat (100s timeout on Workers)
❌ Deploy without testing in MCP Inspector first
Known Issues Prevention
This skill prevents 8 documented issues:
Issue #1: CORS Policy Blocks MCP Endpoint
Error: Access to fetch at 'https://my-app.workers.dev/mcp' from origin 'https://chatgpt.com' has been blocked by CORS policy
Source: Common browser console error when CORS not configured
Why It Happens: ChatGPT makes cross-origin requests to your MCP server. Without CORS headers, browsers block these requests.
Prevention:
app.use('/mcp/*', cors({
origin: 'https://chatgpt.com', // Must allow ChatGPT specifically
credentials: true,
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization']
}));
Issue #2: Widget Returns 404 Not Found
Error: Failed to load resource: the server responded with a status of 404 (Not Found) for widget URL
Source: OpenAI Apps SDK requirement for ui://widget/ prefix
Why It Happens: Resource URI must follow specific pattern. Wrong prefix causes ChatGPT to fail widget resolution.
Prevention:
// ✅ Correct
annotations: {
openai: {
outputTemplate: 'ui://widget/map.html'
}
}
// ❌ Wrong - will 404
outputTemplate: 'resource://map.html'
outputTemplate: '/widgets/map.html'
Issue #3: Widget Displays as Plain Text
Error: HTML source code visible instead of rendered widget
Source: OpenAI Apps SDK MIME type requirement
Why It Happens: ChatGPT expects specific MIME type text/html+skybridge to render widgets. Standard text/html won't work.
Prevention:
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [{
uri: 'ui://widget/map.html',
mimeType: 'text/html+skybridge' // Must use +skybridge
}]
}));
Issue #4: ASSETS Binding Undefined
Error: TypeError: Cannot read property 'fetch' of undefined when accessing c.env.ASSETS
Source: Misconfigured wrangler.jsonc or TypeScript types
Why It Happens: ASSETS binding name in wrangler.jsonc must match TypeScript binding interface.
Prevention:
// wrangler.jsonc
{
"assets": {
"directory": "dist/client",
"binding": "ASSETS" // Must match TypeScript
}
}
// src/index.ts
type Bindings = {
ASSETS: Fetcher; // Must match wrangler.jsonc binding name
};
Issue #5: SSE Connection Drops After 100 Seconds
Error: SSE stream closes unexpectedly, tools stop responding
Source: Cloudflare Workers SSE timeout when no data sent
Why It Happens: Workers close SSE connections after 100 seconds of inactivity. Must send heartbeat events.
Prevention:
import { streamSSE } from 'hono/streaming';
app.get('/mcp/stream', (c) => {
return streamSSE(c, async (stream) => {
// Send heartbeat every 30 seconds
const heartbeat = setInterval(async () => {
await stream.writeSSE({
data: JSON.stringify({ type: 'heartbeat' }),
event: 'ping'
});
}, 30000);
// Clean up on close
stream.onAbort(() => clearInterval(heartbeat));
});
});
Issue #6: ChatGPT Doesn't Suggest Tool
Error: Tool registered but never appears in ChatGPT suggestions
Source: OpenAI tool discovery requires action-oriented descriptions
Why It Happens: Generic descriptions don't match user intent patterns. ChatGPT uses descriptions to decide when to invoke tools.
Prevention:
// ✅ Good - action-oriented
description: 'Use this when the user wants to see a location on a map'
// ❌ Bad - generic
description: 'Shows a map'
// ✅ Good - specific use case
description: 'Use this when the user asks about weather in a specific city'
// ❌ Bad - vague
description: 'Gets weather data'
Issue #7: Widget Can't Access Initial Data
Error: window.openai.getInitialData() returns undefined in widget
Source: Missing _meta.initialData in tool response
Why It Happens: Data must be explicitly passed via _meta field. Regular response content doesn't reach widgets.
Prevention:
// Tool handler
return {
content: [{
type: 'text',
text: 'Here is your map'
}],
_meta: {
initialData: { // This object reaches window.openai.getInitialData()
location: 'San Francisco',
zoom: 12
}
}
};
Issue #8: Widget Scripts Blocked by CSP
Error: Refused to load the script because it violates the following Content Security Policy directive
Source: ChatGPT's Content Security Policy for widget iframes
Why It Happens: External scripts from third-party CDNs are blocked for security.
Prevention:
<!-- ✅ Inline scripts work -->
<script>
console.log('Widget loaded');
</script>
<!-- ✅ Same-origin scripts work -->
<script src="/widgets/lib.js"></script>
<!-- ❌ Third-party CDNs may be blocked -->
<script src="https://cdn.example.com/library.js"></script>
<!-- Workaround: Bundle libraries with Vite -->
Configuration Files Reference
wrangler.jsonc (Full Example)
{
"name": "my-openai-app",
"main": "dist/index.js",
"compatibility_date": "2025-10-08",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "dist/client",
"binding": "ASSETS"
},
"observability": {
"enabled": true
},
"vars": {
"ENVIRONMENT": "production"
}
}
Why these settings:
nodejs_compat- Required for MCP SDK (uses Node.js APIs)assets.directory- Where Vite outputs widget HTML filesassets.binding: "ASSETS"- How to access static assets in codeobservability- Enable logging and tracing for debugging
vite.config.ts (Full Example)
import { defineConfig } from 'vite';
import { cloudflareDevProxyVitePlugin as cloudflare } from '@cloudflare/vite-plugin';
export default defineConfig({
plugins: [
cloudflare({
configPath: 'wrangler.jsonc',
persist: { path: '.wrangler/state' }
})
],
build: {
outDir: 'dist',
rollupOptions: {
input: {
worker: './src/index.ts',
// Widget entry points
hello: './src/widgets/hello.html',
map: './src/widgets/map.html'
},
output: {
entryFileNames: (chunkInfo) => {
if (chunkInfo.name === 'worker') return 'index.js';
return 'client/[name]-[hash].js';
},
assetFileNames: 'client/[name]-[hash][extname]'
}
}
}
});
Why these settings:
- Multiple input entry points (worker + widgets)
- Separate output for worker (
index.js) vs client assets (client/) - Content hashing for cache busting (
[hash])
Common Patterns
Pattern 1: Simple Text-Only Tool
Tool that returns text without widgets:
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: 'get_weather',
description: 'Use this when the user asks about current weather in a city',
inputSchema: {
type: 'object',
properties: {
city: { type: 'string', description: 'City name' }
},
required: ['city']
}
// No annotations - returns plain text
}]
}));
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'get_weather') {
const { city } = request.params.arguments as { city: string };
// In production, call weather API
const weather = await fetchWeather(city);
return {
content: [{
type: 'text',
text: `Weather in ${city}: ${weather.description}, ${weather.temp}°F`
}]
};
}
});
When to use: Simple data retrieval that doesn't need visual representation
Pattern 2: List Widget
Tool that displays a list of items:
// Tool registration
{
name: 'search_products',
description: 'Use this when the user wants to search for products',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' }
}
},
annotations: {
openai: {
outputTemplate: 'ui://widget/product-list.html'
}
}
}
// Tool handler
const products = await searchProducts(query);
return {
content: [{
type: 'text',
text: `Found ${products.length} products`
}],
_meta: {
initialData: {
products: products.map(p => ({
id: p.id,
name: p.name,
price: p.price,
image: p.image
}))
}
}
};
<!-- product-list.html -->
<div id="products"></div>
<script>
const data = window.openai.getInitialData();
const container = document.getElementById('products');
data.products.forEach(product => {
const item = document.createElement('div');
item.className = 'product-item';
item.innerHTML = `
<img src="${product.image}" alt="${product.name}">
<h3>${product.name}</h3>
<p>$${product.price}</p>
`;
container.appendChild(item);
});
</script>
When to use: Displaying collections of items (search results, recommendations, etc.)
Pattern 3: Interactive Widget with Tool Callbacks
Widget that can trigger other tools:
// Register multiple related tools
{
name: 'show_tasks',
description: 'Use this when user wants to see their task list',
annotations: {
openai: {
outputTemplate: 'ui://widget/tasks.html'
}
}
},
{
name: 'complete_task',
description: 'Mark a task as completed',
inputSchema: {
type: 'object',
properties: {
taskId: { type: 'number' }
}
}
}
<!-- tasks.html -->
<div id="tasks"></div>
<script>
const data = window.openai.getInitialData();
data.tasks.forEach(task => {
const taskEl = document.createElement('div');
taskEl.innerHTML = `
<span>${task.title}</span>
<button onclick="completeTask(${task.id})">Complete</button>
`;
document.getElementById('tasks').appendChild(taskEl);
});
function completeTask(taskId) {
if (window.openai && window.openai.callTool) {
window.openai.callTool({
name: 'complete_task',
arguments: { taskId }
});
}
}
</script>
When to use: Interactive widgets that need to trigger follow-up actions
Using Bundled Resources
Scripts (scripts/)
Run ./scripts/scaffold-openai-app.sh to generate a complete starter project:
chmod +x ./scripts/scaffold-openai-app.sh
./scripts/scaffold-openai-app.sh my-new-app
cd my-new-app
npm install
npm run dev
References (references/)
references/mcp-protocol-basics.md- MCP SDK usage patternsreferences/openai-metadata-format.md- All OpenAI-specific annotation fieldsreferences/widget-patterns.md- Widget HTML examples and best practicesreferences/optional-agents-upgrade.md- When and how to add stateful features
When Claude should load these: When you need detailed API references or advanced patterns beyond this SKILL.md
Templates (templates/)
templates/basic/- Minimal stateless MCP servertemplates/with-react/- React-based widgets with Vite build
Usage: Copy entire template directory as starting point for new projects
Advanced Topics
Using Zod for Input Validation
import { z } from 'zod';
const CreateTaskInputSchema = z.object({
title: z.string().min(1).max(200),
dueDate: z.string().datetime().optional(),
priority: z.enum(['low', 'medium', 'high']).default('medium')
});
// Convert Zod schema to JSON Schema for MCP
const jsonSchema = zodToJsonSchema(CreateTaskInputSchema);
// Use in tool registration
{
name: 'create_task',
inputSchema: jsonSchema,
// ...
}
// Validate in handler
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'create_task') {
try {
const validated = CreateTaskInputSchema.parse(request.params.arguments);
// validated is typed and validated
} catch (error) {
if (error instanceof z.ZodError) {
return {
content: [{
type: 'text',
text: `Invalid input: ${error.errors.map(e => e.message).join(', ')}`
}],
isError: true
};
}
}
}
});
Environment Variables and Secrets
# Set secrets via Wrangler
wrangler secret put API_KEY
# Access in code
type Bindings = {
ASSETS: Fetcher;
API_KEY: string; // Secret binding
};
// In handler
const apiKey = c.env.API_KEY;
React Widgets with Vite
// vite.config.ts - add React plugin
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react(),
cloudflare({ /* ... */ })
],
// ...
});
// src/widgets/TaskList.tsx
import { useState, useEffect } from 'react';
export function TaskList() {
const [tasks, setTasks] = useState([]);
useEffect(() => {
if (window.openai) {
const data = window.openai.getInitialData();
setTasks(data.tasks);
}
}, []);
return (
<div>
{tasks.map(task => (
<div key={task.id}>{task.title}</div>
))}
</div>
);
}
Dependencies
Required:
@modelcontextprotocol/sdk@1.21.0- Official MCP protocol implementationhono@4.10.2- Lightweight routing frameworkzod@3.25.76- Runtime type validation
Optional:
@cloudflare/vite-plugin@1.13.13- Build tool for Workers + static assets (recommended)@vitejs/plugin-react@4.3.4- React support for widgetsagents@0.2.23- Cloudflare Agents SDK (only if you need stateful agents)
Official Documentation
- Model Context Protocol: https://modelcontextprotocol.io/
- MCP SDK (GitHub): https://github.com/modelcontextprotocol/typescript-sdk
- OpenAI Apps SDK: https://developers.openai.com/apps-sdk
- Cloudflare Workers: https://developers.cloudflare.com/workers/
- Hono Framework: https://hono.dev/
- Context7 Library ID: /modelcontextprotocol/typescript-sdk
Package Versions (Verified 2025-11-17)
{
"dependencies": {
"@modelcontextprotocol/sdk": "^1.21.0",
"hono": "^4.10.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.13.13",
"@cloudflare/workers-types": "^4.20250531.0",
"@vitejs/plugin-react": "^4.3.4",
"vite": "^7.1.9",
"wrangler": "^4.42.2"
}
}
Production Example
This skill is based on patterns from:
- Toolbase-AI OpenAI Apps Template: https://github.com/Toolbase-AI/openai-apps-sdk-cloudflare-vite-template
- Build Time: ~45 minutes from zero to deployed
- Errors: 0 (all 8 known issues prevented with this skill)
- Validation: ✅ MCP Inspector tests passing, widgets rendering correctly in ChatGPT
Troubleshooting
Problem: MCP Inspector shows "Connection refused"
Solution: Ensure dev server is running on correct port and /mcp endpoint is configured:
npm run dev # Should show port (usually 8787)
npx @modelcontextprotocol/inspector http://localhost:8787/mcp
Problem: Widget shows blank page in ChatGPT
Solution: Check browser console for errors. Common causes:
- CSP blocking external scripts - bundle them with Vite
- Missing
window.openaicheck - widget loads before API ready - CORS headers missing - add to
/mcp/*route
Problem: Tool registered but never invoked
Solution: Improve tool description to be more action-oriented:
// ✅ Good
description: 'Use this when the user wants to see weather forecast for a specific city'
// ❌ Bad
description: 'Weather tool'
Complete Setup Checklist
- Project scaffolded with
npm create cloudflare - Dependencies installed (
@modelcontextprotocol/sdk,hono,zod) -
wrangler.jsoncconfigured with ASSETS binding -
vite.config.tsconfigured with CloudFlare plugin - CORS middleware added to
/mcp/*routes - At least one tool registered with MCP server
- Tool has action-oriented description
- Widget HTML created in
src/widgets/ - Resource registered with
ui://widget/URI pattern - MIME type set to
text/html+skybridge - Tool handler returns
_meta.initialDatafor widgets - Dev server runs without errors (
npm run dev) - MCP Inspector connects successfully
- Production build succeeds (
npm run build) - Deployed to Cloudflare Workers (
npx wrangler deploy) - Tested in ChatGPT developer mode
Questions? Issues?
- Check
references/openai-metadata-format.mdfor all annotation fields - Verify all steps in the setup process
- Check official MCP docs: https://modelcontextprotocol.io/
- Ensure CORS is configured to allow
https://chatgpt.com - Test with MCP Inspector before trying in ChatGPT