Back to AI Tools & Agents

OpenAI Apps MCP

OpenAIChatGPTMCPCloudflare WorkersAI Agent DevelopmentServerlessCustom ToolsInteractive Widgets
⭐ 860πŸ“„ MITπŸ•’ 2026-06-11Source β†—

Install this skill

npx skills add jezweb/claude-skills

Works across Claude Code, Cursor, Codex, Copilot & Antigravity

OpenAI Apps MCP enables developers to construct interactive applications for the ChatGPT platform using Cloudflare Workers. By implementing the Model Context Protocol, this skill bridges custom backend logic with frontend UI components that render directly inside the ChatGPT interface. Unlike standard tool calls that return simple text, these apps leverage specific annotations to display web-based widgets. The system relies on a specialized communication flow where the MCP server handles JSON-RPC 2.0 requests, routes tool execution, and serves HTML resources via an ASSETS binding. Developers must follow precise requirements regarding CORS headers, specific widget URI schemes, and MIME type definitions to ensure the ChatGPT client correctly hosts and executes these custom widgets within the chat environment.

When to Use This Skill

  • β€’Creating interactive dashboards for real-time data visualization
  • β€’Building task-specific input forms that collect structured user data
  • β€’Embedding dynamic mapping or graphical controls within chat
  • β€’Providing rich, multi-step configuration interfaces for external services

How to Invoke This Skill

Example prompts that trigger this skill in Claude Code, Cursor, or Antigravity:

  • β€œOpen the custom dashboard for this project
  • β€œShow me the interactive configuration tool
  • β€œLaunch the data visualization app
  • β€œOpen the widget to manage settings
  • β€œDisplay the graphical interface for this request

Pro Tips

  • πŸ’‘Prioritize security when handling user context and external API calls within your MCP tools, especially when exposing sensitive data.
  • πŸ’‘Leverage Hono for efficient routing and Zod for robust schema validation of your JSON-RPC payloads to ensure data integrity and reliability.
  • πŸ’‘Optimize widget performance by minimizing HTML/JS bundle sizes and utilizing modern web techniques, ensuring a responsive user experience within the iframe.
  • πŸ’‘Make effective use of `nodejs_compat` in Cloudflare Workers to ensure seamless integration with the MCP SDK and other Node.js-compatible libraries.

What this skill does

  • β€’Execute backend logic and tool functions on Cloudflare Workers
  • β€’Render custom HTML/JS widgets inside ChatGPT iframes
  • β€’Pass dynamic state data from tools to frontend widgets
  • β€’Standardize tool registration via Model Context Protocol
  • β€’Manage widget assets through Cloudflare's static asset binding

When not to use it

  • βœ•When your application logic requires persistent WebSocket connections
  • βœ•When simple text-based responses suffice without UI widgets
  • βœ•When you cannot host your backend on Cloudflare Workers

Example workflow

  1. Scaffold a Cloudflare Worker project with MCP SDK and Hono
  2. Configure wrangler.jsonc with necessary asset bindings
  3. Register a tool in the MCP server with an outputTemplate annotation
  4. Define a corresponding HTML widget file that uses window.openai.getInitialData
  5. Deploy the worker to the edge and verify using the MCP inspector
  6. Invoke the tool within ChatGPT to trigger the widget render

Prerequisites

  • –Cloudflare account with Workers access
  • –Familiarity with Model Context Protocol (MCP) standards
  • –Node.js development environment
  • –Basic knowledge of TypeScript

Pitfalls & limitations

  • !Forgetting to set the text/html+skybridge MIME type, which prevents widget rendering
  • !Using incorrect URI prefixes like resource:// instead of ui://widget/
  • !Missing CORS headers that block the connection to chatgpt.com
  • !Bundling widget assets directly in the code instead of using the ASSETS binding

FAQ

