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, or ASSETS binding errors.
/plugin marketplace add jezweb/claude-skills/plugin install jezweb-tooling-skills@jezweb/claude-skillsThis skill is limited to using the following tools:
README.mdassets/example-template.txtreferences/example-reference.mdreferences/openai-metadata-format.mdscripts/example-script.shtemplates/basic/package.jsontemplates/basic/vite.config.tstemplates/basic/wrangler.jsoncStatus: Production Ready
Last Updated: 2026-01-09
Dependencies: cloudflare-worker-base, hono-routing (optional)
Latest Versions: @modelcontextprotocol/sdk@1.25.2, 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.2 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/mcp
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)
This skill prevents 8 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.jsonc
type Bindings = { ASSETS: Fetcher }; // index.ts
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);
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>
Breaking 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.1",
"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 patternThis skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.