关于 OpenAI Apps MCP
用于构建 OpenAI Apps 的开发技能,通过 Cloudflare Workers 上的无状态 MCP 服务扩展 ChatGPT。内容涵盖服务端、工具和组件模式,以及部署时常见问题。
主要功能
- 01无状态 MCP 服务模式
- 02Cloudflare Workers 配置
- 03使用 Zod 验证工具输入
- 04ChatGPT 组件资源
- 05部署与测试指南
适用场景
- 01构建集成 ChatGPT 的应用
- 02在 ChatGPT 中呈现交互式组件
- 03部署前测试 MCP 端点
MCP 相关
提供实现无状态 MCP 服务的模式,向 ChatGPT 暴露工具和组件资源,并涵盖配置与测试指导。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ ovachiever/ droid-tings --skill openai-apps-mcp安装后在 Claude Code 中输入/openai-apps-mcp确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
README.md.claude-plugin/plugin.jsonassets/example-template.txtreferences/openai-metadata-format.mdexample-reference.mdscripts/example-script.shtemplates/basic/wrangler.jsoncvite.config.tspackage.json详细介绍
使用无状态 MCP 服务构建 OpenAI Apps
状态:已可用于生产环境
最后更新:2025-11-17
依赖项:cloudflare-worker-base、hono-routing(可选,对路由模式很有帮助)
最新版本:@modelcontextprotocol/[email protected]、[email protected]、[email protected]
概览
本技能提供经过生产环境测试的模式,用于构建 OpenAI Apps——通过模型上下文协议(MCP)扩展 ChatGPT 功能的应用。重点介绍使用 Cloudflare Workers 构建的无状态 MCP 服务,适用于 80% 的 OpenAI Apps 使用场景。
什么是 OpenAI Apps?
OpenAI Apps 是集成到 ChatGPT 界面中的扩展,允许用户:
- 直接在对话中访问第三方服务
- 显示交互式小组件(地图、轮播图、列表等)
- 执行可返回结构化 UI 组件的工具
- 为 ChatGPT 增添特定领域的功能
架构
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)
核心组件
- MCP 服务 - 通过模型上下文协议公开工具的 HTTP 端点
- 工具处理程序 - 处理输入并返回结果的函数
- 小组件资源 - 在 ChatGPT iframe 中渲染的 HTML 页面
- OpenAI 元数据 - 用于小组件路由和显示的特殊注解
快速入门(10 分钟)
1. 搭建项目脚手架
npm create cloudflare@latest my-openai-app -- --type hello-world --ts --git --deploy false
cd my-openai-app
# Install dependencies
npm install @modelcontextprotocol/[email protected] [email protected] [email protected]
npm install -D @cloudflare/[email protected] [email protected]
为什么这很重要:
@modelcontextprotocol/sdk是官方 MCP 协议实现hono提供轻量级路由,非常适合 API 端点- Vite + CloudFlare 插件支持构建和提供小组件
2. 配置 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
}
}
重要:
- MCP SDK 要求启用
nodejs_compat标志 assets.binding: "ASSETS"必须与 TypeScript 绑定名称一致assets.directory必须与 Vite 构建输出目录一致
3. 创建 MCP 服务
// 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;
5 步设置流程
步骤 1:搭建项目脚手架
使用 Cloudflare 官方脚手架:
npm create cloudflare@latest my-openai-app -- --type hello-world --ts --git --deploy false
要点:
- 创建使用 TypeScript 的 Workers 项目
- 包含 wrangler.jsonc
- 初始化 git 仓库
步骤 2:安装依赖项
npm install @modelcontextprotocol/[email protected] [email protected] [email protected]
npm install -D @cloudflare/[email protected] [email protected]
各软件包的作用:
@modelcontextprotocol/sdk- 官方 MCP 协议(Anthropic)hono- 快速、轻量的路由框架zod- 对工具输入进行运行时类型验证@cloudflare/vite-plugin- 用于 Workers 和静态资源的构建工具vite- 前端构建工具
步骤 3:配置构建系统
创建 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';
}
}
}
}
});
为什么这很重要:
- 同时构建 Worker 代码和静态资源
- 为 Workers 和 ASSETS 绑定生成正确的输出结构
- 使用内容哈希实现缓存失效
步骤 4:创建小组件 HTML
mkdir -p src/widgets
创建 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>
应避免的事项:
- 不要使用第三方 CDN 脚本(CSP 可能会阻止)
- 不要使用自定义字体(请使用系统字体)
- 未配置 CORS 时不要调用外部 API
步骤 5:部署并测试
# Build
npm run build
# Deploy to Cloudflare
npx wrangler deploy
# Test with MCP Inspector
npx @modelcontextprotocol/inspector https://my-openai-app.workers.dev/mcp
关键规则
务必做到
✅ 将 CORS 设置为允许 https://chatgpt.com
✅ 小组件使用资源 URI 模式 ui://widget/
✅ HTML 资源的 MIME 类型设置为 text/html+skybridge
✅ 在工具响应中包含 _meta.initialData 以初始化小组件
✅ 使用以操作为导向的工具描述(“在……时使用此工具”)
✅ 使用 Zod 模式验证工具输入
✅ 部署到 ChatGPT 之前,使用 MCP Inspector 进行测试
绝不要
❌ 使用自定义 MIME 类型(必须是 text/html+skybridge)
❌ 忘记配置 CORS(ChatGPT 将无法连接)
❌ 使用不带 ui://widget/ 前缀的资源 URI
❌ 将小组件打包进 Worker 代码(请使用 ASSETS 绑定)
❌ 跳过输入验证(工具接收的数据不可信)
❌ 使用 SSE 时不配置心跳(Workers 上会在 100s 后超时)
❌ 未先在 MCP Inspector 中测试就部署
已知问题预防
本技能可预防已记录的 8 个问题:
问题 #1:CORS 策略阻止 MCP 端点
错误:Access to fetch at 'https://my-app.workers.dev/mcp' from origin 'https://chatgpt.com' has been blocked by CORS policy
来源:未配置 CORS 时常见的浏览器控制台错误
原因:ChatGPT 会向你的 MCP 服务发起跨域请求。如果没有 CORS 标头,浏览器会阻止这些请求。
预防措施:
app.use('/mcp/*', cors({
origin: 'https://chatgpt.com', // Must allow ChatGPT specifically
credentials: true,
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization']
}));
问题 #2:小组件返回 404 Not Found
错误:小组件 URL 出现 Failed to load resource: the server responded with a status of 404 (Not Found)
来源:OpenAI Apps SDK 对 ui://widget/ 前缀的要求
原因:资源 URI 必须遵循特定格式。前缀错误会导致 ChatGPT 无法解析小组件。
预防措施:
// ✅ Correct
annotations: {
openai: {
outputTemplate: 'ui://widget/map.html'
}
}
// ❌ Wrong - will 404
outputTemplate: 'resource://map.html'
outputTemplate: '/widgets/map.html'
问题 #3:小组件显示为纯文本
错误:显示的是 HTML 源代码,而非渲染后的小组件
来源:OpenAI Apps SDK 对 MIME 类型的要求
原因:ChatGPT 需要特定的 MIME 类型 text/html+skybridge 才能渲染小组件。标准的 text/html 无法正常工作。
预防措施:
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [{
uri: 'ui://widget/map.html',
mimeType: 'text/html+skybridge' // Must use +skybridge
}]
}));
问题 #4:ASSETS 绑定未定义
错误:访问 c.env.ASSETS 时出现 TypeError: Cannot read property 'fetch' of undefined
来源:wrangler.jsonc 或 TypeScript 类型配置错误
原因:wrangler.jsonc 中的 ASSETS 绑定名称必须与 TypeScript 绑定接口一致。
预防措施:
// wrangler.jsonc
{
"assets": {
"directory": "dist/client",
"binding": "ASSETS" // Must match TypeScript
}
}
// src/index.ts
type Bindings = {
ASSETS: Fetcher; // Must match wrangler.jsonc binding name
};
问题 #5:SSE 连接在 100 秒后断开
错误:SSE 流意外关闭,工具停止响应
来源:Cloudflare Workers 在没有数据发送时的 SSE 超时
原因:Workers 会在 100 秒无活动后关闭 SSE 连接。必须发送心跳事件。
预防措施:
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));
});
});
问题 #6:ChatGPT 不会推荐工具
错误:工具已注册,但从未出现在 ChatGPT 的推荐中
来源:OpenAI 工具发现机制要求描述以操作为导向
原因:泛泛的描述无法匹配用户意图模式。ChatGPT 会根据描述判断何时调用工具。
预防措施:
// ✅ 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'
问题 #7:小组件无法访问初始数据
错误:小组件中的 window.openai.getInitialData() 返回 undefined
来源:工具响应中缺少 _meta.initialData
原因:必须通过 _meta 字段显式传递数据。常规响应内容不会传递给小组件。
预防措施:
// Tool handler
return {
content: [{
type: 'text',
text: 'Here is your map'
}],
_meta: {
initialData: { // This object reaches window.openai.getInitialData()
location: 'San Francisco',
zoom: 12
}
}
};
问题 #8:CSP 阻止小组件脚本
错误:Refused to load the script because it violates the following Content Security Policy directive
来源:ChatGPT 针对小组件 iframe 的内容安全策略
原因:出于安全考虑,第三方 CDN 的外部脚本会被阻止。
预防措施:
<script>
console.log('Widget loaded');
</script>
<script src="/widgets/lib.js"></script>
<script src="https://cdn.example.com/library.js"></script>
配置文件参考
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
},
"vars": {
"ENVIRONMENT": "production"
}
}
这些设置的作用:
nodejs_compat- MCP SDK 所需(使用 Node.js API)assets.directory- Vite 输出小组件 HTML 文件的位置assets.binding: "ASSETS"- 在代码中访问静态资源的方式observability- 启用日志记录和跟踪以便调试
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',
// 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]'
}
}
}
});
这些设置的作用:
- 多个输入入口(worker 和小组件)
- 为 worker(
index.js)和客户端资源(client/)分别设置输出 - 使用内容哈希防止缓存问题(
[hash])
常见模式
模式 1:纯文本工具
返回文本而不包含小组件的工具:
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`
}]
};
}
});
适用场景:不需要可视化呈现的简单数据检索
模式 2:列表小组件
显示项目列表的工具:
// 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
}))
}
}
};
<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>
适用场景:显示项目集合(搜索结果、推荐内容等)
模式 3:带工具回调的交互式小组件
可以触发其他工具的小组件:
// 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' }
}
}
}
<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>
适用场景:需要触发后续操作的交互式小组件
使用捆绑资源
脚本(scripts/)
运行 ./scripts/scaffold-openai-app.sh 以生成完整的入门项目:
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/mcp-protocol-basics.md- MCP SDK 使用模式references/openai-metadata-format.md- 所有 OpenAI 专用注解字段references/widget-patterns.md- Widget HTML 示例和最佳实践references/optional-agents-upgrade.md- 何时以及如何添加有状态功能
Claude 应在以下情况下加载这些资料:需要查阅详细的 API 参考或了解本 SKILL.md 之外的高级模式时
模板(templates/)
templates/basic/- 最小化的无状态 MCP 服务templates/with-react/- 基于 React 的 Widget,使用 Vite 构建
用法:复制整个模板目录,作为新项目的起点
高级主题
使用 Zod 进行输入验证
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
};
}
}
}
});
环境变量和密钥
# 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;
使用 Vite 构建 React Widget
// 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>
);
}
依赖项
必需:
@modelcontextprotocol/[email protected]- 官方 MCP 协议实现[email protected]- 轻量级路由框架[email protected]- 运行时类型验证
可选:
@cloudflare/[email protected]- Workers + 静态资源的构建工具(推荐)@vitejs/[email protected]- 为 Widget 提供 React 支持[email protected]- Cloudflare Agents SDK(仅在需要有状态 Agent 时使用)
官方文档
- Model Context Protocol:https://modelcontextprotocol.io/
- MCP SDK(GitHub):https://github.com/modelcontextprotocol/typescript-sdk
- OpenAI Apps SDK:https://developers.openai.com/apps-sdk
- Cloudflare Workers:https://developers.cloudflare.com/workers/
- Hono 框架:https://hono.dev/
- Context7 库 ID:/modelcontextprotocol/typescript-sdk
软件包版本(已于 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"
}
}
生产环境示例
此技能基于以下模式:
- Toolbase-AI OpenAI Apps 模板:https://github.com/Toolbase-AI/openai-apps-sdk-cloudflare-vite-template
- 构建时间:从零开始到部署约需 45 分钟
- 错误数:0(此技能预防了全部 8 个已知问题)
- 验证:✅ MCP Inspector 测试通过,Widget 在 ChatGPT 中正常渲染
故障排除
问题:MCP Inspector 显示“Connection refused”
解决方案:确保开发服务正在正确的端口上运行,并且已配置 /mcp 端点:
npm run dev # Should show port (usually 8787)
npx @modelcontextprotocol/inspector http://localhost:8787/mcp
问题:Widget 在 ChatGPT 中显示空白页面
解决方案:检查浏览器控制台中的错误。常见原因:
- CSP 阻止外部脚本——使用 Vite 将其打包
- 缺少
window.openai检查——Widget 在 API 就绪前加载 - 缺少 CORS 标头——将其添加到
/mcp/*路由
问题:工具已注册,但从未被调用
解决方案:改进工具描述,使其更具行动导向:
// ✅ Good
description: 'Use this when the user wants to see weather forecast for a specific city'
// ❌ Bad
description: 'Weather tool'
完整设置清单
- 使用
npm create cloudflare搭建项目脚手架 - 安装依赖(
@modelcontextprotocol/sdk、hono、zod) - 配置
wrangler.jsonc中的 ASSETS 绑定 - 使用 CloudFlare 插件配置
vite.config.ts - 为
/mcp/*路由添加 CORS 中间件 - 至少向 MCP 服务注册一个工具
- 为工具编写面向操作的描述
- 在
src/widgets/中创建 Widget HTML - 使用
ui://widget/URI 模式注册资源 - 将 MIME 类型设置为
text/html+skybridge - 工具处理程序为 Widget 返回
_meta.initialData - 开发服务器运行正常,无错误(
npm run dev) - MCP Inspector 成功连接
- 生产构建成功(
npm run build) - 部署到 Cloudflare Workers(
npx wrangler deploy) - 在 ChatGPT 开发者模式下完成测试
有问题?遇到故障?
- 查看
references/openai-metadata-format.md,了解所有注释字段 - 检查设置流程中的所有步骤
- 查看 MCP 官方文档:https://modelcontextprotocol.io/
- 确保 CORS 配置允许
https://chatgpt.com - 在尝试使用 ChatGPT 前,先使用 MCP Inspector 测试