What is the correct MIME type for widgets?
You must use text/html+skybridge. Standard text/html will not render correctly within the ChatGPT iframe.
How does the widget access data from the tool?
Data passed via _meta.initialData in the MCP response is accessible on the client side through the window.openai.getInitialData() function.
Why is the widget failing to load?
Ensure your widget URI uses the ui://widget/ prefix and that your worker is correctly configured to serve the file through the ASSETS binding.

How it compares

This approach upgrades ChatGPT from a text-in/text-out model to a full-stack application environment, allowing for functional UI widgets that manual text prompts cannot reproduce.

Source & trust

⭐ 860 starsπŸ“„ MITπŸ•’ Updated 2026-06-11
πŸ“„ Full skill instructions β€” original source: jezweb/claude-skills
# Building OpenAI Apps with Stateless MCP Servers

**Status**: Production Ready
**Last Updated**: 2026-01-21
**Dependencies**: cloudflare-worker-base, hono-routing (optional)
**Latest Versions**: @modelcontextprotocol/[email protected], [email protected], [email protected], [email protected]

---

## Overview

Build **ChatGPT Apps** using **MCP (Model Context Protocol)** servers on Cloudflare Workers. Extends ChatGPT with custom tools and interactive widgets (HTML/JS UI rendered in iframe).

**Architecture**: ChatGPT β†’ MCP endpoint (JSON-RPC 2.0) β†’ Tool handlers β†’ Widget resources (HTML)

**Status**: Apps available to Business/Enterprise/Edu (GA Nov 13, 2025). MCP Apps Extension (SEP-1865) formalized Nov 21, 2025.

---

## Quick Start

### 1. Scaffold & Install

npm create cloudflare@latest my-openai-app -- --type hello-world --ts --git --deploy false
cd my-openai-app
npm install @modelcontextprotocol/[email protected] [email protected] [email protected]
npm install -D @cloudflare/[email protected] [email protected]


### 2. Configure wrangler.jsonc

{
"name": "my-openai-app",
"main": "dist/index.js",
"compatibility_flags": ["nodejs_compat"], // Required for MCP SDK
"assets": {
"directory": "dist/client",
"binding": "ASSETS" // Must match TypeScript
}
}


### 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';

const app = new Hono<{ Bindings: { ASSETS: Fetcher } }>();

// CRITICAL: Must allow chatgpt.com
app.use('/mcp/*', cors({ origin: 'https://chatgpt.com' }));

const mcpServer = new Server(
{ name: 'my-app', version: '1.0.0' },
{ capabilities: { tools: {}, resources: {} } }
);

// Tool registration
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: 'hello',
description: 'Use this when user wants to see a greeting',
inputSchema: {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
},
annotations: {
openai: { outputTemplate: 'ui://widget/hello.html' } // Widget URI
}
}]
}));

// Tool execution
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'hello') {
const { name } = request.params.arguments as { name: string };
return {
content: [{ type: 'text', text: Hello, ${name}! }],
_meta: { initialData: { name } } // Passed to widget
};
}
throw new Error(Unknown tool: ${request.params.name});
});

app.post('/mcp', async (c) => {
const body = await c.req.json();
const response = await mcpServer.handleRequest(body);
return c.json(response);
});

app.get('/widgets/*', async (c) => c.env.ASSETS.fetch(c.req.raw));

export default app;


### 4. Create Widget (src/widgets/hello.html)

<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; padding: 20px; font-family: system-ui; }
</style>
</head>
<body>
<div id="greeting">Loading...</div>
<script>
if (window.openai && window.openai.getInitialData) {
const data = window.openai.getInitialData();
document.getElementById('greeting').textContent = Hello, ${data.name}! πŸ‘‹;
}
</script>
</body>
</html>


### 5. Deploy

npm run build
npx wrangler deploy
npx @modelcontextprotocol/inspector https://my-app.workers.dev/mcp


---

## Critical Requirements

