当 OpenAI 发布 Apps in ChatGPT 功能时,为了一探究竟,我尝试着把自己的博客的一些接口做成了 ChatGPT App。这篇文章记录了我从零开发博客 ChatGPT App 的全过程,配合完整代码,带你一探究竟。
2025年10月,OpenAI 发布了 Apps in ChatGPT 功能,允许开发者为 ChatGPT 开发自定义应用。作为开发者,我第一反应是:能不能让 ChatGPT 读懂我的博客?
我的博客 mofei.life 有完整的 API 接口。于是我用这些 API 开发了一个完整的 ChatGPT App,这篇文章记录全过程。
在之前的文章 "Make Your Website or API AI-Ready with MCP Server (Full Guide + Code Examples)" 中,我介绍了如何将 API 封装为 MCP。最近发布的 Apps in ChatGPT 则在此基础上进一步使用MCP 的 resources 功能,同时加入了自定义 metadata 与 window.openai API,并通过 iframe 将用户自定义 UI 直接嵌入到 ChatGPT 对话界面,从而实现更加自然的交互体验。
简单来说,Apps in ChatGPT 基于 MCP (Model Context Protocol) 协议,让开发者可以:
工作原理示意图:

简单来说,这个流程就是这样:
list-blog-posts(page=1, lang="en") 这个工具查一下。”list-blog-posts 的结果,并告知这些数据可以使用 ui://widget/blog-list.html 模板渲染(也就是名为 "blog-list-widget" 的 MCP Resource 提供的 UI)。听起来是不是挺酷的?那接下来问题来了:具体要怎么实现呢?
经过几天的探索和开发,我最终实现了一个功能完整的博客 ChatGPT App:
功能列表:
技术栈:
效果演示:

