Claude Code Plugins

Community-maintained marketplace

Feedback

OpenAI Apps MCP

@jezweb/claude-skills
33
0

|

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

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

  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