**CORS**: Must allow https://chatgpt.com on /mcp/* routes
**Widget URI**: Must use ui://widget/ prefix (e.g., ui://widget/map.html)
**MIME Type**: Must be text/html+skybridge for HTML resources
**Widget Data**: Pass via _meta.initialData (accessed via window.openai.getInitialData())
**Tool Descriptions**: Action-oriented ("Use this when user wants to...")
**ASSETS Binding**: Serve widgets from ASSETS, not bundled in worker code
**SSE**: Send heartbeat every 30s (100s timeout on Workers)

---

## Known Issues Prevention

This skill prevents **14** documented issues:

### Issue #1: CORS Policy Blocks MCP Endpoint
**Error**: Access to fetch blocked by CORS policy
**Fix**: app.use('/mcp/*', cors({ origin: 'https://chatgpt.com' }))

### Issue #2: Widget Returns 404 Not Found
**Error**: 404 (Not Found) for widget URL
**Fix**: Use ui://widget/ prefix (not resource:// or /widgets/)
annotations: { openai: { outputTemplate: 'ui://widget/map.html' } }


### Issue #3: Widget Displays as Plain Text
**Error**: HTML source code visible instead of rendered widget
**Fix**: MIME type must be text/html+skybridge (not text/html)
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [{ uri: 'ui://widget/map.html', mimeType: 'text/html+skybridge' }]
}));


### Issue #4: ASSETS Binding Undefined
**Error**: TypeError: Cannot read property 'fetch' of undefined
**Fix**: Binding name in wrangler.jsonc must match TypeScript
{ "assets": { "binding": "ASSETS" } }  // wrangler.jsonc

type Bindings = { ASSETS: Fetcher };  // index.ts


### Issue #5: SSE Connection Drops After 100 Seconds
**Error**: SSE stream closes unexpectedly
**Fix**: Send heartbeat every 30s (Workers timeout at 100s inactivity)
const heartbeat = setInterval(async () => {
await stream.writeSSE({ data: JSON.stringify({ type: 'heartbeat' }), event: 'ping' });
}, 30000);


### Issue #6: ChatGPT Doesn't Suggest Tool
**Error**: Tool registered but never appears in suggestions
**Fix**: Use action-oriented descriptions
// βœ… Good: 'Use this when user wants to see a location on a map'
// ❌ Bad: 'Shows a map'


### Issue #7: Widget Can't Access Initial Data
**Error**: window.openai.getInitialData() returns undefined
**Fix**: Pass data via _meta.initialData
return {
content: [{ type: 'text', text: 'Here is your map' }],
_meta: { initialData: { location: 'SF', zoom: 12 } }
};


### Issue #8: Widget Scripts Blocked by CSP
**Error**: Refused to load script (CSP directive)
**Fix**: Use inline scripts or same-origin scripts. Third-party CDNs blocked.
<!-- βœ… Works --> <script>console.log('ok');</script>
<!-- ❌ Blocked --> <script src="https://cdn.example.com/lib.js"></script>


### Issue #9: Hono Global Response Override Breaks Next.js (v1.25.0-1.25.2)
**Error**: No response is returned from route handler (Next.js App Router)
**Source**: [GitHub Issue #1369](https://github.com/modelcontextprotocol/typescript-sdk/issues/1369)
**Affected Versions**: v1.25.0 to v1.25.2
**Fixed In**: v1.25.3
**Why It Happens**: Hono (MCP SDK dependency) overwrites global.Response, breaking frameworks that extend it (Next.js, Remix, SvelteKit). NextResponse instanceof check fails.
**Prevention**:
- **Upgrade to v1.25.3+** (recommended)
- **Before fix**: Use webStandardStreamableHTTPServerTransport instead
- **Or**: Run MCP server on separate port from Next.js/Remix/SvelteKit app

// βœ… v1.25.3+ - Fixed
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});

// βœ… v1.25.0-1.25.2 - Workaround
import { webStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/index.js';
const transport = webStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});