开源项目: 完整代码已开源到 GitHub: 🔗 https://github.com/zmofei/mofei-life-chatgpt-app
这篇文章会分享我自己开发的 ChatGPT App 的过程以及学到的东西,供你参考:
所有代码都在 GitHub 上,你可以直接 clone 下来跑,用来学习。
如果你也对 ChatGPT App 开发感兴趣,或者想让 ChatGPT 能读懂你自己的数据,希望这篇文章能帮到你。
在开始写代码之前,我花了不少时间搞清楚 ChatGPT App 和 MCP 到底是什么关系。一开始确实有点绕,但理解了之后就豁然开朗了。
MCP (Model Context Protocol) 是一个标准协议,用来让 AI 模型能够调用外部工具和访问数据。
简单理解,就是:
在我之前的文章 Make Your Website or API AI-Ready with MCP Server 里,详细介绍过如何用 MCP 来暴露 API。当时只用了 MCP 的 Tools 功能,也就是让 AI 能调用我的接口。
ChatGPT App 并不是一个全新的东西,它本质上还是基于 MCP 协议,但在此基础上做了几个重要的扩展:
MCP 原本就有 Resources 的概念,但 ChatGPT App 把它用来做 UI 模板:
// Register blog list resource
this.server.registerResource(
"blog-list-widget",
"ui://widget/blog-list.html",
{
title: "Blog List Widget",
description: "Displays a list of blog posts",
},
async () => {
return {
contents: [
{
uri: "ui://widget/blog-list.html",
mimeType: "text/html+skybridge",
text: WIDGETS.blogList, // Complete HTML page with all CSS and JavaScript
_meta: {
"openai/widgetPrefersBorder": true,
"openai/widgetDomain": "https://chatgpt.com",
"openai/widgetCSP": {
connect_domains: [
"https://static.mofei.life",
"https://api.mofei.life",
],
resource_domains: ["https://static.mofei.life"],
},
},
},
],
};
}
);
这个 Resource 返回的不是普通数据,而是一个完整的 HTML 页面,包含了所有的 CSS 和 JavaScript。注意这里的 widgetCSP 配置很重要,它定义了 Widget 可以访问哪些域名。
关于 WIDGETS.blogList 的说明:
你可能注意到代码里有个 WIDGETS.blogList,它到底是什么?
这是我用 React + Tailwind CSS 写的 Widget 组件,经过编译后生成的自包含 HTML 文件。整个编译过程是这样的:
# 在项目根目录运行
npm run build:web
# 这个命令会执行以下步骤:
# 1. build:widgets - 使用 Vite 编译 React 组件
# 2. build:loader - 运行 build-loader.mjs 生成 loader.ts
编译工具链:
最终生成的 web/loader.ts 文件长这样:
// Auto-generated file
export const WIDGETS = {
blogList: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
/* All Tailwind CSS inlined here */
body { margin: 0; font-family: system-ui; }
.container { max-width: 1200px; margin: 0 auto; }
/* ... thousands of lines of CSS ... */
</style>
</head>
<body>
<div id="root"></div>
<script type="module">
// All React code compiled and inlined here
const { useState, useEffect } = React;
function BlogList() {
// Access data from ChatGPT
const metadata = window.openai?.toolResponseMetadata;
const posts = metadata?.allPosts || [];
// Render blog list UI
return React.createElement('div', { className: 'container' },
posts.map(post => /* ... */)
);
}
// Mount React app
ReactDOM.render(
React.createElement(BlogList),
document.getElementById('root')
);
</script>
</body>
</html>`,
blogArticle: `<!-- Similar structure for article widget -->`
};
这样做的好处是:
实际的 loader.ts 文件有 400+ KB,因为包含了完整的 React runtime 和所有样式。
💡 调试提示: Widget 可以直接在浏览器中打开调试,手动注入 window.openai 数据模拟 ChatGPT 环境,详见后续"Widget 开发"章节。
在 Tool 的定义里,可以通过 _meta 字段告诉 ChatGPT 用哪个 Widget 来显示结果:
// Register blog post listing tool
this.server.registerTool(
"list-blog-posts",
{
title: "List Blog Posts",
description: "Browse and list blog posts with pagination",
inputSchema: {
page: z.number().describe("The page number to retrieve").default(1),
lang: z.string().describe("Language code, e.g. 'en' or 'zh'").default("en"),
},
_meta: {
// Key: Tell ChatGPT to use this Widget for display
"openai/outputTemplate": "ui://widget/blog-list.html",
"openai/toolInvocation/invoking": "Loading blog posts...",
"openai/toolInvocation/invoked": "Blog posts loaded successfully",
"openai/widgetAccessible": true, // Allow Widget to call this tool
},
},
async ({ page, lang }) => {
const url = `https://api.mofei.life/api/blog/list/${page}?lang=${lang}`;
const res = await fetch(url);
const data = await res.json();
// Return three-layer data structure...
return {
structuredContent: { /* ... */ },
content: [ /* ... */ ],
_meta: { /* ... */ }
};
}
);
常用的 _meta 字段
| 字段 | 类型 | 说明 | 示例 |
|---|---|---|---|
openai/outputTemplate | string (URI) | 指定用哪个 Widget UI 来渲染工具返回的结果 | "ui://widget/blog-list.html" |
openai/widgetCSP | object | 定义内容安全策略,包含 connect_domains(可连接的域名) 和 resource_domains(可加载资源的域名) | { connect_domains: ["https://api.mofei.life"] } |
openai/widgetAccessible | boolean | 允许 Widget 通过 window.openai.callTool 独立调用工具 | true |
openai/toolInvocation/invoking | string | 工具执行时显示的加载消息 | "Loading blog posts..." |
openai/toolInvocation/invoked | string | 工具执行完成后显示的确认消息 | "Blog posts loaded" |
其他可用字段包括 widgetPrefersBorder、widgetDomain、widgetDescription、locale、userAgent 等,完整列表见 OpenAI 官方文档。
这些字段可以在两个地方使用:
_meta - 定义工具本身的元数据_meta - 传递给 Widget 的运行时数据这是最关键的部分。ChatGPT 会在 Widget 的 iframe 里注入一个全局对象 window.openai,让 Widget 可以:
window.openai.toolResponseMetadata 获取工具返回的完整数据window.openai.callTool() 调用其他工具(比如翻页)window.openai.sendFollowUpMessage() 给 ChatGPT 发送后续消息// 在 Widget 里可以这样用
function BlogList() {
// 读取数据
const metadata = window.openai.toolResponseMetadata;
const posts = metadata?.allPosts || [];
// 翻页
async function handlePageChange(page: number) {
await window.openai.callTool("list-blog-posts", {
page,
lang: "zh"
});
}
// 点击文章
function handleArticleClick(id: string) {
window.openai.sendFollowUpMessage(`请显示文章 ${id} 的内容`);
}
return <div>{/* UI 代码 */}</div>;
}
完整的 window.openai API 列表
根据 OpenAI 官方文档,Widget 可以访问以下 API(截至2025年11月23日):
数据和状态属性:
| 属性/方法 | 类型 | 说明 |
|---|---|---|
toolInput | object | 工具被调用时传入的参数,用于读取输入参数 |
toolOutput | object | 包含你返回的 structuredContent,AI 模型会直接读取这些字段 |
toolResponseMetadata | object | 包含返回的 _meta 数据,只有 Widget 可见,AI 模型看不到 |
widgetState | object | UI 状态快照,在渲染之间持久化,让组件保持上下文 |
setWidgetState(state) | function | 同步存储新的状态快照,在每次有意义的用户交互后调用 |
Widget 运行时 API:
| 方法 | 签名 | 说明 |
|---|---|---|
callTool | callTool(name: string, args: object): Promise<any> | 让 Widget 独立调用 MCP 工具。需要在工具的 _meta 中设置 openai/widgetAccessible: true |
sendFollowUpMessage | sendFollowUpMessage({ prompt: string }): Promise<void> | 让 Widget 向 ChatGPT 发送消息,触发新的对话 |
requestDisplayMode | requestDisplayMode({ mode: string }): Promise<any> | 请求画中画或全屏渲染模式 |
requestModal | requestModal(...): Promise<any> | 创建 ChatGPT 控制的模态框,用于结账流程等覆盖层 |
notifyIntrinsicHeight | notifyIntrinsicHeight(...): void | 报告动态 Widget 高度,防止滚动裁剪 |
openExternal | openExternal({ href: string }): Promise<void> | 在用户浏览器中打开经过审核的外部链接 |
上下文属性:
| 属性 | 类型 | 说明 |
|---|---|---|
theme | "light" | "dark" | 当前主题模式 |
displayMode | "inline" | "pip" | "fullscreen" | Widget 显示模式 |
maxHeight | number | Widget 最大高度(像素) |
safeArea | object | 安全区域边距 |
view | string | 视图类型 |
userAgent | string | 用户代理字符串 |
locale | string | 语言环境代码(如 "en-US", "zh-CN") |
这些 API 可以通过两种方式访问:
window.openai.toolResponseMetadatauseToolResponseMetadata(), useTheme() 等(响应式更新)
用一个生活化的比喻来说:
想象餐厅和中央厨房的关系:
MCP 就像是 中央厨房(供应工厂):
ChatGPT App 就像是 餐厅:
总结来说:

