OpenAI Apps MCP
Build ChatGPT apps with MCP servers on Cloudflare Workers. Extend ChatGPT with custom tools and interactive widgets (HTML/JS UI). Use when: developing ChatGPT extensions, implementing MCP servers, or troubleshooting CORS blocking (allow chatgpt.com), widget 404s (missing ui://widget/), wrong MIME type (text/html+skybridge), or ASSETS binding undefined.
$ Installieren
git clone https://github.com/ovachiever/droid-tings /tmp/droid-tings && cp -r /tmp/droid-tings/skills/openai-apps-mcp ~/.claude/skills/droid-tings// tip: Run this command in your terminal to install the skill
name: OpenAI Apps MCP description: | Build ChatGPT apps with MCP servers on Cloudflare Workers. Extend ChatGPT with custom tools and interactive widgets (HTML/JS UI).
Use when: developing ChatGPT extensions, implementing MCP servers, or troubleshooting CORS blocking (allow chatgpt.com), widget 404s (missing ui://widget/), wrong MIME type (text/html+skybridge), or ASSETS binding undefined. license: MIT metadata: version: 1.0.0 author: Jeremy Dawes | Jezweb last_updated: 2025-11-17 production_tested: true token_savings: "~70%" errors_prevented: 8 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
Repository