### Issue #10: Elicitation (User Input) Fails on Cloudflare Workers
**Error**: EvalError: Code generation from strings disallowed
**Source**: [GitHub Issue #689](https://github.com/modelcontextprotocol/typescript-sdk/issues/689)
**Why It Happens**: Internal AJV v6 validator uses prohibited APIs on edge platforms
**Prevention**: Avoid elicitInput() on edge platforms (Cloudflare Workers, Vercel Edge, Deno Deploy)

**Workaround**:
// ❌ Don't use on Cloudflare Workers
const userInput = await server.elicitInput({
prompt: "What is your name?",
schema: { type: "string" }
});

// βœ… Use tool parameters instead
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name } = request.params.arguments as { name: string };
// User provides via tool call, not elicitation
});


**Status**: Requires MCP SDK v2 to fix properly. Track [PR #844](https://github.com/modelcontextprotocol/typescript-sdk/pull/844).

### Issue #11: SSE Transport Statefulness Breaks Serverless Deployments
**Error**: 400: No transport found for sessionId
**Source**: [GitHub Issue #273](https://github.com/modelcontextprotocol/typescript-sdk/issues/273)
**Why It Happens**: SSEServerTransport relies on in-memory session storage. In serverless environments (AWS Lambda, Cloudflare Workers), the initial GET /sse request may be handled by Instance A, but subsequent POST /messages requests land on Instance B, which lacks the in-memory state.
**Prevention**: Use **Streamable HTTP transport** (added in v1.24.0) instead of SSE for serverless deployments
**Solution**: For stateful SSE, deploy to non-serverless environments (VPS, long-running containers)

**Official Status**: Fixed by introducing Streamable HTTP (v1.24+) - now the **recommended standard** for serverless.

### Issue #12: OAuth Configuration Requires TWO Separate Apps
**Source**: [Cloudflare Remote MCP Server Docs](https://developers.cloudflare.com/agents/guides/remote-mcp-server/)
**Why It Happens**: OAuth providers validate redirect URLs strictly. Localhost and production have different URLs, so they need separate OAuth client registrations.
**Prevention**:
# Development OAuth App
Callback URL: http://localhost:8788/callback

# Production OAuth App
Callback URL: https://my-mcp-server.workers.dev/callback


**Additional Requirements**:
- KV namespace for auth state storage (create manually)
- COOKIE_ENCRYPTION_KEY env var: openssl rand -hex 32
- Client restart required after config changes

### Issue #13: Widget State Over 4k Tokens Causes Performance Issues (Community-sourced)
**Source**: [OpenAI Apps SDK - ChatGPT UI](https://developers.openai.com/apps-sdk/build/chatgpt-ui/)
**Why It Happens**: Widget state persists only to a single widget instance tied to one conversation message. State is reset when users submit via the main chat composer instead of widget controls.
**Prevention**: Keep state payloads under **4k tokens** for optimal performance

// βœ… Good - Lightweight state
window.openai.setWidgetState({ selectedId: "item-123", view: "grid" });

// ❌ Bad - Will cause performance issues
window.openai.setWidgetState({
items: largeArray, // Don't store full datasets
history: conversationLog, // Don't store conversation history
cache: expensiveComputation // Don't cache large results
});


**Best Practice**:
- Store only UI state (selected items, view mode, filters)
- Fetch data from MCP server on widget mount
- Use tool calls to persist important data

### Issue #14: Widget-Initiated Tool Calls Fail Without Permission Flag (Community-sourced)
**Source**: [OpenAI Apps SDK - ChatGPT UI](https://developers.openai.com/apps-sdk/build/chatgpt-ui/)
**Why It Happens**: Components initiating tool calls via window.openai.callTool() require the tool marked as "able to be initiated by the component" on the MCP server. Without this flag, calls fail silently.
**Prevention**: Mark tools as widgetCallable: true in annotations

// MCP Server - Mark tool as widget-callable
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: 'update_item',
description: 'Update an item',
inputSchema: { /* ... */ },
annotations: {
openai: {
outputTemplate: 'ui://widget/item.html',
// βœ… Required for widget-initiated calls
widgetCallable: true
}
}
}]
}));

