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.

allowed_tools: Read, Write, Edit, Bash, Glob, Grep

$ 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

  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

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

{
  "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

// 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 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:

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 files
  • assets.binding: "ASSETS" - How to access static assets in code
  • observability - 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 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

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 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


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:


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:

  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:

// โœ… 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