Migrate OpenAI Apps SDK Code to MCP Apps

Migrating from Apps SDK-specific patterns to MCP Apps gives a ChatGPT App a cleaner path to other MCP Apps-compatible hosts.
The highest-intent ChatGPT App migration searches are not broad questions like “what is an MCP App.” They are field-level and API-level questions:
- “What replaces
openai/outputTemplate?” - “Do I still need
window.openai.callTool?” - “How do I migrate
text/html+skybridge?” - “Where do
connect_domainsandresource_domainsgo?” - “How do I keep one ChatGPT App working in other MCP Apps hosts?”
OpenAI’s current Apps SDK docs answer the direction: ChatGPT supports the MCP Apps standard for embedded app UIs, and new UI work should use MCP Apps standard keys and the ui/* bridge by default. window.openai still exists for compatibility and ChatGPT-only extensions.
This guide turns that into a migration checklist.
TL;DR: Move the portable contract to MCP Apps first. Replace _meta["openai/outputTemplate"] with _meta.ui.resourceUri, replace text/html+skybridge with text/html;profile=mcp-app, move resource CSP into _meta.ui.csp, move tool accessibility into _meta.ui.visibility, and treat window.openai as an optional ChatGPT extension. Keep compatibility aliases only where you need them, and test the protocol contract before you debug React.
The Migration Shape
Most Apps SDK to MCP Apps migrations have three layers:
| Layer | Old Apps SDK habit | MCP Apps shape |
|---|---|---|
| Tool metadata | Flat _meta["openai/..."] fields | Nested _meta.ui.* fields |
| Resource metadata | ChatGPT widget metadata and text/html+skybridge | MCP App resource metadata and text/html;profile=mcp-app |
| Client runtime | Synchronous window.openai globals | Async host bridge events and requests |
You do not need to delete every OpenAI-specific field on day one. If your app is already live in ChatGPT, keep compatibility fields while you migrate. The main rule is that the MCP Apps fields become the source of truth.
That means compatibility aliases should mirror the same values, not define a second app contract.
Server Metadata Mapping
Start on the server because most rendering bugs come from a bad tool or resource contract.
| Goal | Apps SDK field | MCP Apps field |
|---|---|---|
| Link a tool to a UI resource | _meta["openai/outputTemplate"] | _meta.ui.resourceUri |
| Let the rendered UI call tools | _meta["openai/widgetAccessible"] | _meta.ui.visibility includes "app" |
| Hide a tool from the model | _meta["openai/visibility"]: "private" | _meta.ui.visibility excludes "model" |
| Set resource CSP | _meta["openai/widgetCSP"] | _meta.ui.csp |
| Set a dedicated widget origin | _meta["openai/widgetDomain"] | _meta.ui.domain |
| Prefer bordered presentation | _meta["openai/widgetPrefersBorder"] | _meta.ui.prefersBorder |
| Serve app HTML | text/html+skybridge | text/html;profile=mcp-app |
If you include both old and new fields during the transition, add a test that asserts they match. Drift is worse than a missing alias because it creates host-specific behavior that is hard to see locally.
// Transitional metadata while you still support ChatGPT compatibility aliases.
const toolMeta = {
ui: {
resourceUri: 'ui://invoice/view.html',
visibility: ['model', 'app'],
},
'openai/outputTemplate': 'ui://invoice/view.html',
'openai/widgetAccessible': true,
};
For new code, prefer the standard shape:
import { registerAppTool } from '@modelcontextprotocol/ext-apps/server';
import { z } from 'zod';
registerAppTool(
server,
'show-invoice',
{
title: 'Show Invoice',
description: 'Display an invoice with line items and payment status',
inputSchema: { invoiceId: z.string() },
outputSchema: {
invoice: z.object({
id: z.string(),
customer: z.string(),
total: z.number(),
status: z.enum(['draft', 'sent', 'paid']),
}),
},
annotations: { readOnlyHint: true },
_meta: {
ui: {
resourceUri: 'ui://invoice/view.html',
visibility: ['model', 'app'],
},
},
},
async ({ invoiceId }) => {
const invoice = await loadInvoice(invoiceId);
return {
content: [{ type: 'text', text: `Displayed invoice ${invoice.id}.` }],
structuredContent: { invoice },
_meta: { loadedAt: new Date().toISOString() },
};
},
);
In a sunpeak project, the same idea is usually shorter because the tool file links to a resource directory and sunpeak handles the resource URI:
// src/tools/show-invoice.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
resource: 'invoice',
title: 'Show Invoice',
description: 'Display an invoice with line items and payment status',
annotations: { readOnlyHint: true },
_meta: {
ui: { visibility: ['model', 'app'] },
},
};
export const schema = {
invoiceId: z.string().describe('Invoice ID to load'),
};
type Args = z.infer<z.ZodObject<typeof schema>>;
export default async function showInvoice(args: Args, _extra: ToolHandlerExtra) {
const invoice = await loadInvoice(args.invoiceId);
return {
content: [{ type: 'text' as const, text: `Displayed invoice ${invoice.id}.` }],
structuredContent: { invoice },
};
}
The generated output still uses the MCP Apps contract. You write less routing code by hand.
Resource Metadata and CSP
Resource metadata controls the iframe. Tool metadata routes the host to the resource, but the resource tells the host which origins the iframe may load, which browser permissions it requests, and how it should be framed.
The common CSP migration is snake case to camel case:
| Apps SDK CSP field | MCP Apps CSP field | Used for |
|---|---|---|
connect_domains | connectDomains | fetch, XHR, WebSocket, event streams |
resource_domains | resourceDomains | images, scripts, styles, fonts, media |
frame_domains | frameDomains | nested iframes |
redirect_domains | no portable equivalent | ChatGPT-specific openExternal redirects |
The MIME type changes too:
import {
RESOURCE_MIME_TYPE,
registerAppResource,
} from '@modelcontextprotocol/ext-apps/server';
registerAppResource(
server,
'Invoice View',
'ui://invoice/view.html',
{ description: 'Invoice detail UI' },
async () => ({
contents: [
{
uri: 'ui://invoice/view.html',
mimeType: RESOURCE_MIME_TYPE,
text: renderInvoiceHtml(),
_meta: {
ui: {
csp: {
resourceDomains: ['https://cdn.example.com'],
connectDomains: ['https://api.example.com'],
frameDomains: [],
},
domain: 'https://invoice-widget.example.com',
prefersBorder: true,
},
},
},
],
}),
);
Do not guess CSP domains. Build the app, inspect the generated HTML, CSS, and JS, and list every origin. Static assets belong in resourceDomains. API calls belong in connectDomains. Embedded third-party iframes belong in frameDomains.
For local dev, include local origins only where the dev resource actually loads them. For production, make the same config value drive both the runtime URL and the CSP entry so a deploy cannot point the app at one domain while the resource metadata allows another.
Client Runtime Mapping
The biggest mental shift is client-side. Apps SDK examples often read from window.openai as if the host state exists before the component starts. MCP Apps use an async bridge. The app connects, then receives host context, tool input, tool results, and host updates.
| Goal | Apps SDK pattern | MCP Apps pattern |
|---|---|---|
| Read tool input | window.openai.toolInput | ui/notifications/tool-input, or app.ontoolinput |
| Read tool output | window.openai.toolOutput | ui/notifications/tool-result, or app.ontoolresult |
| Read UI-only result metadata | window.openai.toolResponseMetadata | tool result _meta |
| Read theme and locale | window.openai.theme, window.openai.locale | app.getHostContext() and host context updates |
| Call another server tool | window.openai.callTool(name, args) | tools/call through the bridge |
| Send a follow-up prompt | window.openai.sendFollowUpMessage(...) | ui/message |
| Update model-visible UI state | window.openai.setWidgetState(...) | ui/update-model-context |
Low-level MCP Apps code looks like this:
import { App } from '@modelcontextprotocol/ext-apps';
const app = new App({ name: 'invoice-viewer', version: '1.0.0' });
app.ontoolinput = (params) => {
renderPendingInvoice(params.arguments);
};
app.ontoolresult = (result) => {
renderInvoice(result.structuredContent);
};
app.onhostcontextchanged = (context) => {
applyTheme(context.theme);
};
await app.connect();
Register handlers before connect(). Some hosts can deliver initial state during or right after the handshake, and a late handler can miss the first useful message.
In sunpeak React code, you use hooks instead of wiring the bridge directly:
import { useCallServerTool, useHostContext, useToolData } from 'sunpeak';
interface InvoiceOutput {
invoice: {
id: string;
customer: string;
total: number;
status: 'draft' | 'sent' | 'paid';
};
}
export function InvoiceResource() {
const { output } = useToolData<unknown, InvoiceOutput>();
const host = useHostContext();
const callServerTool = useCallServerTool();
if (!output) return <p>Loading invoice...</p>;
async function refreshInvoice() {
await callServerTool('refresh-invoice', {
invoiceId: output.invoice.id,
});
}
return (
<main data-theme={host.theme}>
<h1>{output.invoice.customer}</h1>
<button type="button" onClick={refreshInvoice}>
Refresh
</button>
</main>
);
}
That component reads the same protocol-shaped data whether it runs in ChatGPT, Claude, or another MCP Apps-compatible host.
What to Keep on window.openai
Do not rewrite ChatGPT-only features into fake portable abstractions.
OpenAI documents window.openai as the compatibility layer and the home for optional ChatGPT extensions. Keep it when the feature only exists in ChatGPT, but put it behind feature detection:
- file upload and file picker APIs
- temporary file download URLs
- host-owned modals
- checkout
- ChatGPT-specific external link handling
- compatibility behavior for older Apps SDK examples
Use a small adapter instead of scattering window.openai checks through components:
export async function requestChatGptModal(params: unknown) {
const openai = typeof window !== 'undefined' ? window.openai : undefined;
if (!openai?.requestModal) {
return { opened: false };
}
await openai.requestModal({ params });
return { opened: true };
}
Then test both paths:
requestModalexists and gets called.window.openaiis missing and the UI still works.
That second test is what keeps a ChatGPT App from crashing in another MCP Apps host.
State Migration
window.openai.widgetState and window.openai.setWidgetState() are common in older ChatGPT App examples. They are useful in ChatGPT, but they are not the portable baseline.
Split state into three buckets before you migrate:
| State type | Example | Portable path |
|---|---|---|
| Tool result data | invoice rows, search results, chart series | structuredContent and outputSchema |
| UI-only state | selected tab, expanded row, scroll position | app state hook, local storage, or server state |
| Model-visible UI context | current filter, selected invoice, draft summary | ui/update-model-context |
If the model should reason about a value, do not hide it in UI-only state. Put safe, compact data in structuredContent or send a model context update. If the value is private to the UI, keep it out of model-visible fields.
For sunpeak apps, useAppState is the portable starting point because it wraps host differences. Drop down to window.openai.setWidgetState() only when you intentionally need ChatGPT-specific persistence behavior.
App-Only Tools Replace widgetAccessible Thinking
_meta["openai/widgetAccessible"] is a boolean. That made sense for an early ChatGPT-only model: can the widget call tools or not?
MCP Apps give you a more useful model. Tool visibility is an array:
_meta: {
ui: {
visibility: ['app'],
},
}
Use ['app'] for tools that only the rendered UI should call:
- pagination
- polling
- form validation
- draft saves
- button actions after the user confirms intent
Use ['model'] for tools the model can call but the UI should not call directly.
Use ['model', 'app'] when both paths are valid.
This is a small migration with a big quality payoff because it reduces model tool-list noise and makes permission boundaries easier to test.
A Safe Migration Order
For an app already working in ChatGPT, migrate in this order:
- Add
_meta.ui.resourceUribeside_meta["openai/outputTemplate"]. - Add
_meta.ui.visibilitybesidewidgetAccessibleandopenai/visibility. - Convert resource MIME type handling to the MCP Apps MIME type or
RESOURCE_MIME_TYPE. - Move CSP to
_meta.ui.cspand convert snake case fields to camel case. - Wrap direct
window.openaireads in one adapter or framework hook layer. - Replace portable interactions with bridge calls, such as
tools/call,ui/message, andui/update-model-context. - Keep ChatGPT-only APIs behind feature detection.
- Add tests that prove both MCP Apps and ChatGPT compatibility fields stay in sync.
Do not start by rewriting the visual component. If the protocol contract is wrong, the cleanest React code in the world still renders the wrong state.
Tests to Add Before You Ship
Start with protocol assertions:
test('invoice tool points to a valid MCP App resource', async ({ mcp }) => {
const tools = await mcp.listTools();
const tool = tools.find((item) => item.name === 'show-invoice');
expect(tool?._meta?.ui?.resourceUri).toBe('ui://invoice/view.html');
expect(tool?._meta?.ui?.visibility).toEqual(['model', 'app']);
expect(tool?._meta?.['openai/outputTemplate']).toBe(tool?._meta?.ui?.resourceUri);
const resource = await mcp.readResource(tool!._meta!.ui.resourceUri);
expect(resource.contents[0].mimeType).toBe('text/html;profile=mcp-app');
});
Then test data lanes:
structuredContentmatchesoutputSchema.- Model-visible
contentis short and useful. - UI-only
_metadoes not contain secrets the model needs. - Compatibility aliases mirror the standard MCP Apps fields.
Then test host behavior in a rendered app:
- loading state before tool result
- success state after
tool-result - error or cancelled tool result
- missing
window.openai - missing optional ChatGPT extension
- app-only tool call from a button
ui/update-model-contextafter a meaningful UI state change- light and dark theme
- inline, fullscreen, and PiP where your target hosts support them
This is where sunpeak helps. The Inspector gives you replicated ChatGPT and Claude runtimes, simulation fixtures, and tests for MCP App states without spending host credits on every change. You can still run a final live-host pass, but most migration bugs should be caught locally and in CI first.
When to Use a Framework
You can migrate with the low-level MCP Apps SDK directly. That is a good fit when you want to own the server shape, resource bundling, bridge code, CSP config, and test harness yourself.
Use an MCP App framework when you want the portable contract to be the default. For this migration, the useful parts are:
- generated resource registration and MIME handling
- tool-to-resource linking
- React hooks for tool data, host context, server tool calls, messages, display mode, and app state
- local ChatGPT and Claude inspection
- protocol, E2E, visual, and live-host tests
The goal is not to hide MCP Apps. The goal is to make the standard shape harder to break while you still keep access to ChatGPT-specific extensions when they are worth using.
Migration Checklist
Before you call the migration done, search the codebase for these patterns:
| Search | What to verify |
|---|---|
"openai/outputTemplate" | Standard _meta.ui.resourceUri exists and matches |
"openai/widgetAccessible" | _meta.ui.visibility expresses the same access model |
"openai/visibility" | Model visibility is represented in _meta.ui.visibility |
text/html+skybridge | Resource uses MCP Apps MIME type |
connect_domains | CSP uses connectDomains |
resource_domains | CSP uses resourceDomains |
frame_domains | CSP uses frameDomains |
window.openai.toolInput | Input comes from bridge events or framework hooks |
window.openai.toolOutput | Output comes from tool result events or framework hooks |
window.openai.callTool | Portable tool calls use the MCP tool surface |
window.openai.setWidgetState | State is split into UI-only and model-visible paths |
window.openai. | Remaining calls are feature-detected ChatGPT extensions |
If a remaining window.openai call is truly ChatGPT-only, keep it. Just make that decision explicit in code and tests.
Start with npx sunpeak new for new work, or add sunpeak testing around an existing MCP server with npx sunpeak inspect --server URL. The migration is easier when every tool, resource, display mode, theme, and host-specific fallback has a fixture you can run before opening a live host.
Get Started
npx sunpeak newFurther Reading
- MCP App tool metadata - resourceUri, visibility, and app-only tools
- MCP App resource metadata - CSP, permissions, and ChatGPT widget fields
- MCP App lifecycle - connect, tool input, tool results, and teardown
- Testing MCP App data flow - content, structuredContent, _meta, and bridge state
- MCP App state persistence - useAppState, widgetState, localStorage, and databases
- MCP App framework
- ChatGPT App framework
- sunpeak docs - MCP App protocol reference
- OpenAI Apps SDK - MCP Apps compatibility in ChatGPT
- OpenAI Apps SDK reference
- MCP Apps overview - Model Context Protocol
- MCP Apps migration guide - ext-apps
Frequently Asked Questions
Should new ChatGPT Apps use OpenAI Apps SDK fields or MCP Apps fields?
Use the MCP Apps standard fields and bridge by default for new UI work. OpenAI documents ChatGPT support for MCP Apps, including _meta.ui.resourceUri and ui/* bridge messages. Keep OpenAI-specific fields such as _meta["openai/outputTemplate"] or window.openai APIs only where you need ChatGPT compatibility or ChatGPT-only features.
What replaces _meta["openai/outputTemplate"] in MCP Apps?
_meta.ui.resourceUri replaces _meta["openai/outputTemplate"] as the standard field that links a tool to the UI resource the host should render. If you still support older ChatGPT Apps SDK paths, include both fields with the same ui:// URI and test that they never drift apart.
What replaces text/html+skybridge in MCP Apps?
MCP Apps use text/html;profile=mcp-app for UI resources. If you use @modelcontextprotocol/ext-apps directly, prefer the RESOURCE_MIME_TYPE constant or registerAppResource helper so the MIME type stays current. If you use sunpeak, the framework generates the resource registration for you.
What replaces window.openai.toolOutput in portable MCP Apps?
In the low-level MCP Apps SDK, handle tool results through ui/notifications/tool-result, usually via app.ontoolresult or a framework hook. The structuredContent field maps to the model-visible tool output. The _meta field remains the place for UI-only data when the host supports it.
Can I still use window.openai in a ChatGPT App?
Yes. OpenAI documents window.openai as a supported compatibility layer and as the place for optional ChatGPT extensions such as file APIs, checkout, host modals, and some display behavior. Feature-detect every window.openai API and provide a fallback so the UI can still run in MCP Apps-compatible hosts that do not expose ChatGPT extensions.
How do I migrate window.openai.callTool?
Use the standard MCP tool surface through the host bridge. With the low-level SDK, call a server tool through the App instance or bridge request that maps to tools/call. In sunpeak React code, use useCallServerTool. Also mark UI-only tools with _meta.ui.visibility: ["app"] so the model does not see tools that only exist for buttons, pagination, validation, or polling.
How should I test an Apps SDK to MCP Apps migration?
Start with protocol tests for tools/list and resources/read. Assert _meta.ui.resourceUri, resource MIME type, CSP domains, outputSchema, structuredContent, and _meta behavior. Then render fixtures in a local inspector for loading, success, error, and missing-host-feature states. Add E2E tests for UI actions such as calling server tools, sending messages, changing display mode, and updating model context.