所以说,ChatGPT App = 从 MCP 定制内容 + 提供用餐环境和服务。
理解了 MCP 和 ChatGPT App 的关系后,让我们看看一个完整的请求是如何工作的。我会用我的博客 App 为例,详细拆解整个流程。
想象用户在 ChatGPT 中说:"Show me the latest articles from Mofei's blog"
整个流程是这样的:

让我详细解释每个步骤:
用户在 ChatGPT 对话框输入:"Show me the latest articles from Mofei's blog"
ChatGPT 分析用户意图,发现有一个 list-blog-posts 工具可以满足需求,于是调用:
// ChatGPT 内部决定调用
list-blog-posts({
page: 1,
lang: "en"
})
我的 MCP Server 收到请求,从 API 获取数据,然后返回三层数据结构:
return {
// Layer 1: structuredContent - AI 模型会读取
structuredContent: {
page: 1,
lang: "en",
totalCount: 42,
postsOnPage: 12,
posts: [
{ id: "123", title: "Article 1", pubtime: "2025-11-23", ... },
// ... 简洁的摘要信息
]
},
// Layer 2: content - 在对话中显示给用户
content: [
{
type: "text",
text: "Found 42 total blog posts. Showing page 1 with 12 posts."
}
],
// Layer 3: _meta - 只有 Widget 能看到
_meta: {
allPosts: [...], // 完整的文章列表,包含所有字段
currentPage: 1,
totalCount: 42,
pageSize: 12,
apiUrl: "https://api.mofei.life/api/blog/list/1?lang=en",
fetchedAt: "2025-11-23T10:00:00Z"
}
};
为什么要三层?
ChatGPT 看到 Tool 定义中的 _meta:
_meta: {
"openai/outputTemplate": "ui://widget/blog-list.html"
}
于是去请求 blog-list-widget Resource。
Resource 返回完整的 HTML 字符串(包含所有 CSS 和 JavaScript):
return {
contents: [{
uri: "ui://widget/blog-list.html",
mimeType: "text/html+skybridge",
text: WIDGETS.blogList, // 400KB+ 的完整 HTML
_meta: {
"openai/widgetDomain": "https://chatgpt.com",
"openai/widgetCSP": { ... }
}
}]
};
ChatGPT:
window.openai API_meta 数据注入为 window.openai.toolResponseMetadataWidget 的 React 代码开始执行:
function BlogList() {
// 读取 ChatGPT 注入的数据
const metadata = window.openai.toolResponseMetadata;
const posts = metadata?.allPosts || [];
// 渲染精美的博客列表
return (
<div>
{posts.map(post => (
<article key={post._id} onClick={() => handleClick(post._id)}>
<h2>{post.title}</h2>
<p>{post.introduction}</p>
<div className="tags">{post.tags.map(...)}</div>
</article>
))}
</div>
);
}
用户看到精美的博客列表 UI。
用户点击"下一页"按钮,Widget 调用:
async function handlePageChange(page: number) {
// Widget 独立调用 Tool
await window.openai.callTool("list-blog-posts", {
page: page,
lang: "en"
});
}
流程回到步骤 3,ChatGPT 重新调用 MCP,更新数据,Widget 重新渲染。
数据分层传递:
structuredContent(简洁)content(文本)_meta(完整数据)Widget 是独立的:
window.openai.callTool)sendFollowUpMessage)MCP 只负责传输:
理解完理论后,让我们开始实战。MCP 部分是整个 ChatGPT App 的核心基础,它定义了 ChatGPT 能做什么、怎么做。我会用我的博客 App 为例,一步步带你实现。
首先,让我们搭建一个基础的 MCP 服务器项目。我选择了 CloudFlare Workers 作为托管平台,因为它免费、快速、全球分布,而且支持 SSE (Server-Sent Events),这是 ChatGPT 调用 MCP 必需的。
初始化项目:
# 创建项目目录
mkdir mofei-blog-chatgpt-app
cd mofei-blog-chatgpt-app
# 初始化 npm 项目
npm init -y
# 安装必要依赖
npm install @modelcontextprotocol/sdk agents zod
npm install -D wrangler typescript @types/node
关键依赖说明:
@modelcontextprotocol/sdk - MCP 官方 SDKagents - CloudFlare Workers 的 MCP 辅助库zod - 用于定义和验证工具参数的 Schemawrangler - CloudFlare Workers 的开发和部署工具创建 src/index.ts,这是 MCP Server 的入口文件。基础结构很简单:
import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
export class MyMCP extends McpAgent {
server = new McpServer({
name: "Mofei's Blog",
version: "1.0.0",
});
async init() {
// 在这里注册 Tools 和 Resources
}
}
// CloudFlare Workers 入口
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url);
// SSE 端点 - ChatGPT 通过这个调用 MCP
if (url.pathname === "/sse" || url.pathname === "/sse/message") {
return MyMCP.serveSSE("/sse").fetch(request, env, ctx);
}
return new Response("Not found", { status: 404 });
},
};
关键点:
📁 完整代码: src/index.ts
Tool 注册的核心是定义参数和返回三层数据结构:
async init() {
this.server.registerTool(
"list-blog-posts",
{
title: "List Blog Posts",
description: "Browse and list blog posts with pagination",
inputSchema: {
page: z.number().default(1),
lang: z.string().default("en"),
},
_meta: {
"openai/outputTemplate": "ui://widget/blog-list.html", // 指定 Widget
"openai/widgetAccessible": true, // 允许 Widget 调用
},
},
async ({ page, lang }) => {
const data = await fetch(`https://api.mofei.life/api/blog/list/${page}?lang=${lang}`)
.then(r => r.json());
return {
structuredContent: { /* AI 看到的简洁数据 */ },
content: [{ type: "text", text: "..." }], // 对话框显示的文本
_meta: { allPosts: data.list, ... }, // Widget 专用的完整数据
};
}
);
}
三层数据结构的关键:
structuredContent - AI 模型读取,要简洁,避免浪费 tokencontent - 对话框显示的文本_meta - Widget 独享,可以包含完整数据、图片等,AI 看不到📁 完整实现: src/index.ts#L83-L144
Resource 用来提供 Widget 的 HTML 内容:
async init() {
this.server.registerResource(
"blog-list-widget",
"ui://widget/blog-list.html",
{ title: "Blog List Widget" },
async () => ({
contents: [{
uri: "ui://widget/blog-list.html",
mimeType: "text/html+skybridge", // 必须是这个类型
text: WIDGETS.blogList, // 完整的 HTML 字符串
_meta: {
"openai/widgetCSP": {
connect_domains: ["https://api.mofei.life"], // 允许调用的 API
resource_domains: ["https://static.mofei.life"], // 允许加载的资源
},
},
}],
})
);
}
关键配置:
widgetCSP - 定义 Widget 可以访问哪些域名(API 和静态资源)WIDGETS.blogList - 编译后的 HTML 字符串,下一章详细讲解📁 完整实现: src/index.ts#L14-L45
配置 wrangler.toml:
name = "mofei-blog-mcp"
main = "src/index.ts"
compatibility_date = "2024-11-01"
启动本地开发服务器:
npm run dev
这会启动一个本地服务器,通常在 http://localhost:8787。
测试 MCP 端点:
# 测试 SSE 端点
curl http://localhost:8787/sse
# 或者用 HTTP POST 测试(调试更方便)
curl -X POST http://localhost:8787/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "tools/list",
"id": 1
}'
部署非常简单:
# 登录 CloudFlare(第一次需要)
npx wrangler login
# 部署
npm run deploy
部署完成后,你会得到一个公网 URL,类似:
https://mofei-blog-mcp.your-username.workers.dev
这个 URL 就是你要在 ChatGPT App 配置中填写的 MCP 端点。
1. 使用 console.log
在 MCP Tool 中添加日志:
async ({ page, lang }) => {
console.log('[MCP] list-blog-posts called:', { page, lang });
const data = await fetch(url).then(r => r.json());
console.log('[MCP] API response:', data);
return { ... };
}
然后在本地开发时,日志会显示在终端。部署到 CloudFlare 后,可以用 wrangler tail 查看实时日志:
npx wrangler tail
2. 测试三层数据结构
你可以单独测试返回的数据格式:
// 临时添加一个测试端点
if (url.pathname === "/test-tool") {
const result = await myMCP.server.tools["list-blog-posts"].handler({
page: 1,
lang: "en"
});
return new Response(JSON.stringify(result, null, 2), {
headers: { "Content-Type": "application/json" }
});
}
3. 验证 Resource 返回
同样添加测试端点检查 Widget HTML:
if (url.pathname === "/test-widget") {
const result = await myMCP.server.resources["blog-list-widget"].handler();
// 返回 HTML,可以直接在浏览器预览
return new Response(result.contents[0].text, {
headers: { "Content-Type": "text/html" }
});
}
MCP 提供了数据和功能,但用户看到的精美界面是从哪来的?答案就是 Widget - 一个运行在 ChatGPT iframe 中的自定义 UI 组件。
我的 Widget 技术栈:
为什么要打包成单个 HTML 文件?
MCP Resource 返回的是一个 HTML 字符串,不是文件路径。理论上你可以在返回的 HTML 中引用外部的 HTTP 资源(CSS、JS 文件),但这样做需要:
widgetCSP 中添加这些资源域名这无疑增加了部署和维护的复杂度。
相比之下,打包成单个自包含的 HTML 文件有明显优势:
这就是 vite-plugin-singlefile 的作用 - 把所有 React 组件、CSS、JavaScript 全部编译并内联到一个 HTML 字符串中。
在项目中创建 web/ 目录:
web/
├── package.json
├── vite.config.ts
├── tsconfig.json
├── build-loader.mjs # 生成 loader.ts 的脚本
└── src/
├── hooks/
│ └── useOpenAi.ts # 封装 window.openai API
├── blog-list/
│ ├── main.tsx # 入口文件
│ └── BlogList.tsx # 主组件
└── blog-article/
├── main.tsx
└── BlogArticle.tsx
核心配置就是使用 vite-plugin-singlefile 插件:
// web/vite.config.ts
import { viteSingleFile } from 'vite-plugin-singlefile';
export default defineConfig({
plugins: [react(), viteSingleFile()], // 单文件打包
build: {
outDir: `dist/${process.env.WIDGET}`,
rollupOptions: {
input: `src/${process.env.WIDGET}/main.tsx`
}
}
});
构建脚本:
{
"scripts": {
"build": "npm run build:widgets && npm run build:loader",
"build:widgets": "WIDGET=blog-list vite build && WIDGET=blog-article vite build",
"build:loader": "node build-loader.mjs"
}
}
📁 完整配置: web/vite.config.ts | web/package.json
创建 web/src/hooks/useOpenAi.ts 来封装 ChatGPT 注入的 API:
import { useSyncExternalStore } from 'react';
function subscribe(callback: () => void) {
window.addEventListener('openai:set_globals', callback);
return () => window.removeEventListener('openai:set_globals', callback);
}
// 获取 Tool 返回的 _meta 数据
export function useToolResponseMetadata<T = any>(): T | null {
return useSyncExternalStore(
subscribe,
() => window.openai?.toolResponseMetadata || null
);
}
// 获取 Tool 的输入参数
export function useToolInput<T>() {
return useSyncExternalStore(
subscribe,
() => window.openai?.toolInput || null
);
}
这个 Hook 使用 useSyncExternalStore 订阅 window.openai 的数据变化,并监听 openai:set_globals 事件来触发重新渲染。
📁 完整代码: web/src/hooks/useOpenAi.ts
Widget 的核心逻辑很简单 - 读取数据并渲染 UI:
export function BlogList() {
// 1. 读取 MCP Tool 返回的数据
const metadata = useToolResponseMetadata<{
allPosts?: BlogPost[];
currentPage?: number;
}>();
const posts = metadata?.allPosts || [];
// 2. 处理翻页 - 直接调 API,不通过 ChatGPT
const handlePageChange = async (newPage: number) => {
const data = await fetch(`https://api.mofei.life/api/blog/list/${newPage}`)
.then(r => r.json());
setPosts(data.list);
};
// 3. 处理文章点击 - 让 ChatGPT 调用 get-blog-article Tool
const handleArticleClick = (articleId: string) => {
window.openai?.sendFollowUpMessage({
prompt: `Show article ${articleId}`
});
};
return (
<div>
{posts.map(post => (
<article key={post._id} onClick={() => handleArticleClick(post._id)}>
<h2>{post.title}</h2>
<p>{post.introduction}</p>
</article>
))}
</div>
);
}
交互模式:
useToolResponseMetadata 获取 MCP 返回的 _metasendFollowUpMessage 让 ChatGPT 调用其他 Tool📁 完整实现: web/src/blog-list/BlogList.tsx
运行构建命令:
cd web
npm run build
Vite + vite-plugin-singlefile 会把 React 组件、Tailwind CSS、所有 JavaScript 编译并内联到一个 HTML 文件:
<!DOCTYPE html>
<html>
<head>
<style>/* 所有 CSS 内联 */</style>
</head>
<body>
<div id="root"></div>
<script type="module">
// 所有 React 代码内联
function BlogList() { /* ... */ }
ReactDOM.render(React.createElement(BlogList), ...);
</script>
</body>
</html>
这个 HTML 文件完全独立,可以直接在浏览器打开运行!
MCP Resource 需要 TypeScript 字符串常量,所以我们用脚本把 HTML 转成 TS 文件。创建 web/build-loader.mjs:
// 读取所有 Widget 的 HTML 文件
const widgets = ['blog-list', 'blog-article'];
const outputs = {};
for (const widget of widgets) {
const html = fs.readFileSync(`dist/${widget}/index.html`, 'utf-8');
outputs[toCamelCase(widget)] = html;
}
// 生成 TypeScript 文件
fs.writeFileSync('../web/loader.ts',
`export const WIDGETS = ${JSON.stringify(outputs, null, 2)};`
);
生成的 web/loader.ts:
export const WIDGETS = {
"blogList": "<!DOCTYPE html><html>...</html>",
"blogArticle": "<!DOCTYPE html><html>...</html>"
};
在 MCP Server 中导入使用:
import { WIDGETS } from "../web/loader";
text: WIDGETS.blogList // 在 Resource 中使用
📁 完整脚本: web/build-loader.mjs
方法 1: 直接打开 HTML
构建完成后,可以直接在浏览器中打开编译后的 HTML 文件:
# 方式 1: 使用命令
open web/dist/blog-list/index.html
# 方式 2: 或者直接在浏览器中打开这个路径
# web/dist/blog-list/src/blog-list/index.html

