嘿, 我是Mofei!
...

从零构建 ChatGPT App:MCP 集成、自定义 Widget 与真实 API 实战案例

2025年11月23日 14:00

当 OpenAI 发布 Apps in ChatGPT 功能时,为了一探究竟,我尝试着把自己的博客的一些接口做成了 ChatGPT App。这篇文章记录了我从零开发博客 ChatGPT App 的全过程,配合完整代码,带你一探究竟。


开篇:一次好奇心驱动的探索

2025年10月,OpenAI 发布了 Apps in ChatGPT 功能,允许开发者为 ChatGPT 开发自定义应用。作为开发者,我第一反应是:能不能让 ChatGPT 读懂我的博客?

我的博客 mofei.life 有完整的 API 接口。于是我用这些 API 开发了一个完整的 ChatGPT App,这篇文章记录全过程。

Apps in ChatGPT 是什么?

在之前的文章 "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) 协议,让开发者可以:

  1. 定义工具(Tools) - 告诉 ChatGPT 你有哪些功能可以调用
  2. 展示 UI (Widgets) - ChatGPT可以结合Tools返回的结果,加上MCP的Resources,在ChatGPT APP中通过iframe显示精美的可视化界面
  3. UI和GPT的交互 - ChatGPT 还开放了一些通过APP UI内部调用ChatGPT的Chatbox或者其他和ChatGPT交互的功能。

工作原理示意图:

https://static.mofei.life/blog/article/251123/2025-11-23-12-09-04_1763892587089.gif

简单来说,这个流程就是这样:

  1. 用户:“我想看 Mofei 博客的最新文章。”
  2. ChatGPT:“好的,我这边有可以调用的 Mofei's blog MCP,先用 list-blog-posts(page=1, lang="en") 这个工具查一下。”
  3. Mofei's blog MCP:返回 list-blog-posts 的结果,并告知这些数据可以使用 ui://widget/blog-list.html 模板渲染(也就是名为 "blog-list-widget" 的 MCP Resource 提供的 UI)。
  4. ChatGPT:“明白了,我现在用一个 iframe 把这个 UI 和数据一起嵌到聊天界面里展示给你。”

听起来是不是挺酷的?那接下来问题来了:具体要怎么实现呢?


我的目标:一个完整的博客 ChatGPT App

经过几天的探索和开发,我最终实现了一个功能完整的博客 ChatGPT App:

功能列表:

  • ✅ 浏览博客文章列表(支持分页)
  • ✅ 阅读完整文章(包含 HTML 渲染)
  • ✅ 精美的可视化界面

技术栈:

  • 后端: 基于Node.js的MCP SDK(托管在CloudFlare Workers,以便ChatGPT可以调用)
  • 前端: React 18 + TypeScript + Tailwind CSS v4 (用来显示基础界面)
  • 构建: Vite + vite-plugin-singlefile

效果演示:

Demo

开源项目: 完整代码已开源到 GitHub: 🔗 https://github.com/zmofei/mofei-life-chatgpt-app


这篇文章里有什么?

这篇文章会分享我自己开发的 ChatGPT App 的过程以及学到的东西,供你参考:

  • ChatGPT App 和 MCP 的关系
  • ChatGPT App 的工作流程
  • 如何开发ChatGPT App 的MCP部分
  • 如何开发ChatGPT App 的Widget部分
  • 如何调试 ChatGPT App

所有代码都在 GitHub 上,你可以直接 clone 下来跑,用来学习。

如果你也对 ChatGPT App 开发感兴趣,或者想让 ChatGPT 能读懂你自己的数据,希望这篇文章能帮到你。


ChatGPT App 和 MCP 的关系

在开始写代码之前,我花了不少时间搞清楚 ChatGPT App 和 MCP 到底是什么关系。一开始确实有点绕,但理解了之后就豁然开朗了。

MCP 是什么?

MCP (Model Context Protocol) 是一个标准协议,用来让 AI 模型能够调用外部工具和访问数据。

简单理解,就是:

  • 你有一堆数据(比如博客文章、用户信息等)已经API
  • AI 想要访问这些数据
  • MCP 就是中间的"翻译官",定义了 AI 该怎么请求、你该怎么返回

在我之前的文章 Make Your Website or API AI-Ready with MCP Server 里,详细介绍过如何用 MCP 来暴露 API。当时只用了 MCP 的 Tools 功能,也就是让 AI 能调用我的接口。

