개요
사용자 정의 UI는 선택 사항입니다. 플러그인 사용자가 구조화된 정보를 살펴보거나 비교, 편집, 확인, 탐색해야 할 때 UI를 추가하세요. ChatGPT와 Codex가 UI 없이도 워크플로우를 완료할 수 있도록 MCP 도구는 컴포넌트 없이도 사용할 수 있게 설계하세요.
MCP 서버는 지정된 도구에 대해 UI 리소스를 반환합니다. 컴포넌트는 ChatGPT의 iframe 안에서 실행되며, MCP Apps 브리지(postMessage를 통한 JSON-RPC)로 호스트와 통신하고 대화와 함께 표시됩니다. MCP Apps는 개방형 표준이므로 호환되는 여러 호스트에서 같은 UI를 실행할 수 있습니다.
MCP Apps로 시작하기
ChatGPT는 MCP 서버가 반환하는 UI를 지원하기 위해 개방형 MCP Apps 표준을 구현합니다. MCP Apps는 서버가 도구와 UI 리소스를 연결하는 방식과 iframe이 호스트와 통신하는 방식을 정의합니다.
새 UI를 만들 때는 다음 단계를 따르세요.
_meta.ui.resourceUri로 UI 리소스를 선언하세요.- 초기화, 알림, 도구 호출, 메시지 전송, 모델에 제공할 컨텍스트 전달에는
ui/*JSON-RPC 브리지를 사용하세요. 이 브리지는postMessage로 통신합니다. - 컴포넌트를 렌더링하지 않는 클라이언트에서도 모델이 워크플로우를 완료할 수 있도록 도구를 UI 없이도 사용할 수 있게 만드세요.
이 표준을 기반으로 만들면 ChatGPT와 다른 호환 MCP Apps 호스트에서 같은 UI를 실행할 수 있습니다.
표준을 구현할 때는 MCP Apps 사양을 참고하세요.
사이드바 앱, 파일 뷰어, 고급 입력 폼에 대해서는 확장 프로그램을 참고하세요.
ChatGPT 확장 기능 추가하기
MCP Apps 플로우가 정상적으로 작동하면 공통 사양에서 다루지 않는 기능에만 window.openai를 사용하세요. 이러한 선택적 확장 기능을 활용하면 여러 호스트에서 사용하는 공통 UI가 해당 기능에 의존하지 않으면서도 ChatGPT에서의 사용 경험을 개선할 수 있습니다.
공통 필드와 메서드 우선 사용하기
공통 사양에서 지원하는 기능에는 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에는 가운데 열의 공통 필드와 브리지 메서드를 사용하세요.
ChatGPT 확장 기능의 예는 다음과 같습니다.
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({
template: "ui://widget/details.html",
});
} else {
document.body.textContent = "This host does not support modals.";
}호스트나 제품 이름을 기준으로 분기하지 마세요. UI에 필요한 기능을 사용할 수 있는지 직접 확인하세요.
확장 기능의 시그니처와 예제는 window.openai 컴포넌트 브리지 레퍼런스를 참고하세요.
선택 사항인 OpenAI 컴포넌트 라이브러리
@openai/apps-sdk-ui 컴포넌트 라이브러리는 ChatGPT 컨테이너에 어울리는 버튼, 카드, 입력 컨트롤, 기본 레이아웃 요소를 제공합니다. 기본 컴포넌트를 다시 만들지 않고 일관된 스타일을 적용하려면 이 라이브러리를 사용하세요.
GitHub의 UI 예제 리포지터리도 살펴볼 수 있습니다.
UI 표시 방식 선택하기
먼저 인라인 UI를 사용하고 워크플로우에 필요한 경우에만 더 넓은 공간을 요청하세요. 사용자가 결과를 이해하거나 작업을 완료하는 데 충분하면서 공간을 가장 적게 차지하는 표시 방식을 선택하세요.
인라인 카드
하나의 결과를 보여 주거나 확인을 받거나 몇 가지 동작을 제공할 때는 인라인 카드를 사용하세요. 카드 안에서 작업을 마칠 수 있게 하고 여러 단계로 이동하는 구조는 피하세요.

인라인 캐러셀
이미지 등 시각적 요소가 풍부한 유사 항목 몇 개를 사용자가 훑어보고 선택해야 할 때는 인라인 캐러셀을 사용하세요.

전체 화면
지도 사용, 캔버스 편집, 상세 정보 탐색처럼 넓은 공간이 필요한 작업에는 전체 화면을 사용하세요. 전체 화면에서도 ChatGPT의 메시지 입력창을 계속 사용할 수 있으므로 사용자가 UI와 입력창을 함께 사용할 수 있도록 설계하세요.

화면 속 화면
라이브 세션, 게임, 동영상처럼 대화가 이어지는 동안에도 진행 중인 활동을 계속 표시해야 할 때는 화면 속 화면을 사용하세요.

레이아웃, 상호작용, 시각 디자인, 접근성에 관한 자세한 지침은 UI 가이드라인을 참고하세요.
데이터 처리와 UI 렌더링 분리하기
도구를 분리하는 패턴
모든 도구 호출에 위젯 템플릿을 연결하면 ChatGPT가 iframe을 지나치게 자주 렌더링할 수 있습니다. 데이터 처리 도구와 렌더링 도구를 분리하는 편이 좋습니다.
- 데이터 도구는 데이터를 가져오거나 계산하거나 변경하고 도구 결과만 반환합니다.
- 렌더링 도구는 최종 데이터를 받아 위젯 템플릿을 반환합니다.
이렇게 하면 모델이 가져온 데이터를 먼저 분석한 뒤 사용자에게 UI를 표시할지 결정할 수 있어, 사용자가 구체적으로 요청한 목표를 달성할 가능성이 훨씬 높아집니다.
이 패턴은 MCP Apps 아키텍처의 일부입니다.
실제로 많은 UI 통합에서 다음과 같이 도구를 분리합니다.
- 검색 및 조회 도구(데이터 우선): 위젯 템플릿 없이 ID와 메타데이터를 반환합니다.
- 렌더링 도구(예:
render_listings_widget): 준비된 ID 목록을 받아 위젯을 렌더링합니다.
_meta.ui.resourceUri는 렌더링 도구에만 포함하세요.
도구를 분리했을 때의 호출 플로우
권장 호출 순서는 다음과 같습니다.
- 모델이 데이터 도구(예:
roll_dice)를 호출합니다. - 모델이 데이터 도구에서
structuredContent를 받습니다. - 모델이 이 데이터를 전달해 렌더링 도구를 호출합니다.
- 모델이 확인한 최종 컨텍스트를 바탕으로 위젯이 한 번만 렌더링됩니다.
예시: 부동산 매물에 대한 후속 질문
플러그인이 매물 카드와 지도를 표시한다고 가정해 보세요. 서버 측 search 도구는 도시, 가격, 침실 수, 욕실 수 같은 기본 조건으로만 검색할 수 있고 학군별로 매물을 필터링할 수는 없습니다.
사용자가 “이 매물 중 리치먼드 초등학교 학군에 있는 곳은 어디인가요?”라고 물으면 도구를 분리한 구조가 다음과 같이 작동합니다.
search가 넓은 범위로 검색해 후보 매물의 ID와 메타데이터를 반환합니다.- 모델이 후속 질문에 맞게 후보 매물을 추립니다.
- 모델이 추린 매물의 ID만 전달해
render_listings_widget을 호출합니다. - 위젯이 최종 매물 목록을 렌더링합니다.
권장 사항은 다음과 같습니다.
- 데이터 도구는 재사용할 수 있게 만드세요. 후속 호출에서 결과를 사용할 수 있도록 완전한
structuredContent를 반환하세요. - 렌더링 도구는 화면 표시에 집중하도록 설계하세요. 렌더링 핸들러에 비즈니스 로직을 넣지 마세요.
- 렌더링 도구 설명에 선행 조건을 명시하세요. 예를 들어 “항상
roll_dice를 먼저 호출하세요”라고 작성하세요. - 필요한 경우에만 도구를 다시 실행하세요. “다시 굴리기”와 같은 위젯 내 상호작용에서는 위젯을 다시 마운트하지 않고 UI가 데이터 도구를 직접 호출하도록 하세요.
도구 분리 예제
다음 예제는 주사위 굴리기와 렌더링을 별도 도구로 구현합니다.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod/v3";
import { build } from "esbuild";
import { fileURLToPath } from "node:url";
const TEMPLATE_URI = "ui://widget/dice.html";
const server = new McpServer(
{ name: "Decoupled dice", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// Bundle the same browser source that is type-checked and tested directly.
const widgetBundle = await build({
entryPoints: [fileURLToPath(new URL("./dice-widget.ts", import.meta.url))],
bundle: true,
format: "esm",
platform: "browser",
write: false,
});
const widgetScript = widgetBundle.outputFiles[0].text.replace(
/<\/script/gi,
"<\\/script"
);
const widgetHtml = `<!doctype html><html lang="en"><meta charset="utf-8"><title>Dice widget</title><body><script type="module">${widgetScript}</script></body></html>`;
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;esbuild를 서버 종속성과 함께 설치한 다음, 아래 브라우저용 코드를 dice-widget.ts라는 이름으로 서버 파일과 같은 디렉터리에 저장하세요.
import { App } from "@modelcontextprotocol/ext-apps";
const container = document.createElement("div");
container.style.cssText = "font-family: system-ui; padding: 8px;";
const resultLine = document.createElement("div");
resultLine.style.cssText = "font-size: 20px; margin-bottom: 6px;";
const output = document.createElement("span");
output.id = "out";
output.textContent = "—";
resultLine.append("Result: ", output);
const rerollButton = document.createElement("button");
rerollButton.id = "reroll";
rerollButton.textContent = "Re-roll";
rerollButton.disabled = true;
container.append(resultLine, rerollButton);
document.body.append(container);
const app = new App({ name: "Dice widget", version: "1.0.0" });
let latestInput: Record<string, unknown> = {};
let latestOutput: Record<string, unknown> = {};
function render(result: Record<string, unknown>) {
latestOutput = result;
output.textContent = String(result.value ?? "—");
}
app.ontoolinput = ({ arguments: input }) => {
latestInput = input ?? {};
};
app.ontoolresult = (result) => {
render(result.structuredContent ?? {});
};
// Call the data tool without remounting the widget.
rerollButton.onclick = async () => {
const candidate = latestOutput.sides ?? latestInput.sides;
const sides =
typeof candidate === "number" &&
Number.isInteger(candidate) &&
candidate >= 2
? candidate
: 6;
const next = await app.callServerTool({
name: "roll_dice",
arguments: { sides },
});
if (next.structuredContent) render(next.structuredContent);
};
await app.connect();
rerollButton.disabled = false;상태 관리하기
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,
}: {
tasks: { id: string; title: string }[];
}) {
const [state, setState] = useState(
window.openai?.widgetState ?? { selectedId: null }
);
function selectTask(selectedId: string) {
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입니다.
const selectedFiles = await window.openai.selectFiles();
window.openai.setWidgetState({
modelContent: "Review the currently selected images.",
privateContent: {
currentView: "image-viewer",
filters: ["crop", "sharpen"],
},
imageIds: selectedFiles.map((file) => file.fileId),
});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@^19 react-dom@^19
npm install -D typescript esbuild
컴포넌트에 드래그 앤 드롭, 차트 등을 위한 라이브러리가 필요하다면 이 단계에서 추가하세요. 번들 크기를 줄이려면 필요한 종속성만 설치하세요.
React 컴포넌트 작성하기
진입점 파일에서는 root 요소에 컴포넌트를 마운트하고 MCP Apps 브리지로 전달된 최신 도구 결과를 바탕으로 렌더링하세요. 예를 들어 ui/notifications/tool-result 알림으로 결과를 받을 수 있습니다.
예제 페이지에서 피자 가게를 나열하는 Pizzaz 목록 등 여러 UI 예제를 확인할 수 있습니다.
Pizzaz 컴포넌트 갤러리 살펴보기
UI 예제에는 다음과 같은 컴포넌트가 있습니다. 자체 UI를 설계할 때 참고하세요.
- Pizzaz 목록: 즐겨찾기와 행동 유도 버튼을 갖춘 순위별 카드 목록입니다.

- Pizzaz 캐러셀: 미디어 콘텐츠가 많은 레이아웃을 보여 주는 Embla 기반 가로 스크롤 컴포넌트입니다.

- Pizzaz 지도: Mapbox를 통합하며 전체 화면에서 상세 정보를 확인하고 호스트와 상태를 동기화할 수 있습니다.

- Pizzaz 앨범: 한 장소를 자세히 살펴볼 수 있도록 항목을 겹겹이 배치한 갤러리입니다.

- Pizzaz 동영상: 오버레이와 전체 화면 컨트롤을 갖추고 스크립트로 제어하는 플레이어입니다.
각 예제는 실제 대화에서 사용할 수 있도록 에셋을 번들링하고 호스트 API를 연결하고 상태를 구성하는 방법을 보여 줍니다. 사용 사례에 가장 가까운 예제를 복사한 뒤 도구 응답에 맞게 데이터 계층을 조정하세요.
React 헬퍼 훅
다음 헬퍼 훅은 ui/notifications/tool-result 알림을 구독합니다.
import { useEffect, useState } from "react";
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에 반영합니다. 이 로캘을 사용해 번역을 로드하고 날짜와 숫자의 표시 형식을 지정하세요. 다음 예제를 사용하기 전에 npm install react-intl을 web/ 디렉터리에서 실행하세요.
import { IntlProvider, FormattedMessage } from "react-intl";
const messages: Record<string, Record<string, string>> = {
"en-US": { greeting: "Hello" },
"es-ES": { greeting: "Hola" },
};
export function PluginUI() {
const locale = document.documentElement.lang || "en-US";
return (
<IntlProvider
locale={locale}
messages={messages[locale] ?? messages["en-US"]}
>
<FormattedMessage id="greeting" defaultMessage="Hello" />
</IntlProvider>
);
}iframe용 번들 만들기
React 컴포넌트를 작성한 뒤에는 서버가 HTML에 직접 삽입할 수 있도록 단일 JavaScript 모듈로 빌드하세요.
// package.json
{
"scripts": {
"build": "esbuild src/component.tsx --bundle --format=esm --jsx=automatic --outfile=dist/component.js"
}
}
npm run build를 실행해 dist/component.js를 생성하세요. esbuild에서 종속성을 찾을 수 없다는 오류가 발생하면 npm install을 web/ 디렉터리에서 실행했는지, import에 사용한 이름이 설치된 패키지 이름과 일치하는지 확인하세요. 예를 들어 @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를 게시하고 이를 참조하는 모든 도구를 업데이트하세요.
콘텐츠 보안 정책(CSP)
컴포넌트가 연결하거나 리소스를 로드하는 도메인을 정확히 선언하세요.
connectDomains에는 API 요청을 보낼 도메인을 지정하세요.resourceDomains에는 스크립트, 스타일, 이미지 등 에셋을 로드할 도메인을 지정하세요.frameDomains는 컴포넌트가 특정 출처의 iframe을 삽입해야 할 때만 지정하세요.
중첩 프레임은 기본적으로 차단됩니다. 각 허용 목록의 범위를 최대한 좁게 유지하세요. 플러그인 검토 과정에서는 선언된 정책이 UI의 실제 동작과 일치하는지 확인합니다.
MCP 서버와 동일한 등록 가능 도메인에 있는 기존 편집기나 관리 인터페이스를 삽입할 수 있습니다. 예를 들어 https://api.example.com/mcp에서 실행되는 서버는 https://app.example.com을 frameDomains에 선언할 수 있습니다. 제출할 때 삽입이 필요한 이유를 명시하고, 공유 호스팅 제한 및 검토 요건을 포함한 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가 선택된 결제 토큰을 MCP 서버의
complete_checkout도구로 보냅니다. 이 도구는 해당 결제 수단으로 대금을 청구하고 완료된 주문을 반환합니다.
결제 세션에는 다음 항목이 필요합니다.
- 고유한 세션 ID
- 주문 항목과 수량
- 해당 통화의 최소 단위로 나타낸 정수 합계
- 결제 서비스 제공업체 및 판매자 메타데이터
- 필수 법적 고지, 개인정보 보호, 환불 및 고객 지원 링크
가격과 주문 상태는 서버 데이터를 기준으로 판단하세요. 결제 토큰을 검증하고, 같은 결제 요청을 여러 번 처리해도 대금이 중복 청구되지 않도록 멱등성을 보장하세요. 주문을 영구 저장한 뒤 서버에서 확정한 영수증을 반환하세요. 컴포넌트에서만 계산한 합계를 신뢰해서는 안 됩니다.
payment_mode: "test"를 사용하면 실제 자금 이동 없이 전체 결제 플로우를 테스트할 수 있습니다. 취소, 결제 거절, 결제 서비스 제공업체 오류는 컴포넌트에서 처리하세요.
전체 결제 세션 필드, 결제 서비스 제공업체의 동작, complete_checkout 결과 구조, 위임 결제 요건은 결제 API 레퍼런스를 참고하세요.