Migrate OpenAI Apps SDK Code to MCP Apps (October 2026)

Migrating from Apps SDK-specific patterns to MCP Apps gives a ChatGPT App a cleaner path to other MCP Apps-compatible hosts.
If your ChatGPT App already works, migration begins with its server contract. The host must still find the same tool, read its UI resource, and deliver its result to the view. Changing React components first will not fix a missing resource URI.
The questions that tend to block the migration are specific:
- “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 ChatGPT UI guide recommends MCP Apps fields and the ui/* bridge for new UI. It keeps window.openai for compatibility and ChatGPT-only extensions. The MCP Apps migration reference lists the field and API mappings, including features without a shared equivalent.
This guide turns that into a migration checklist.
TL;DR: Add _meta.ui.resourceUri to the tool descriptor, return text/html;profile=mcp-app from the resource, move CSP into _meta.ui.csp, and express tool access with _meta.ui.visibility. Move shared interactions to the MCP Apps bridge. Keep ChatGPT aliases only where needed, and verify both the MCP contract and the rendered UI before removing old fields.
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. Make the MCP Apps values the source of truth, then remove aliases only after checking the ChatGPT integration you actually deploy.
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 |
| no Apps SDK equivalent | baseUriDomains | origins allowed by the base-uri directive |
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. Audit any <base> element before adding baseUriDomains; omit permissions your view never requests. The MCP Apps resource metadata also has _meta.ui.permissions for camera, microphone, geolocation, and clipboard requests, but a declaration does not guarantee that a host will grant them.
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.
Features Without a Direct MCP Apps Replacement
Do a separate pass over ChatGPT-only behavior instead of assuming every window.openai call has a new name. The MCP Apps migration reference lists these gaps:
| Existing behavior | Migration decision |
|---|---|
window.openai.uploadFile, selectFiles, and getFileDownloadUrl | Keep the ChatGPT extension when needed, or implement upload and download through your authenticated server. Test a host without the extension. |
window.openai.requestModal and requestClose | Keep these behind a capability check. An in-view dialog or close control needs its own fallback and accessibility test. |
window.openai.widgetState and setWidgetState | Decide whether the value belongs in React state, model context, or server storage. The bridge does not provide identical widget-state persistence. |
openai/toolInvocation/invoking, openai/toolInvocation/invoked, and openai/widgetDescription | Keep ChatGPT metadata if it affects the current experience. There is no field-for-field MCP Apps replacement. |
redirect_domains | Keep this ChatGPT-specific CSP setting if your ChatGPT link flow requires it; review the shared app’s openLink behavior separately. |
For calls with a shared counterpart, use that counterpart: app.callServerTool({ name, arguments }) for tool calls, app.sendMessage(...) for follow-up messages, app.openLink({ url }) for external links, and app.requestDisplayMode({ mode }) after checking the host’s available display modes. Treat any rejected request as a normal UI state because hosts may differ in which capabilities they allow.
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 | React state; server storage if it must survive sessions |
| 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 tool output in structuredContent or send a model context update after the user changes the view. If the value is private to the UI, keep it out of model-visible fields. ui/update-model-context shares context; it is not a cross-session database.
For sunpeak apps, useAppState is useful for UI state that should also reach the model. Use ordinary React state for a purely visual toggle. Store durable account data on your server and use window.openai.setWidgetState() only when you intentionally need ChatGPT’s widget-scoped 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.
Keep Data Tools Separate From Render Tools
Before copying _meta.ui.resourceUri onto every tool, review which calls should open an iframe. OpenAI’s UI guide recommends keeping fetch and mutation tools useful without a component, then attaching the UI resource only to a render tool when the user needs to inspect or edit the result.
For an invoice workflow, find-invoices can return IDs and short summaries in structuredContent. show-invoice can take the chosen ID, return the detail, and point to ui://invoice/view.html. The model can still use find-invoices in a client that never renders UI, while a ChatGPT or Claude host can display the invoice when show-invoice runs.
Check the tool descriptor returned by tools/list. That is where _meta.ui.resourceUri belongs. A URI in the tool result alone will not tell the host which resource to fetch. The URI must resolve through resources/read, and the returned resource must use the MCP Apps MIME type.
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 retained ChatGPT compatibility fields stay in sync.
- Review every tool with a resource URI and remove it from data-only tools that should not open a view.
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.
- Data-only tools remain useful when a host does not render UI.
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
- a real server-backed tool call, so a fixture does not mask a broken server route
This is where sunpeak helps. The Inspector gives you replicated ChatGPT and Claude runtimes, simulation fixtures, and tests for MCP App states without opening a live host for every change. Run both a fixture path and a real server-backed path, then do a final pass in each host you support. The E2E testing guide shows how to distinguish those paths.
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 |
redirect_domains | ChatGPT-specific redirect handling is still intentional |
baseUriDomains | Any <base> URI has an explicit, narrow allowlist |
openai/toolInvocation/ | ChatGPT-only status text remains where needed |
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 capability detection and fallbacks
- E2E testing MCP Apps across fixture and server paths
- MCP App framework
- ChatGPT App framework
- sunpeak docs - MCP App protocol reference
- OpenAI - add UI to an MCP server for ChatGPT
- OpenAI - ChatGPT-specific UI extension reference
- MCP Apps overview - Model Context Protocol
- MCP Apps SDK - migrate from OpenAI Apps SDK
Frequently Asked Questions
Should new ChatGPT Apps use OpenAI Apps SDK fields or MCP Apps fields?
Use the MCP Apps standard fields and bridge for new UI work. OpenAI documents ChatGPT support for MCP Apps, including _meta.ui.resourceUri and ui/* bridge messages. Keep OpenAI-specific aliases or window.openai APIs only when an existing integration or a ChatGPT-only feature needs them. Test each target host because the shared bridge does not guarantee every optional capability.
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 app.callServerTool({ name, arguments: args }), which maps to tools/call. In sunpeak React code, use useCallServerTool. Mark tools intended only for the UI with _meta.ui.visibility: ["app"] so they do not appear in the model tool list.
How should I test an Apps SDK to MCP Apps migration?
Start with protocol tests for tools/list, tools/call, and resources/read. Assert the resource URI, MIME type, CSP domains, outputSchema, structuredContent, and compatibility aliases. Render loading, success, and error fixtures, then make a real server-backed UI call. Finally check the app in each target host, including behavior when optional host APIs are absent.