ChatGPT App 在 MCP 基础上加了什么?

ChatGPT App 并不是一个全新的东西,它本质上还是基于 MCP 协议,但在此基础上做了几个重要的扩展:

1. 加入了 Resources (Widget 模板)

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

编译工具链:

  • Vite + vite-plugin-singlefile: 把 React 组件、CSS、JavaScript 全部打包成单个 HTML 文件
  • build-loader.mjs: 读取生成的 HTML 文件,转换成 TypeScript 常量

最终生成的 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 -->`
};

这样做的好处是:

  1. 完全独立运行 - 这就是一个普通的 HTML 文件,你可以直接在浏览器打开,不需要任何服务器或依赖
  2. 一个字符串包含所有内容 - 所有 CSS、JavaScript、React 代码全部内联,零外部依赖
  3. MCP Resource 直接返回 - 不需要额外的文件服务器,MCP 直接把这个 HTML 字符串返回给 ChatGPT
  4. iframe 沙箱运行 - ChatGPT 用 iframe 加载这个 HTML,安全隔离

实际的 loader.ts 文件有 400+ KB,因为包含了完整的 React runtime 和所有样式。

💡 调试提示: Widget 可以直接在浏览器中打开调试,手动注入 window.openai 数据模拟 ChatGPT 环境,详见后续"Widget 开发"章节。

2. 扩展了 Tool 的 _meta 字段

在 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/outputTemplatestring (URI)指定用哪个 Widget UI 来渲染工具返回的结果"ui://widget/blog-list.html"
openai/widgetCSPobject定义内容安全策略,包含 connect_domains(可连接的域名) 和 resource_domains(可加载资源的域名){ connect_domains: ["https://api.mofei.life"] }
openai/widgetAccessibleboolean允许 Widget 通过 window.openai.callTool 独立调用工具true
openai/toolInvocation/invokingstring工具执行时显示的加载消息"Loading blog posts..."
openai/toolInvocation/invokedstring工具执行完成后显示的确认消息"Blog posts loaded"

其他可用字段包括 widgetPrefersBorderwidgetDomainwidgetDescriptionlocaleuserAgent 等,完整列表见 OpenAI 官方文档

这些字段可以在两个地方使用:

  • Tool 定义时的 _meta - 定义工具本身的元数据
  • Tool 返回结果的 _meta - 传递给 Widget 的运行时数据

3. 提供了 window.openai API

这是最关键的部分。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日):

数据和状态属性:

属性/方法类型说明
toolInputobject工具被调用时传入的参数,用于读取输入参数
toolOutputobject包含你返回的 structuredContent,AI 模型会直接读取这些字段
toolResponseMetadataobject包含返回的 _meta 数据,只有 Widget 可见,AI 模型看不到
widgetStateobjectUI 状态快照,在渲染之间持久化,让组件保持上下文
setWidgetState(state)function同步存储新的状态快照,在每次有意义的用户交互后调用

Widget 运行时 API:

方法签名说明
callToolcallTool(name: string, args: object): Promise<any>让 Widget 独立调用 MCP 工具。需要在工具的 _meta 中设置 openai/widgetAccessible: true
sendFollowUpMessagesendFollowUpMessage({ prompt: string }): Promise<void>让 Widget 向 ChatGPT 发送消息,触发新的对话
requestDisplayModerequestDisplayMode({ mode: string }): Promise<any>请求画中画或全屏渲染模式
requestModalrequestModal(...): Promise<any>创建 ChatGPT 控制的模态框,用于结账流程等覆盖层
notifyIntrinsicHeightnotifyIntrinsicHeight(...): void报告动态 Widget 高度,防止滚动裁剪
openExternalopenExternal({ href: string }): Promise<void>在用户浏览器中打开经过审核的外部链接

上下文属性:

属性类型说明
theme"light" | "dark"当前主题模式
displayMode"inline" | "pip" | "fullscreen"Widget 显示模式
maxHeightnumberWidget 最大高度(像素)
safeAreaobject安全区域边距
viewstring视图类型
userAgentstring用户代理字符串
localestring语言环境代码(如 "en-US", "zh-CN")

这些 API 可以通过两种方式访问:

  1. 直接访问 - window.openai.toolResponseMetadata
  2. React Hook - useToolResponseMetadata(), useTheme() 等(响应式更新)

三者的关系图

我的理解

用一个生活化的比喻来说:

想象餐厅和中央厨房的关系:

  • MCP 就像是 中央厨房(供应工厂):

    • 提供标准化的菜单(Tools 和 Resources 的定义)
    • 制作各种产品:
      • Tools 提供食物本身(数据内容,AI 可以理解和处理的 JSON)
      • Widget 提供食物的精美包装(完整的 UI 展示,HTML+CSS+JS,通过 Resource 供应)
    • 统一供应给餐厅
  • ChatGPT App 就像是 餐厅:

    • 向中央厨房定制菜品(规定必须是 text/html+skybridge 格式的 Widget)
    • 收到预制菜后:
      • 摆放在餐桌上(iframe 沙箱环境)
      • 提供餐具和服务员(window.openai API,让顾客能点单、互动)
      • 制定菜单标注(_meta 字段,标明菜品特点、适用场景)
    • 最终呈现给顾客(用户)

总结来说:

  • MCP(中央厨房)负责生产,包括制作菜品(Widget HTML)和标准化供应
  • ChatGPT App(餐厅)负责呈现,定制菜品规格、提供用餐环境、服务顾客

所以说,ChatGPT App = 从 MCP 定制内容 + 提供用餐环境和服务


ChatGPT App 的工作流程

理解了 MCP 和 ChatGPT App 的关系后,让我们看看一个完整的请求是如何工作的。我会用我的博客 App 为例,详细拆解整个流程。

完整的交互流程

想象用户在 ChatGPT 中说:"Show me the latest articles from Mofei's blog"

整个流程是这样的:

详细步骤拆解

让我详细解释每个步骤:

1. 用户发起请求

用户在 ChatGPT 对话框输入:"Show me the latest articles from Mofei's blog"

2. ChatGPT 分析并调用 Tool

ChatGPT 分析用户意图,发现有一个 list-blog-posts 工具可以满足需求,于是调用:

// ChatGPT 内部决定调用
list-blog-posts({
  page: 1,
  lang: "en"
})

3. MCP Server 执行并返回三层数据

我的 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"
  }
};

为什么要三层?

  • structuredContent: AI 需要理解数据,但不需要所有细节(图片、样式等)
  • content: 用户在对话中看到的简洁文本
  • _meta: Widget 需要完整数据来渲染精美的 UI

4. ChatGPT 读取 Widget 配置并加载

ChatGPT 看到 Tool 定义中的 _meta:

_meta: {
  "openai/outputTemplate": "ui://widget/blog-list.html"
}

于是去请求 blog-list-widget Resource。

5. MCP 返回 Widget HTML

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": { ... }
    }
  }]
};

6. ChatGPT 加载 Widget

ChatGPT:

  1. 创建一个 iframe 沙箱
  2. 把 HTML 加载到 iframe
  3. 注入 window.openai API
  4. 把 Tool 返回的 _meta 数据注入为 window.openai.toolResponseMetadata

7. Widget 渲染 UI

Widget 的 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。

8. 用户与 Widget 交互

用户点击"下一页"按钮,Widget 调用:

async function handlePageChange(page: number) {
  // Widget 独立调用 Tool
  await window.openai.callTool("list-blog-posts", {
    page: page,
    lang: "en"
  });
}

流程回到步骤 3,ChatGPT 重新调用 MCP,更新数据,Widget 重新渲染。

关键理解

  1. 数据分层传递:

    • AI 读 structuredContent(简洁)
    • 用户看 content(文本)
    • Widget 读 _meta(完整数据)
  2. Widget 是独立的:

    • 在 iframe 中运行,完全隔离
    • 可以独立调用 Tool (通过 window.openai.callTool)
    • 可以发消息给 ChatGPT (通过 sendFollowUpMessage)
  3. MCP 只负责传输:

    • MCP 提供 Tool 和 Resource 机制
    • ChatGPT App 决定如何使用(加载 Widget、注入 API)

如何开发 ChatGPT App 的 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 官方 SDK
  • agents - CloudFlare Workers 的 MCP 辅助库
  • zod - 用于定义和验证工具参数的 Schema
  • wrangler - CloudFlare Workers 的开发和部署工具

MCP 基础结构

创建 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 });
  },
};

关键点:

  • SSE 端点 - ChatGPT 通过 Server-Sent Events 调用 MCP,这是必须的
  • init() 方法 - 在这里注册所有的 Tools 和 Resources

📁 完整代码: src/index.ts

注册第一个 Tool

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 专用的完整数据
      };
    }
  );
}

三层数据结构的关键:

  1. structuredContent - AI 模型读取,要简洁,避免浪费 token
  2. content - 对话框显示的文本
  3. _meta - Widget 独享,可以包含完整数据、图片等,AI 看不到

📁 完整实现: src/index.ts#L83-L144

注册 Widget Resource

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 Workers

部署非常简单:

# 登录 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" }
  });
}

如何开发 ChatGPT App 的 Widget 部分

MCP 提供了数据和功能,但用户看到的精美界面是从哪来的?答案就是 Widget - 一个运行在 ChatGPT iframe 中的自定义 UI 组件。

Widget 的技术选型

我的 Widget 技术栈:

  • React 18 - 熟悉的组件化开发
  • TypeScript - 类型安全
  • Tailwind CSS v4 - 快速样式开发
  • Vite - 快速构建
  • vite-plugin-singlefile - 关键!把所有资源打包成单个 HTML 文件

为什么要打包成单个 HTML 文件?

MCP Resource 返回的是一个 HTML 字符串,不是文件路径。理论上你可以在返回的 HTML 中引用外部的 HTTP 资源(CSS、JS 文件),但这样做需要:

  1. 单独部署一套静态资源服务器
  2. 配置 CORS 跨域访问
  3. 在 Widget 的 widgetCSP 中添加这些资源域名

这无疑增加了部署和维护的复杂度。

相比之下,打包成单个自包含的 HTML 文件有明显优势:

  • 零外部依赖 - 不需要额外的静态资源服务器
  • 部署简单 - 只需要部署一个 MCP Server
  • 加载更快 - 没有额外的 HTTP 请求
  • 更可靠 - 不会因为外部资源加载失败而出问题

这就是 vite-plugin-singlefile 的作用 - 把所有 React 组件、CSS、JavaScript 全部编译并内联到一个 HTML 字符串中。

Widget 项目结构

在项目中创建 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

核心配置就是使用 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

封装 window.openai API

创建 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

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 返回的 _meta
  • 独立翻页 - Widget 直接调 API,更快更流畅
  • 触发 Tool - 通过 sendFollowUpMessage 让 ChatGPT 调用其他 Tool

📁 完整实现: web/src/blog-list/BlogList.tsx

构建 Widget

运行构建命令:

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 文件完全独立,可以直接在浏览器打开运行!

生成 loader.ts

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

本地调试 Widget

方法 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
    }
  }
}));

调试时需要注意:

  1. 完整的 API 属性 - 包含所有 window.openai 属性,避免 Widget 调用时报错
  2. 严格的顺序 - 必须先更新数据,再触发事件。Widget 使用 useSyncExternalStore 监听 openai:set_globals 事件
  3. 独立运行 - Widget 是完全自包含的,可以脱离 ChatGPT 独立调试

方法 2: 本地开发服务器

cd web
npm run dev

会自动打开浏览器,然后同样使用上面的代码注入 window.openai 数据。

Widget 开发最佳实践

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-window
  • 避免不必要的重新渲染 - 用 React.memo

4. 处理错误

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 并测试

如何调试 ChatGPT APP

开发完 MCP 和 Widget 后,最关键的一步就是连接到 ChatGPT 并进行调试。这个过程涉及三个步骤:托管 MCP Server、连接到 ChatGPT、开启调试模式。

步骤 1: 托管 MCP Server

首先需要让 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

步骤 2: 连接 MCP 到 ChatGPT

  1. 开启 Developer Mode

    首先需要开启开发者模式:

    • 在 ChatGPT 界面点击左下角的用户名
    • 选择 "Settings" → "Apps & Connectors" → "Advanced settings"
    • 开启 "Developer mode"
  2. 添加 MCP Server

    • 点击 "Apps & Connectors" → "Create"
    • 在 "MCP Server URL" 字段填入你的 MCP Server 地址:
    # CloudFlare Workers
    https://your-mcp-name.your-username.workers.dev/sse
    
    # 或 ngrok
    https://abc123.ngrok.io/sse
    

    ⚠️ 注意: URL 必须以 /sse 结尾

  3. 验证连接

    点击 "Test connection" 按钮,如果成功会显示:

    • ✅ 发现的 Tools 列表
    • ✅ 发现的 Resources 列表
  4. 保存配置

    连接成功后,点击 "Save" 保存配置

📖 参考文档: Connect to ChatGPT

步骤 3: 测试和调试

基础测试:

在 ChatGPT 中输入测试指令:

Show me the blog posts from Mofei's blog

观察 Debug 面板中的信息:

  1. Tool 被调用 - 确认 list-blog-posts 被调用
  2. 参数正确 - 检查传入的 pagelang 参数
  3. 数据返回 - 查看三层数据结构是否完整
  4. Widget 加载 - 确认 UI 正常渲染

常见调试场景:

场景 1: Tool 没有被调用

可能原因:

  • Tool 的 description 不够清晰,AI 不知道该用它
  • MCP Server 连接失败

解决方法:

// 改进 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 不显示

可能原因:

  • Resource URI 不匹配
  • HTML 中有语法错误
  • CSP 配置限制了资源加载

解决方法:

// 检查 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 数据未注入
  • React 组件报错

解决方法:

// 在 Widget 中添加日志
console.log('[Widget] window.openai:', window.openai);
console.log('[Widget] metadata:', metadata);

// 添加错误边界
if (!metadata) {
  return <div>Loading or no data available...</div>;
}

场景 4: 跨域或资源加载失败

可能原因:

  • CSP 配置不正确
  • 资源域名未添加到白名单

解决方法:

_meta: {
  "openai/widgetCSP": {
    connect_domains: [
      "https://api.mofei.life",  // 添加你要调用的 API 域名
    ],
    resource_domains: [
      "https://static.mofei.life",  // 添加图片、CSS 等资源域名
    ],
  },
}

总结

通过这篇文章,我们完整地走过了开发 ChatGPT App 的全过程 - 从理解概念到实际编码,再到部署调试。

核心要点回顾

1. ChatGPT App = MCP + Widget

  • MCP 提供数据和功能(Tools & Resources)
  • Widget 提供精美的可视化界面
  • ChatGPT 作为平台整合两者,提供 window.openai API

2. 三层数据结构是关键

return {
  structuredContent: { /* AI 读取 */ },
  content: [{ /* 对话框显示 */ }],
  _meta: { /* Widget 独享 */ }
}

这种设计让 AI、用户、Widget 各取所需,既节省 token 又保证体验。

3. 单文件打包简化部署

使用 vite-plugin-singlefile 把 React 组件打包成自包含的 HTML,让部署变得极其简单 - 只需要一个 MCP Server。

4. Debug 模式是开发利器

通过 Developer Mode,可以看到:

  • Tool 调用过程
  • 完整的数据结构
  • Widget 加载细节
  • 错误堆栈信息

开发 ChatGPT App 的价值

开发 ChatGPT App 不仅仅是技术实践,更是让 AI 能够:

  • 📊 访问你的专有数据 - 博客、数据库、内部系统
  • 🎨 提供定制化体验 - 不再局限于纯文本对话
  • 🔧 成为真正的助手 - 调用实际的工具和服务
  • 🚀 无限扩展可能 - 任何能用 API 实现的都可以接入

资源和链接

官方文档:

本文完整代码:

我的博客:

最后的话

ChatGPT App 还是一个新生事物,OpenAI 也在持续改进 API 和功能。但正因为新,才有更多探索的空间和创造的可能。

从好奇心驱动到实际产品,这个过程充满了挑战,但也收获满满:

  • 深入理解了 AI 和外部世界的交互方式
  • 掌握了一套完整的开发和部署流程
  • 看到了 AI 应用的更多可能性

如果这篇文章对你有帮助,欢迎:

  • Star 项目仓库
  • 在评论区分享你的想法
  • 分享给更多感兴趣的朋友

让我们一起探索 AI 应用开发的无限可能!

如果觉得文章有值得讨论的地方,欢迎留言和我交流!

如果有任何疑问或者不同意见,欢迎留言一起讨论!

HI. I AM MOFEI!

NICE TO MEET YOU!

从零构建 ChatGPT App:MCP 集成、自定义 Widget 与真实 API 实战案例 | Mofei的生活博客