概覽
自訂 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 Primary School 的學區內?」 解耦設計就能發揮作用:
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 Apps UI MIME 類型
(text/html;profile=mcp-app),將元件提供為 MCP 資源。如果使用
@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,並更新所有參照該 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 參考文件。