概览
自定义 UI 是可选的。当插件的使用场景需要用户查看、比较、编辑、确认或浏览结构化信息时,再添加 UI。确保 MCP 工具在没有组件时仍然可用,让 ChatGPT 和 Codex 无需 UI 也能完成工作流程。
MCP 服务器为选定的工具返回 UI 资源。组件在 ChatGPT 的 iframe 中运行,
通过 MCP Apps 桥接机制与主机通信
(基于 postMessage 的 JSON-RPC),并在对话旁呈现。
开放的 MCP Apps 标准让 UI 能够在不同的兼容主机上运行。
从 MCP Apps 开始
ChatGPT 实现了开放的 MCP Apps 标准,用于呈现 MCP 服务器返回的 UI。MCP Apps 定义了服务器如何将工具与 UI 资源关联, 以及 iframe 如何与其主机通信。
对于新建的 UI:
- 使用
_meta.ui.resourceUri声明 UI 资源。 - 使用基于
postMessage的ui/*JSON-RPC 桥接机制来处理初始化、 通知、工具调用、消息以及模型可见的上下文。 - 确保工具在没有 UI 时仍然可用,让模型能够在不渲染组件的客户端中完成工作流程。
这种以标准为基础的设计,让同一套 UI 能够在 ChatGPT 和其他兼容的 MCP Apps 主机上运行。
当您准备实现该标准时,请参照 MCP Apps 规范。
在此基础上添加 ChatGPT 扩展程序
在 MCP Apps 流程正常运行后,仅针对共享规范未涵盖的能力使用 window.openai。
这些可选扩展程序可以改善 ChatGPT 中的体验,
而无需将其纳入可移植 UI 的
基础部分。
优先使用共享字段和方法
只要共享规范涵盖了所需能力,就使用 MCP Apps 的字段或方法:
| 目标 | MCP Apps 标准 | ChatGPT 兼容别名 |
|---|---|---|
| 将工具关联到 UI 资源 | _meta.ui.resourceUri | _meta["openai/outputTemplate"] |
| 接收工具输入 | ui/initialize + ui/notifications/tool-input | window.openai.toolInput |
| 接收工具结果 | ui/notifications/tool-result | window.openai.toolOutput |
| 从 UI 调用工具 | tools/call | window.openai.callTool |
| 发送后续消息 | ui/message | window.openai.sendFollowUpMessage |
现有集成仍可使用这些兼容别名。新建的 UI 应使用中间一列中的共享字段和桥接方法。
示例包括:
- 使用
window.openai.requestCheckout实现即时结账。 - 使用
window.openai.uploadFile、window.openai.selectFiles和window.openai.getFileDownloadUrl处理 ChatGPT 文件。 - 使用
window.openai.requestModal实现由主机控制的模态窗口。 - 使用
window.openai.widgetState和window.openai.setWidgetState持久化小组件状态。
检测每个扩展程序是否可用,并在可行时提供回退方案:
const openai = typeof window !== "undefined" ? window.openai : undefined;
if (openai?.requestModal) {
await openai.requestModal({
/* ... */
});
} else {
// Fallback behavior for hosts without this extension.
}
避免根据主机或产品名称选择逻辑分支。请检测您的 UI 所需的能力是否可用。
有关扩展程序的签名和示例,请参阅 window.openai 组件
桥接参考资料。
可选的 OpenAI 组件库
@openai/apps-sdk-ui 组件库
提供与 ChatGPT 容器相匹配的现成按钮、卡片、
输入控件和基础布局元素。
如果您希望保持样式一致,又不想重新构建基础组件,
可以使用该组件库。
您还可以查看 GitHub 上的 UI 示例代码仓库。
选择呈现方式
从内嵌 UI 开始,仅在工作流程需要时请求更多空间。在能够让用户理解结果或完成任务的前提下,选择占用空间最小的呈现方式。
内嵌卡片
使用内嵌卡片呈现单一结果、确认事项或一小组操作。确保卡片内容独立完整,避免多层导航。

内嵌轮播
当用户需要快速浏览一小组相似且视觉内容丰富的选项并做出选择时,使用内嵌轮播。

全屏
对于地图、编辑画布或详细浏览等内容丰富、需要更大空间的任务,使用全屏模式。设计时应考虑与 ChatGPT 编辑器配合使用,因为编辑器在全屏模式下仍然可用。

画中画
对于实时会话、游戏或视频等持续进行的活动,如果需要在对话继续时保持可见,请使用画中画。