然后在浏览器控制台手动注入 window.openai 来模拟 ChatGPT 环境:
// Step 1: 初始化 window.openai API (包含完整属性)
window.openai = {
toolInput: { page: 1, lang: "en" },
toolOutput: null,
toolResponseMetadata: null,
widgetState: null,
theme: "light",
locale: "en-US",
displayMode: "inline",
maxHeight: 800,
setWidgetState: async (state) => {
window.openai.widgetState = state;
console.log('Widget state updated:', state);
},
callTool: async (name, args) => {
console.log('Tool called:', name, args);
return { success: true };
},
sendFollowUpMessage: async (args) => {
console.log('Follow-up message:', args);
}
};
// Step 2: 注入测试数据
window.openai.toolResponseMetadata = {
allPosts: [
{
_id: "test123",
title: "Getting Started with ChatGPT Apps",
introduction: "Learn how to build your first ChatGPT App using MCP protocol and custom widgets",
pubtime: "2025-11-23",
tags: [
{ id: 1, name: "JavaScript", color: "#f7df1e" },
{ id: 2, name: "React", color: "#61dafb" }
],
visited: 1234
},
{
_id: "test456",
title: "Understanding MCP Resources",
introduction: "Deep dive into Model Context Protocol resources and how to use them effectively",
pubtime: "2025-11-22",
tags: [{ id: 3, name: "TypeScript", color: "#3178c6" }],
visited: 567
}
],
currentPage: 1,
totalCount: 20,
pageSize: 12
};
// Step 3: 触发事件通知 React 重新渲染
// ⚠️ 重要: 必须先设置数据(Step 2),再触发事件(Step 3)
window.dispatchEvent(new CustomEvent('openai:set_globals', {
detail: {
globals: {
toolResponseMetadata: window.openai.toolResponseMetadata
}
}
}));
调试时需要注意:
window.openai 属性,避免 Widget 调用时报错useSyncExternalStore 监听 openai:set_globals 事件方法 2: 本地开发服务器
cd web
npm run dev
会自动打开浏览器,然后同样使用上面的代码注入 window.openai 数据。
1. 处理数据缺失
Widget 加载时,window.openai 可能还没初始化完成,要做好容错:
export function BlogList() {
const metadata = useToolResponseMetadata();
if (!metadata) {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
<span className="ml-3">Loading...</span>
</div>
);
}
const posts = metadata.allPosts || [];
// ...
}
2. 响应主题切换
ChatGPT 支持浅色/深色主题,Widget 也应该跟随:
import { useTheme } from '../hooks/useOpenAi';
export function BlogList() {
const theme = useTheme();
return (
<div className={theme === 'dark' ? 'bg-gray-900 text-white' : 'bg-white text-black'}>
{/* ... */}
</div>
);
}
3. 优化性能
loading="lazy"react-windowReact.memo4. 处理错误
const [error, setError] = useState<string | null>(null);
const handlePageChange = async (page: number) => {
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load');
const data = await response.json();
setPosts(data.list);
} catch (err) {
setError('Failed to load page. Please try again.');
console.error(err);
}
};
{error && (
<div className="p-4 bg-red-50 text-red-600 rounded">
{error}
</div>
)}
整理一下完整的开发流程:
# 1. 开发 Widget
cd web
npm run dev # 启动 Vite dev server
# 2. 在浏览器中调试,手动注入 window.openai 数据
# 3. 构建 Widget
npm run build # 生成 HTML 和 loader.ts
# 4. 构建 MCP Server
cd ..
npm run build # 可选,TypeScript 编译
# 5. 部署到 CloudFlare Workers
npm run deploy
# 6. 在 ChatGPT 中配置 MCP URL 并测试
开发完 MCP 和 Widget 后,最关键的一步就是连接到 ChatGPT 并进行调试。这个过程涉及三个步骤:托管 MCP Server、连接到 ChatGPT、开启调试模式。
首先需要让 ChatGPT 能够访问你的 MCP Server。有两种方式:
方式 A: 部署到 CloudFlare Workers (推荐)
# 部署到生产环境
npm run deploy
部署后会得到一个公网 URL:
https://your-mcp-name.your-username.workers.dev
方式 B: 使用 ngrok 暴露本地服务
如果想在本地调试,可以用 ngrok 创建临时隧道:
# 启动本地 MCP Server
npm run dev # 默认在 http://localhost:8787
# 新开一个终端,用 ngrok 暴露
ngrok http 8787
ngrok 会给你一个临时 URL:
https://abc123.ngrok.io
📖 参考文档: Deploy your MCP server
开启 Developer Mode
首先需要开启开发者模式:
添加 MCP Server
# CloudFlare Workers
https://your-mcp-name.your-username.workers.dev/sse
# 或 ngrok
https://abc123.ngrok.io/sse
⚠️ 注意: URL 必须以 /sse 结尾
验证连接
点击 "Test connection" 按钮,如果成功会显示:
保存配置
连接成功后,点击 "Save" 保存配置
📖 参考文档: Connect to ChatGPT
基础测试:
在 ChatGPT 中输入测试指令:
Show me the blog posts from Mofei's blog
观察 Debug 面板中的信息:
list-blog-posts 被调用page 和 lang 参数常见调试场景:
场景 1: Tool 没有被调用
可能原因:
解决方法:
// 改进 Tool 的 description
description: "Browse and list blog posts with pagination. Use this when the user wants to see blog articles, explore blog content, or find specific posts."
场景 2: Widget 不显示
可能原因:
解决方法:
// 检查 outputTemplate 和 Resource URI 是否一致
_meta: {
"openai/outputTemplate": "ui://widget/blog-list.html" // Tool 中
}
// Resource 注册
registerResource(
"blog-list-widget",
"ui://widget/blog-list.html", // 必须完全一致
...
)
场景 3: Widget 显示空白
可能原因:
window.openai 数据未注入解决方法:
// 在 Widget 中添加日志
console.log('[Widget] window.openai:', window.openai);
console.log('[Widget] metadata:', metadata);
// 添加错误边界
if (!metadata) {
return <div>Loading or no data available...</div>;
}
场景 4: 跨域或资源加载失败
可能原因:
解决方法:
_meta: {
"openai/widgetCSP": {
connect_domains: [
"https://api.mofei.life", // 添加你要调用的 API 域名
],
resource_domains: [
"https://static.mofei.life", // 添加图片、CSS 等资源域名
],
},
}
通过这篇文章,我们完整地走过了开发 ChatGPT App 的全过程 - 从理解概念到实际编码,再到部署调试。
1. ChatGPT App = MCP + Widget
window.openai API2. 三层数据结构是关键
return {
structuredContent: { /* AI 读取 */ },
content: [{ /* 对话框显示 */ }],
_meta: { /* Widget 独享 */ }
}
这种设计让 AI、用户、Widget 各取所需,既节省 token 又保证体验。
3. 单文件打包简化部署
使用 vite-plugin-singlefile 把 React 组件打包成自包含的 HTML,让部署变得极其简单 - 只需要一个 MCP Server。
4. Debug 模式是开发利器
通过 Developer Mode,可以看到:
开发 ChatGPT App 不仅仅是技术实践,更是让 AI 能够:
官方文档:
本文完整代码:
我的博客:
ChatGPT App 还是一个新生事物,OpenAI 也在持续改进 API 和功能。但正因为新,才有更多探索的空间和创造的可能。
从好奇心驱动到实际产品,这个过程充满了挑战,但也收获满满:
如果这篇文章对你有帮助,欢迎:
让我们一起探索 AI 应用开发的无限可能!
如果有任何疑问或者不同意见,欢迎留言一起讨论!