概述
UI 组件将来自 MCP 服务器的结构化工具结果转换为用户友好的界面。您的组件在 ChatGPT 的 iframe 中运行,通过 MCP Apps 桥接器(基于 postMessage 的 JSON-RPC)与宿主通信,并与对话内容内联渲染。
这是专为 ChatGPT 应用构建的 UI 架构,后来被标准化为 MCP Apps,因此您可以“一次构建,到处运行”,在所有兼容 MCP Apps 的宿主上运行您的 UI。
ChatGPT 继续支持 window.openai,以实现 Apps SDK 兼容性和可选的 ChatGPT 扩展功能。
您还可以查看 GitHub 上的 示例仓库。
组件库
使用 apps-sdk-ui 中的可选 UI 工具包,获取现成的按钮、卡片、输入控件和布局原语,它们与 ChatGPT 的容器风格高度一致。如果您希望保持样式统一且不想从头构建基础组件,这将节省大量时间。
使用 MCP Apps 桥接器(推荐)
ChatGPT 实现了开放的 MCP Apps 应用接口标准。对于新应用,请默认使用该桥接器。
- 传输方式:基于
postMessage的 JSON-RPC 2.0。 - 工具 I/O:
ui/notifications/tool-input和ui/notifications/tool-result。 - 工具调用:
tools/call。 - 消息传递 + 上下文:
ui/message和ui/update-model-context。
有关高级概述以及从 Apps SDK API 迁移的指南,请参阅 ChatGPT 中的 MCP Apps 兼容性。
接收工具输入和结果
ChatGPT 将工具输入和结果作为 JSON-RPC 通知发送到您的 iframe 中。例如,工具结果以 ui/notifications/tool-result 的形式到达。
{
"jsonrpc": "2.0",
"method": "ui/notifications/tool-result",
"params": {
"content": [],
"structuredContent": { "tasks": [] }
}
}
监听通知并根据 structuredContent 进行重渲染。
window.addEventListener(
"message",
(event) => {
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;
const toolResult = message.params;
const data = toolResult?.structuredContent;
// Update UI from `data`.
},
{ passive: true }
);
从 UI 调用工具
要直接从 UI 调用工具,请发送 tools/call 的 JSON-RPC 请求。确保该工具在其描述符中对 UI(应用)可用。默认情况下,工具对模型和 UI 均可用;如有必要,请使用 _meta.ui.visibility 进行限制。
有关使用 postMessage 的最小请求/响应实现,请参阅快速入门:快速入门。
发送后续消息
使用 ui/message 请求宿主发布消息。
window.parent.postMessage(
{
jsonrpc: "2.0",
method: "ui/message",
params: {
role: "user",
content: [
{ type: "text", text: "Draft a tasting itinerary for my picks." },
],
},
},
"*"
);
更新模型可见的上下文
当 UI 状态发生改变且需要被模型感知时,请调用 ui/update-model-context。
// Requires a JSON-RPC request/response helper.
await rpcRequest("ui/update-model-context", {
content: [{ type: "text", text: "User selected 3 items." }],
});
将数据处理与 UI 渲染分离
解耦模式
如果您在每个工具调用中都附加一个 Widget 模板,ChatGPT 可能会频繁地重渲染您的 iframe。一种更好的模式是将数据处理工具与渲染工具分离开来。
- 数据工具:用于获取、计算或修改数据,并仅返回工具结果。
- 渲染工具:接收最终数据并返回 Widget 模板。
这使得模型能够在决定向用户展示 UI 之前,利用其智能对获取的数据进行处理,从而更有可能达成用户表达的特定目标。
当前的 Apps SDK 设计已对此提供支持。
实际上,许多应用都采用了这种拆分方式:
- 搜索/获取工具(数据优先): 返回 ID 和元数据,不附加 Widget 模板。
- 渲染工具(例如
render_listings_widget): 接收准备好的 ID 列表并渲染 Widget。
在 ChatGPT 中,只有渲染工具应该包含 _meta["openai/outputTemplate"]。为了实现更广泛的 MCP Apps 兼容性,也请在渲染工具上设置 _meta.ui.resourceUri。
解耦调用流程
推荐的调用流程
- 模型调用数据工具(例如
roll_dice)。 - 模型接收来自数据工具的
structuredContent。 - 模型使用该数据调用渲染工具。
- Widget 使用最终的、经模型核实的上下文渲染一次。
示例:房地产后续查询
假设您的应用展示房源卡片和地图,但您的后端 search 工具仅支持广泛的筛选(城市、价格、卧室、浴室),无法按学区筛选。
如果用户问:“这些房源中哪些在里士满小学学区内?”,解耦模式会有所帮助:
search进行广泛搜索,并返回候选房源 ID 和元数据。- 模型针对后续问题优化候选集。
- 模型仅使用筛选后的 ID 调用
render_listings_widget。 - Widget 渲染最终筛选后的结果集。
最佳实践
- 保持数据工具可复用。返回完整的
structuredContent以便进行链式调用。 - 保持渲染工具专注于展示。不要将业务逻辑混入渲染处理器中。
- 在渲染工具描述中声明依赖项(例如,“始终先调用
roll_dice”)。 - 有意识地触发重渲染。允许 UI 直接调用数据工具来处理本地交互(如“重新投掷”),而无需卸载并重新挂载 Widget。
解耦示例
示例(解耦的骰子工具)
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 toolOutput.
// 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");
function render(result) {
outputEl.textContent = String(result?.value ?? "—");
}
render(window.openai?.toolOutput);
rerollButton.onclick = async () => {
const current = window.openai?.toolOutput;
const sides = current?.sides ?? window.openai?.toolInput?.sides ?? 6;
const next = await window.openai?.callTool?.("roll_dice", { sides });
if (next?.structuredContent) {
render(next.structuredContent);
}
};
window.addEventListener(
"openai:set_globals",
(event) => {
render(event.detail?.globals?.toolOutput ?? window.openai?.toolOutput);
},
{ passive: true }
);
</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/outputTemplate": 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;
理解 window.openai API
ChatGPT 提供 window.openai 作为 Apps SDK 兼容层和一些仅限 ChatGPT 使用的功能。OpenAI 扩展是可选的——当它们在 ChatGPT 中能提供实质性价值时使用,但不要依赖它们来实现基础的 MCP Apps 兼容性。
有关完整 API 参考,请参阅 Apps SDK 参考。
useOpenAiGlobal 辅助函数
许多 Apps SDK 项目将 window.openai 的访问包装在小的辅助函数中,以保持视图的可测试性。此示例辅助函数监听宿主的 openai:set_globals 事件,并允许 React 组件订阅单个全局值。
export function useOpenAiGlobal<K extends keyof WebplusGlobals>(
key: K
): WebplusGlobals[K] {
return useSyncExternalStore(
(onChange) => {
const handleSetGlobal = (event: SetGlobalsEvent) => {
const value = event.detail.globals[key];
if (value === undefined) {
return;
}
onChange();
};
window.addEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal, {
passive: true,
});
return () => {
window.removeEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal);
};
},
() => window.openai[key]
);
}
从 Widget 上传文件(ChatGPT 扩展)
使用 window.openai.uploadFile(file, { library?: boolean }) 上传用户选择的文件并接收 fileId。如果希望上传的文件同时保存到用户的 ChatGPT 文件库中(如果当前用户可用),请传递 { library: true }。
function FileUploadInput() {
return (
<input
type="file"
onChange={async (event) => {
const file = event.currentTarget.files?.[0];
if (!file || !window.openai?.uploadFile) {
return;
}
const { fileId } = await window.openai.uploadFile(file, {
library: true,
});
console.log("Uploaded fileId:", fileId);
}}
/>
);
}
复用 ChatGPT 文件库中的文件(ChatGPT 扩展)
当用户需要选择已上传至 ChatGPT 的文件而不是重新上传时,请使用 window.openai.selectFiles()。由于并非所有用户或环境都可用 ChatGPT 文件库,因此在依赖此功能前请进行特性检测。返回的文件 ID 已获得当前应用的授权。
async function pickExistingFiles() {
if (!window.openai?.selectFiles) {
return [];
}
const files = await window.openai.selectFiles();
console.log(files);
// [{ fileId, fileName, mimeType }]
return files;
}
对 window.openai.selectFiles 进行特性检测,若当前环境或用户无法访问文件库选择器,则回退至 window.openai.uploadFile。
在 Widget 中下载文件(ChatGPT 扩展)
使用 window.openai.getFileDownloadUrl({ fileId }) 获取临时 URL,用于下载 Widget 上传的、从文件库选择的、通过工具输入参数接收的或从工具结果文件引用接收的文件。
const { downloadUrl } = await window.openai.getFileDownloadUrl({ fileId });
imageElement.src = downloadUrl;
工具文件引用使用蛇形命名法(snake case)字段。
{
"download_url": "https://...",
"file_id": "file_...",
"mime_type": "image/png",
"file_name": "input.png"
}
在调用 window.openai.getFileDownloadUrl({ fileId }) 时,使用对象中的 file_id 作为 fileId。download_url 是临时的,仅应在当前操作中使用。
关闭 Widget(ChatGPT 扩展)
您可以通过两种方式关闭 Widget:从 UI 调用 window.openai.requestClose(),或者在服务器端通过工具响应设置 metadata.openai/closeWidget: true,这会指示宿主在收到该响应时隐藏 Widget。
{
"role": "tool",
"tool_call_id": "abc123",
"content": "...",
"metadata": {
"_meta": {
"ui": {
"csp": {
"connectDomains": ["https://api.myapp.example.com"],
"resourceDomains": ["https://persistent.oaistatic.com"],
"frameDomains": ["https://widgets.example.com"]
}
}
},
"openai/closeWidget": true,
"openai/widgetCSP": {
"redirect_domains": ["https://checkout.example.com"]
},
"openai/widgetDomain": "https://myapp.example.com"
}
}
注意:默认情况下,Widget 不允许渲染子框架(subframes)。设置 _meta.ui.csp.frameDomains 可以放宽此限制,允许您的 Widget 嵌入来自这些源的 iframe。使用 iframe 嵌入的应用会面临更严格的审查,除非 iframe 内容是核心用例,否则往往无法通过广泛分发的审核。
如果您希望 window.openai.openExternal 将用户跳转到外部流程(如结账)并启用返回同一对话的链接,请将目标来源添加到 openai/widgetCSP 下的 redirect_domains 中。ChatGPT 将跳过安全链接确认模态,并在目标 URL 后追加 redirectUrl 查询参数,以便您引导用户返回 ChatGPT。
Widget 会话 ID
宿主在工具响应元数据中包含一个每个 Widget 唯一的标识符,即 openai/widgetSessionId。在 Widget 保持挂载期间,请使用它来关联同一 Widget 实例的工具调用或日志。
请求替代布局(ChatGPT 扩展)
如果 UI 需要更多空间(如地图、表格或嵌入式编辑器),请要求宿主更改容器。window.openai.requestDisplayMode 可协商内联、画中画 (PiP) 或全屏显示。
await window.openai?.requestDisplayMode({ mode: "fullscreen" });
// Note: on mobile, PiP may be coerced to fullscreen
打开模态窗口(ChatGPT 扩展)
使用 window.openai.requestModal 打开宿主控制的模态窗口。您可以通过提供在 MCP 服务器上用 registerResource 注册的模板 URI 来从同一应用传递不同的 UI 模板,或者省略 template 以打开当前模板。
await window.openai.requestModal({
template: "ui://widget/checkout.html",
});
使用宿主支持的导航
Skybridge(沙盒运行时)将 iframe 的历史记录镜像到 ChatGPT 的 UI 中。使用标准的路由 API(如 React Router),宿主将保持导航控件与您的组件同步。
路由器设置(React Router 的 BrowserRouter)
export default function PizzaListRouter() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<PizzaListApp />}>
<Route path="place/:placeId" element={<PizzaListApp />} />
</Route>
</Routes>
</BrowserRouter>
);
}
编程式导航
const navigate = useNavigate();
function openDetails(placeId: string) {
navigate(`place/${placeId}`, { replace: false });
}
function closeDetails() {
navigate("..", { replace: true });
}
搭建组件项目脚手架
现在您已经理解了 MCP Apps 桥接器(以及可选的 ChatGPT 扩展),是时候搭建您的组件项目脚手架了。
作为最佳实践,请将组件代码与服务器逻辑分开。常见结构如下:
app/
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 app/web
npm init -y
npm install react@^18 react-dom@^18
npm install -D typescript esbuild
如果您的组件需要拖放、图表或其他库,请立即添加它们。保持依赖集合精简以减小包体积。
编写 React 组件
您的入口文件应将组件挂载到 root 元素中,并根据通过 MCP Apps 桥接器传递的最新工具结果(例如 ui/notifications/tool-result)进行渲染。
示例页面包含示例应用,例如列出比萨餐厅的“Pizza list”应用。
探索 Pizzaz 组件库
Apps SDK 示例包含组件示例。在构建自己的 UI 时,将它们作为蓝图参考。
- Pizzaz List: 带有收藏和行动号召按钮的排名卡片列表。

- Pizzaz Carousel: 由 Embla 驱动的水平滚动器,演示了重媒体布局。

- Pizzaz Map: Mapbox 集成,带有全屏检查器和宿主状态同步。

- Pizzaz Album: 为深入了解单个地点而构建的堆叠式画廊视图。

- Pizzaz Video: 带有叠加层和全屏控制的脚本化播放器。
每个示例都展示了如何打包资源、关联宿主 API 以及为真实对话构建状态。复制最接近您用例的示例,并针对您的工具响应适配数据层。
React 辅助 Hook
一个用于订阅 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 渲染,并将其视为不可信输入。
Widget 本地化
宿主将区域设置镜像到 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 App() {
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-backend 对比 react-dnd-html5-backend)。
在服务器响应中嵌入组件
请参阅设置您的服务器文档,了解如何将组件嵌入 MCP 服务器响应中。
组件 UI 模板是生产环境的推荐路径。
开发过程中,您可以在 React 代码更改时重新构建组件包并热重载服务器。