有关布局、交互、视觉设计和无障碍的详细指导, 请参阅 UI 指南。
将数据处理与 UI 渲染分离
解耦模式
如果您在每次工具调用中都附加小组件模板,ChatGPT 可能会过于频繁地重新渲染 iframe。更好的模式是将数据处理工具与渲染工具分离:
- 数据工具 负责获取、计算或修改数据,并且只返回工具结果。
- 渲染工具 接收最终数据并返回小组件模板。
这样,模型就可以先运用自身的智能处理获取的数据,再决定是否向用户呈现 UI,从而大幅提高实现用户所表达的具体目标的可能性。
这种模式是 MCP Apps 架构的一部分。
在实践中,许多 UI 集成采用以下划分方式:
- 搜索/获取工具(以数据为先): 返回 ID 和元数据, 不附加小组件模板。
- 渲染工具(例如
render_listings_widget): 接收准备好的 ID 列表, 并渲染小组件。
只有渲染工具才应包含 _meta.ui.resourceUri。
解耦后的调用流程
推荐的调用流程:
- 模型调用数据工具(例如
roll_dice)。 - 模型从数据工具接收
structuredContent。 - 模型使用这些数据调用渲染工具。
- 小组件使用经过模型检查的最终上下文,只渲染一次。
示例:房产后续查询
假设您的插件展示房源卡片和地图,但服务端的 search 工具
仅支持宽泛的筛选条件(城市、价格、卧室数、浴室数),无法按
学区筛选。
如果用户问:“这些房源中,哪些位于 Richmond 小学的学区内?” 解耦就能发挥作用:
search进行宽泛搜索,返回候选房源的 ID 和元数据。- 模型根据后续问题进一步筛选这些候选房源。
- 模型调用
render_listings_widget,仅传入筛选后的 ID。 - 小组件渲染最终筛选出的房源。
最佳实践:
- 确保数据工具可以复用。返回完整的
structuredContent,以便链式调用。 - 让渲染工具专注于展示。不要将业务逻辑混入渲染处理函数。
- 在渲染工具的描述中说明依赖关系(例如,“始终
先调用
roll_dice”)。 - 仅在明确需要时重新运行。对于“重新掷骰子”等局部交互,让 UI 直接调用数据工具,无需重新挂载小组件。
解耦示例
示例(解耦后的骰子工具):
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod/v3";
const TEMPLATE_URI = "ui://widget/dice.html";
const server = new McpServer(
{ name: "Decoupled dice", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// The widget only renders the latest tool result.
// Re-roll calls the data tool directly to avoid remounting the widget.
const widgetHtml = `
<div style="font-family: system-ui; padding: 8px;">
<div style="font-size: 20px; margin-bottom: 6px;">
Result: <span id="out">—</span>
</div>
<button id="reroll">Re-roll</button>
</div>
<script>
const outputEl = document.getElementById("out");
const rerollButton = document.getElementById("reroll");
const pendingRequests = new Map();
let nextRequestId = 1;
let latestToolInput;
let latestToolOutput;
function render(result) {
outputEl.textContent = String(result?.value ?? "—");
}
function request(method, params) {
const id = nextRequestId++;
window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*");
return new Promise((resolve, reject) => {
pendingRequests.set(id, { resolve, reject });
});
}
window.addEventListener(
"message",
(event) => {
if (event.source !== window.parent) return;
const message = event.data;
if (!message || message.jsonrpc !== "2.0") return;
if (message.id !== undefined && pendingRequests.has(message.id)) {
const pending = pendingRequests.get(message.id);
pendingRequests.delete(message.id);
if (message.error) pending.reject(message.error);
else pending.resolve(message.result);
return;
}
if (message.method === "ui/notifications/tool-input") {
latestToolInput = message.params;
}
if (message.method === "ui/notifications/tool-result") {
latestToolOutput = message.params?.structuredContent;
render(latestToolOutput);
}
},
{ passive: true }
);
rerollButton.onclick = async () => {
const sides = latestToolOutput?.sides ?? latestToolInput?.sides ?? 6;
const next = await request("tools/call", {
name: "roll_dice",
arguments: { sides },
});
if (next?.structuredContent) {
render(next.structuredContent);
}
};
</script>
`.trim();
server.registerResource("dice-widget", TEMPLATE_URI, {}, async () => ({
contents: [
{
uri: TEMPLATE_URI,
mimeType: "text/html;profile=mcp-app",
text: widgetHtml,
_meta: { ui: { prefersBorder: true } },
},
],
}));
// 1) Data tool: no output template, returns chainable structuredContent.
server.registerTool(
"roll_dice",
{
title: "Roll dice",
description: "Roll an N-sided die and return { sides, value }.",
inputSchema: { sides: z.number().int().min(2) },
outputSchema: {
sides: z.number().int().min(2),
value: z.number().int().min(1),
},
_meta: {
"openai/toolInvocation/invoking": "Rolling…",
"openai/toolInvocation/invoked": "Rolled.",
},
},
async ({ sides }) => {
const value = 1 + Math.floor(Math.random() * sides);
return {
structuredContent: { sides, value },
content: [{ type: "text", text: `Rolled ${value} on ${sides} sides.` }],
};
}
);
// 2) Render tool: owns the template and requires data from roll_dice.
server.registerTool(
"render_dice_widget",
{
title: "Render dice widget",
description:
"Render the dice widget from roll data. First call roll_dice, then pass its sides and value to this tool.",
inputSchema: {
sides: z.number().int().min(2),
value: z.number().int().min(1),
},
outputSchema: {
sides: z.number().int().min(2),
value: z.number().int().min(1),
},
_meta: {
ui: { resourceUri: TEMPLATE_URI },
"openai/toolInvocation/invoking": "Rendering…",
"openai/toolInvocation/invoked": "Rendered.",
},
},
async ({ sides, value }) => ({
structuredContent: { sides, value },
content: [
{
type: "text",
text: `Showing a ${sides}-sided roll: ${value}.`,
},
],
})
);
export default server;
管理状态
MCP 服务器提供的 UI 涉及三类状态:
| 状态类型 | 管理方 | 生命周期 | 示例 |
|---|---|---|---|
| 业务数据(权威数据) | MCP 服务器或外部服务 | 长期保留 | 任务、工单、文档 |
| UI 状态(临时) | UI 实例 | UI 实例处于活动状态期间 | 选中的行、展开的面板、排序顺序 |
| 跨会话状态(持久) | 由您控制的存储 | 跨会话、跨对话 | 已保存的筛选条件、视图模式、工作空间 |
将每个值保存在负责管理它的系统中。UI 应渲染工具结果中的权威数据,并在此基础上叠加临时的展示状态。
MCP server or external service
│
├── Authoritative business data
│
▼
UI
│
├── Ephemeral presentation state
│
└── Rendered view = business data + UI state
将业务数据保存在服务器上
业务数据是权威依据,不要仅将其保存在 UI 中。当用户执行操作时:
- UI 调用 MCP 工具。
- 服务器验证请求并更新数据。
- 服务器返回更新后的权威快照。
- UI 渲染该快照,同时保留与之兼容的展示状态。
返回足够的结构化内容,让模型和 UI 都能理解新状态。这样,即使 UI 无法加载,对话仍能提供有用的信息。
将临时 UI 状态保存在 UI 中
对于仅影响展示的值,例如选中的项目、打开的面板或尚未应用的筛选条件,请使用框架的状态机制。每个渲染出的 UI 实例都有自己的状态。
当模型需要了解某个选择或暂存的编辑时,请
通过 ui/update-model-context 发送这些信息。这是 MCP Apps 提供的可移植机制,
用于更新模型可见的上下文。
ChatGPT 还提供可选的小组件级持久化功能:
- 从
window.openai.widgetState读取当前快照。 - 使用
window.openai.setWidgetState(state)写入新快照。
setWidgetState 是同步方法。每次 UI 状态发生有意义的变化后都应调用它;
无需使用 await 等待。
import { useState } from "react";
export function TaskList({ tasks }) {
const [state, setState] = useState(
window.openai?.widgetState ?? { selectedId: null }
);
function selectTask(selectedId) {
const nextState = { ...state, selectedId };
setState(nextState);
window.openai?.setWidgetState?.(nextState);
}
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>
<button
type="button"
aria-pressed={state.selectedId === task.id}
onClick={() => selectTask(task.id)}
>
{task.title}
</button>
</li>
))}
</ul>
);
}
小组件状态属于单个渲染出的 UI 实例。不要将其用作业务数据的权威依据,也不要将其用作持久存储。
让模型能够看到图像
对于需要处理图像的 UI,请使用以下结构化的小组件状态格式:
modelContent:模型应看到的文本或 JSON。privateContent:仅供 UI 使用、模型不应看到的状态。imageIds:模型应在后续轮次中接收的文件 ID。
window.openai.setWidgetState({
modelContent: "Review the currently selected images.",
privateContent: {
currentView: "image-viewer",
filters: ["crop", "sharpen"],
},
imageIds: ["file_123", "file_456"],
});
仅包含通过 window.openai.uploadFile 上传、通过
window.openai.selectFiles 选择、通过工具输入中的文件参数接收,或
通过工具结果中的文件引用返回的文件 ID。
将跨会话状态存储在您的服务器上
将需要跨对话、设备或会话保留的偏好设置和数据保存在您控制的存储中。对用户进行身份验证,以便 MCP 服务器将每个请求关联到正确的账户。
添加持久存储时:
- 将延迟控制在足够低的水平,以满足交互式 UI 的需求。
- 通过服务器端授权保护私有数据。
- 提前规划数据驻留和合规要求。
- 对重试或并发 UI 实例产生的流量实施速率限制。
- 为存储的对象设置版本,以便在不影响现有对话的情况下迁移对象。
避免使用 localStorage 存储核心状态。UI 在隔离的 iframe 中运行,
浏览器存储无法提供可靠的跨设备或跨会话数据层。
搭建组件项目骨架
了解 MCP Apps 桥接机制(以及可选的 ChatGPT 扩展)后,就可以开始搭建组件项目骨架了。
最佳实践是将组件代码与服务器逻辑分开。常见的目录结构如下:
plugin-ui/
server/ # MCP server (Python or Node)
web/ # Component bundle source
package.json
tsconfig.json
src/component.tsx
dist/component.js # Build output
创建项目并安装依赖项(建议使用 Node 18+):
cd plugin-ui/web
npm init -y
npm install react@^18 react-dom@^18
npm install -D typescript esbuild
如果您的组件需要拖放、图表或其他库,请现在添加。尽量精简依赖项,以减小打包体积。
编写 React 组件
入口文件应将组件挂载到 root 元素中,并根据
通过 MCP Apps 桥接机制传入的最新工具结果进行渲染(例如,
ui/notifications/tool-result)。
示例页面提供了 UI 示例,例如 Pizzaz 的 披萨餐厅列表。
探索 Pizzaz 组件库
UI 示例包含多个示例组件。构建您自己的 UI 时,可以将它们作为参考:
- Pizzaz 列表: 带有收藏功能和操作按钮的排名卡片列表。

