--- 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 1. **MCP Server** - HTTP endpoint exposing tools via Model Context Protocol 2. **Tool Handlers** - Functions that process inputs and return results 3. **Widget Resources** - HTML pages that render in ChatGPT's iframe 4. **OpenAI Metadata** - Special annotations for widget routing and display --- ## Quick Start (10 Minutes) ### 1. Scaffold Project ```bash 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/sdk` is the official MCP protocol implementation - `hono` provides lightweight routing perfect for API endpoints - Vite + CloudFlare plugin enable building and serving widgets ### 2. Configure wrangler.jsonc ```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_compat` flag is required for MCP SDK - `assets.binding: "ASSETS"` must match TypeScript binding name - `assets.directory` must match Vite build output ### 3. Create MCP Server ```typescript // 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: ```bash 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 ```bash 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 framework - `zod` - Runtime type validation for tool inputs - `@cloudflare/vite-plugin` - Build tool for Workers + static assets - `vite` - Frontend build tool ### Step 3: Configure Build System Create `vite.config.ts`: ```typescript 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 ```bash mkdir -p src/widgets ``` Create `src/widgets/hello.html`: ```html Hello Widget
Loading...
``` **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 ```bash # 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**: ```typescript 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**: ```typescript // ✅ 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**: ```typescript 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**: ```jsonc // wrangler.jsonc { "assets": { "directory": "dist/client", "binding": "ASSETS" // Must match TypeScript } } ``` ```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**: ```typescript 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**: ```typescript // ✅ 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**: ```typescript // 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**: ```html ``` --- ## Configuration Files Reference ### wrangler.jsonc (Full Example) ```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 }, "vars": { "ENVIRONMENT": "production" } } ``` **Why these settings:** - `nodejs_compat` - Required for MCP SDK (uses Node.js APIs) - `assets.directory` - Where Vite outputs widget HTML files - `assets.binding: "ASSETS"` - How to access static assets in code - `observability` - Enable logging and tracing for debugging ### vite.config.ts (Full Example) ```typescript 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: ```typescript 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: ```typescript // 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 })) } } }; ``` ```html
``` **When to use**: Displaying collections of items (search results, recommendations, etc.) ### Pattern 3: Interactive Widget with Tool Callbacks Widget that can trigger other tools: ```typescript // 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' } } } } ``` ```html
``` **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: ```bash 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 patterns - `references/openai-metadata-format.md` - All OpenAI-specific annotation fields - `references/widget-patterns.md` - Widget HTML examples and best practices - `references/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 server - `templates/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 ```typescript 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 ```bash # 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 ```typescript // vite.config.ts - add React plugin import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [ react(), cloudflare({ /* ... */ }) ], // ... }); ``` ```tsx // 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 (
{tasks.map(task => (
{task.title}
))}
); } ``` --- ## Dependencies **Required**: - `@modelcontextprotocol/sdk@1.21.0` - Official MCP protocol implementation - `hono@4.10.2` - Lightweight routing framework - `zod@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 widgets - `agents@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) ```json { "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: ```bash 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: 1. CSP blocking external scripts - bundle them with Vite 2. Missing `window.openai` check - widget loads before API ready 3. CORS headers missing - add to `/mcp/*` route ### Problem: Tool registered but never invoked **Solution**: Improve tool description to be more action-oriented: ```typescript // ✅ 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.jsonc` configured with ASSETS binding - [ ] `vite.config.ts` configured 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.initialData` for 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?** 1. Check `references/openai-metadata-format.md` for all annotation fields 2. Verify all steps in the setup process 3. Check official MCP docs: https://modelcontextprotocol.io/ 4. Ensure CORS is configured to allow `https://chatgpt.com` 5. Test with MCP Inspector before trying in ChatGPT