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, widget 404s, MIME types, ASSETS binding errors, Next.js integration issues, or edge platform limitations.
Status: Production Ready
Last Updated: 2026-01-21
Dependencies: cloudflare-worker-base, hono-routing (optional)
Latest Versions: @modelcontextprotocol/sdk@1.25.3, hono@4.11.3, zod@4.3.5, wrangler@4.58.0
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.
npm create cloudflare@latest my-openai-app -- --type hello-world --ts --git --deploy false
cd my-openai-app
npm install @modelcontextprotocol/sdk@1.25.3 hono@4.11.3 zod@4.3.5
npm install -D @cloudflare/vite-plugin@1.17.1 vite@7.2.4{
"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
}
}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;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>npm run build
npx wrangler deploy
npx @modelcontextprotocol/inspector https://my-app.workers.dev/mcpCORS: 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)
This skill prevents 14 documented issues:
Error: Access to fetch blocked by CORS policy
Fix: app.use('/mcp/*', cors({ origin: 'https://chatgpt.com' }))
Error: 404 (Not Found) for widget URL
Fix: Use ui://widget/ prefix (not resource:// or /widgets/)
annotations: { openai: { outputTemplate: 'ui://widget/map.html' } }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' }]
}));Error: TypeError: Cannot read property 'fetch' of undefined
Fix: Binding name in wrangler.jsonc must match TypeScript
{ "assets": { "binding": "ASSETS" } } // wrangler.jsonctype Bindings = { ASSETS: Fetcher }; // index.tsError: 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);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'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 } }
};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>Error: No response is returned from route handler (Next.js App Router)
Source: GitHub Issue #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:
webStandardStreamableHTTPServerTransport instead// ✅ 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,
});Error: EvalError: Code generation from strings disallowed
Source: GitHub Issue #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.
Error: 400: No transport found for sessionId
Source: GitHub Issue #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.
Source: Cloudflare Remote MCP Server Docs 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/callbackAdditional Requirements:
COOKIE_ENCRYPTION_KEY env var: openssl rand -hex 32Source: OpenAI Apps SDK - 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:
Source: OpenAI Apps SDK - 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' }
});Source: OpenAI Apps SDK - 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:
Source: OpenAI Apps SDK - 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:
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-upBreaking Changes from @modelcontextprotocol/sdk@1.24.x → 1.25.x:
setRequestHandler is now typesafe - incorrect schemas throw type errorsNew Features:
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';Breaking Changes from zod@3.x → 4.x:
.default() now expects input type (not output type). Use .prefault() for old behavior.error.issues (not error.errors).merge() and .superRefine() deprecatedPerformance: 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": {
"@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"
}
}Open Source Example: https://github.com/jezweb/chatgpt-app-sdk (portfolio carousel widget)
window.openai.toolOutput → React carousel/src/lib/mcp/server.ts - Complete MCP handler/src/server/tools/portfolio.ts - Tool with widget annotations/src/widgets/PortfolioWidget.tsx - Data access patternCloudflare One-Click Deploy: Deploy MCP servers to Cloudflare Workers with pre-built templates and auto-configured CI/CD. Includes OAuth wrapper and Python support.
Skybridge (Community): React-focused framework with HMR support for widgets and enhanced MCP server helpers. Unofficial but actively maintained.
Note: Community frameworks are not officially supported. Use at your own discretion
fa91c34
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.