- Pizzaz 轮播: 基于 Embla 的水平滚动组件,展示以媒体内容为主的布局。

- Pizzaz 地图: 集成 Mapbox,支持全屏详情查看和宿主状态同步。

- Pizzaz 相册: 堆叠式图库视图,便于深入了解单个地点。

- Pizzaz 视频: 通过脚本控制的播放器,提供叠加层和全屏控件。
每个示例都展示了如何打包资源、接入宿主 API,以及为实际对话组织状态。复制最接近您使用场景的示例,并调整数据层以适配您的工具响应。
React 辅助钩子
用于订阅 ui/notifications/tool-result 的简短辅助函数:
type ToolResult = { structuredContent?: unknown } | null;
export function useToolResult() {
const [toolResult, setToolResult] = useState<ToolResult>(null);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.source !== window.parent) return;
const message = event.data;
if (!message || message.jsonrpc !== "2.0") return;
if (message.method !== "ui/notifications/tool-result") return;
setToolResult(message.params ?? null);
};
window.addEventListener("message", onMessage, { passive: true });
return () => window.removeEventListener("message", onMessage);
}, []);
return toolResult;
}
根据 toolResult?.structuredContent 进行渲染,并将其视为不可信输入。
小组件本地化
宿主会将语言区域设置同步到 document.documentElement.lang。根据该设置
加载翻译并格式化日期和数字。以下是使用
react-intl 的常见方式:
import { IntlProvider } from "react-intl";
import en from "./locales/en-US.json";
import es from "./locales/es-ES.json";
const messages: Record<string, Record<string, string>> = {
"en-US": en,
"es-ES": es,
};
export function PluginUI() {
const locale = document.documentElement.lang || "en-US";
return (
<IntlProvider
locale={locale}
messages={messages[locale] ?? messages["en-US"]}
>
{/* Render UI with <FormattedMessage> or useIntl() */}
</IntlProvider>
);
}
为 iframe 打包
编写完 React 组件后,您可以将其构建为单个 JavaScript 模块,供服务器内联嵌入:
// package.json
{
"scripts": {
"build": "esbuild src/component.tsx --bundle --format=esm --outfile=dist/component.js"
}
}
运行 npm run build 以生成 dist/component.js。如果 esbuild 报告缺少依赖项,请确认您已在 web/ 目录中运行 npm install,且导入名称与已安装的包名一致(例如,注意区分 @react-dnd/html5-server-side 和 react-dnd-html5-server-side)。
将组件嵌入服务器响应
将组件作为 MCP 资源提供,使用 MCP Apps UI MIME 类型
(text/html;profile=mcp-app)。如果您使用
@modelcontextprotocol/ext-apps/server,请优先使用 RESOURCE_MIME_TYPE,
而不是直接嵌入字符串:
import {
registerAppResource,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { readFileSync } from "node:fs";
const component = readFileSync("web/dist/component.js", "utf8");
registerAppResource(
server,
"project-board",
"ui://project-board/v1.html",
{},
async () => ({
contents: [
{
uri: "ui://project-board/v1.html",
mimeType: RESOURCE_MIME_TYPE,
text: `<div id="root"></div><script type="module">${component}</script>`,
_meta: {
ui: {
prefersBorder: true,
domain: "https://example.com",
csp: {
connectDomains: ["https://api.example.com"],
resourceDomains: ["https://static.example.com"],
},
},
},
},
],
})
);
仅将资源 URI 关联到应渲染该组件的工具。
为获得更广泛的 MCP Apps 兼容性,请使用 _meta.ui.resourceUri。
ChatGPT 也支持将 _meta["openai/outputTemplate"] 用作兼容性别名。
将资源 URI 视为缓存键。当您对 HTML、JavaScript 或 CSS 进行不兼容的更改时,请发布新的 URI,并更新所有引用它的工具。
内容安全策略(CSP)
准确声明组件连接到的域名或加载资源所用的域名:
connectDomains用于 API 请求。resourceDomains用于脚本、样式、图像和其他资源。- 仅当组件必须嵌入特定来源的 iframe 时,才使用
frameDomains。
默认禁止嵌套框架。请尽可能缩小每个允许列表的范围。插件审查流程会检查声明的策略是否与 UI 行为一致。
您可以嵌入与您的 MCP 服务器位于同一
可注册域名下的现有编辑器或管理界面。例如,位于 https://api.example.com/mcp 的服务器可以
在 frameDomains 中声明 https://app.example.com。请在提交时提供所需的
理由说明,并遵守
iframe 政策,包括
其中对共享托管的限制和审查要求。
建议在生产环境中使用组件 UI 模板。
在开发期间,每当 React 代码发生更改时,您都可以重新构建组件包并热重载服务器。
在 UI 中提供结账功能
如果您希望用户能够通过插件的 UI 流程结账,请使用组件在用户确认之前展示商品、价格、条款和支付选项。确保底层商品目录和订单工具在没有 UI 的情况下仍然可用,然后选择外部结账流程,或在可用时选择嵌入式支付选项。
默认使用外部结账
外部结账是推荐且普遍可用的方式。在组件中提供链接,指向您自有域名上由商家托管的结账流程,并在那里处理以下事项:
- 定价和收款。
- 税费、折扣和手续费。
- 配送和订单履约。
- 退款、支持和合规。
目前获批范围仅限于用于购买实物商品的插件。除非 OpenAI 已明确为您的插件启用其他商业类别,否则请勿提供这些类别。
使用已保存的支付方式
对于符合条件的实物商品购买,可选 UI 可以让客户选择之前在您的服务中保存的支付方式。此流程可以显示符合条件的已保存支付方式,但不能收集新的支付凭据。您的 MCP 服务器负责处理购买,并返回权威订单结果。
使用 ChatGPT 支付面板
通过 ChatGPT 支付面板进行的嵌入式结账目前仅面向部分市场进行封闭测试,尚未向所有开发者或用户开放。
对于已启用此功能的集成,window.openai.requestCheckout 会打开
ChatGPT 支付面板:
const order = await window.openai.requestCheckout(checkoutSession);
结账流程分为四个部分:
- MCP 工具在
structuredContent中返回结账会话。 - 组件显示明细项、总金额、条款和履约选项。
- 用户选择付款后,
组件调用
requestCheckout(checkoutSession)。 - ChatGPT 将所选支付 Token 发送给 MCP 服务器的
complete_checkout工具,由该工具通过相应支付方式扣款,并返回 已完成的订单。
结账会话必须包含:
- 唯一的会话 ID。
- 明细项和数量。
- 以最小货币单位的整数表示的总金额。
- 支付服务提供商和商家的元数据。
- 必需的法律、隐私、退款和支持链接。
价格和订单状态应以服务器为准。验证支付 Token,确保操作具有幂等性,持久化存储订单,并返回权威收据。切勿信任仅在组件中计算的总金额。
使用 payment_mode: "test" 测试端到端流程,无需动用真实资金。
在组件中处理取消、付款被拒以及
支付服务提供商错误。
有关完整的结账会话字段、支付服务提供商的行为、
complete_checkout 结果结构和委托支付要求,请参阅
结账 API 参考。