// Widget - Now allowed to call tool
window.openai.callTool({
name: 'update_item',
arguments: { id: itemId, status: 'completed' }
});


---

## Widget Development Best Practices

### File Upload Limitations (Community-sourced)
**Source**: [OpenAI Apps SDK - ChatGPT UI](https://developers.openai.com/apps-sdk/build/chatgpt-ui/)

window.openai.uploadFile() only supports 3 image formats: image/png, image/jpeg, and image/webp. Other formats fail silently.

// βœ… Supported
window.openai.uploadFile({ accept: 'image/png,image/jpeg,image/webp' });

// ❌ Not supported (fails silently)
window.openai.uploadFile({ accept: 'application/pdf' });
window.openai.uploadFile({ accept: 'text/csv' });


**Alternative for Other File Types**:
1. Use base64 encoding in tool arguments
2. Request user paste text content
3. Use external upload service (S3, R2) and pass URL

### Tool Performance Targets (Community-sourced)
**Source**: [OpenAI Apps SDK - Troubleshooting](https://developers.openai.com/apps-sdk/deploy/troubleshooting)

Tool calls exceeding "a few hundred milliseconds" cause UI sluggishness in ChatGPT. Official docs recommend profiling backends and implementing caching for slow operations.

**Performance Targets**:
- **< 200ms**: Ideal response time
- **200-500ms**: Acceptable but noticeable
- **> 500ms**: Sluggish, needs optimization

**Optimization Strategies**:
// 1. Cache expensive computations
const cache = new Map();
if (cache.has(key)) return cache.get(key);
const result = await expensiveOperation();
cache.set(key, result);

// 2. Use KV/D1 for pre-computed data
const cached = await env.KV.get(result:${id});
if (cached) return JSON.parse(cached);

// 3. Paginate large datasets
return {
content: [{ type: 'text', text: 'First 20 results...' }],
_meta: { hasMore: true, nextPage: 2 }
};

// 4. Move slow work to async tasks
// Return immediately, update via follow-up


---

## MCP SDK 1.25.x Updates (December 2025)

**Breaking Changes** from @modelcontextprotocol/[email protected] β†’ 1.25.x:
- Removed loose type exports (Prompts, Resources, Roots, Sampling, Tools) - use specific schemas
- ES2020 target required (previous: ES2018)
- setRequestHandler is now typesafe - incorrect schemas throw type errors

**New Features**:
- **Tasks** (v1.24.0+): Long-running operations with progress tracking
- **Sampling with Tools** (v1.24.0+): Tools can request model sampling
- **OAuth Client Credentials** (M2M): Machine-to-machine authentication

**Migration**: If using loose type imports, update to specific schema imports:
// ❌ Old (removed in 1.25.0)
import { Tools } from '@modelcontextprotocol/sdk/types.js';

// βœ… New (1.25.1+)
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';


---

## Zod 4.0 Migration Notes (MAJOR UPDATE - July 2025)

**Breaking Changes** from [email protected] β†’ 4.x:
- .default() now expects input type (not output type). Use .prefault() for old behavior.
- ZodError: error.issues (not error.errors)
- .merge() and .superRefine() deprecated
- Optional properties with defaults now always apply

**Performance**: 14x faster string parsing, 7x faster arrays, 6.5x faster objects

**Migration**: Update validation code:
// Zod 4.x
try {
const validated = schema.parse(data);
} catch (error) {
if (error instanceof z.ZodError) {
return { content: [{ type: 'text', text: error.issues.map(e => e.message).join(', ') }] };
}
}


---

## Dependencies

{
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.3",
"hono": "^4.11.3",
"zod": "^4.3.5"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.17.1",
"@cloudflare/workers-types": "^4.20260103.0",
"vite": "^7.2.4",
"wrangler": "^4.54.0"
}
}


## Official Documentation

- **MCP Specification**: https://modelcontextprotocol.io/ (Latest: 2025-11-25)
- **MCP SDK**: https://github.com/modelcontextprotocol/typescript-sdk
- **OpenAI Apps SDK**: https://developers.openai.com/apps-sdk
- **MCP Apps Extension (SEP-1865)**: http://blog.modelcontextprotocol.io/posts/2025-11-21-mcp-apps/
- **Context7 Library ID**: /modelcontextprotocol/typescript-sdk

## Production Reference

**Open Source Example**: https://github.com/jezweb/chatgpt-app-sdk (portfolio carousel widget)
- **Live in Production**: Rendering in ChatGPT Business
- **MCP Server**: Full JSON-RPC 2.0 implementation with tools + resources (~310 lines)
- **Widget Integration**: WordPress API β†’ window.openai.toolOutput β†’ React carousel
- **Database**: D1 (SQLite) for contact form submissions
- **Stack**: Hono 4 + React 19 + Tailwind v4 + Drizzle ORM
- **Key Files**:
- /src/lib/mcp/server.ts - Complete MCP handler
- /src/server/tools/portfolio.ts - Tool with widget annotations
- /src/widgets/PortfolioWidget.tsx - Data access pattern
- **Verified**: All 14 known issues prevented, zero errors in production

---

## Community Resources

### Deployment Tools

**Cloudflare One-Click Deploy**: Deploy MCP servers to Cloudflare Workers with pre-built templates and auto-configured CI/CD. Includes OAuth wrapper and Python support.
- Docs: https://developers.cloudflare.com/agents/guides/remote-mcp-server/
- Blog: https://blog.cloudflare.com/model-context-protocol/

### Frameworks

**Skybridge** (Community): React-focused framework with HMR support for widgets and enhanced MCP server helpers. Unofficial but actively maintained.
- GitHub: https://github.com/alpic-ai/skybridge
- Docs: https://www.skybridge.tech/

> **Note**: Community frameworks are not officially supported. Use at your own discretion

How to Use This Skill Unit

Option A: Project-Specific (Recommended)

  1. Click "Download" above
  2. In your project, create the directory: .agent/skills/openai-apps-mcp/
  3. Save the file as SKILL.md
  4. The agent will automatically discover the skill based on its description.

Option B: Global Installation (All Agents)

Save the file to these locations to make it available across all projects:

  • Claude Code: ~/.claude/skills/jezweb/claude-skills/openai-apps-mcp/SKILL.md
  • Cursor: ~/.cursor/skills/jezweb/claude-skills/openai-apps-mcp/SKILL.md
  • Antigravity: ~/.gemini/antigravity/skills/jezweb/claude-skills/openai-apps-mcp/SKILL.md

πŸš€ Install with CLI:
npx skills add jezweb/claude-skills

Read the Master Guide: Mastering Agent Skills β†’

Recommended Rules

View more rules β†’

Recommended Workflows

View more workflows β†’

Recommended MCP Servers

View more MCP servers β†’

Take It Further

Maximize your productivity with these powerful resources

πŸ“‹

Define Your Standards

Set up coding standards to ensure this workflow produces consistent, high-quality results.

Browse Rules Library
πŸ“–

Master Workflows

Learn how to create custom workflows, use Turbo Mode, and build your automation library.

Complete Guide

How to use this Skill in Claude Code & Cursor

For Claude Code (CLI)

To use this skill in Claude Code, copy the rule content into your project's custom instructions or follow our Add-Skill CLI guide. This ensures Claude follows your standards during every code generation.

For Cursor & Windsurf

For Cursor or Windsurf, individual skills are best used in the "Rules for AI" section. This specific unit helps the agent avoid ai tools & agents issues, leading to cleaner, more efficient code.

Why the skill format matters: the standardized Agent Skills format lets your AI agent load detailed instructions only when they are relevant, keeping your prompt clean while improving results.

Source & attribution

This skill is categorized under AI Tools & Agents and is published by JezWeb, maintained in jezweb/claude-skills.

← Browse All Agent Skills
Sponsored AI assistant. Recommendations may be paid.