Skip to main content
All posts

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

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingOpenAI Apps SDKwindow.openaiMCP Apps Migration
Migrating from Apps SDK-specific patterns to MCP Apps gives a ChatGPT App a cleaner path to other MCP Apps-compatible hosts.

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_domains and resource_domains go?”
  • “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:

LayerOld Apps SDK habitMCP Apps shape
Tool metadataFlat _meta["openai/..."] fieldsNested _meta.ui.* fields
Resource metadataChatGPT widget metadata and text/html+skybridgeMCP App resource metadata and text/html;profile=mcp-app
Client runtimeSynchronous window.openai globalsAsync 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.

GoalApps SDK fieldMCP 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 HTMLtext/html+skybridgetext/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 fieldMCP Apps CSP fieldUsed for
connect_domainsconnectDomainsfetch, XHR, WebSocket, event streams
resource_domainsresourceDomainsimages, scripts, styles, fonts, media
frame_domainsframeDomainsnested iframes
redirect_domainsno portable equivalentChatGPT-specific openExternal redirects
no Apps SDK equivalentbaseUriDomainsorigins 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.

GoalApps SDK patternMCP Apps pattern
Read tool inputwindow.openai.toolInputui/notifications/tool-input, or app.ontoolinput
Read tool outputwindow.openai.toolOutputui/notifications/tool-result, or app.ontoolresult
Read UI-only result metadatawindow.openai.toolResponseMetadatatool result _meta
Read theme and localewindow.openai.theme, window.openai.localeapp.getHostContext() and host context updates
Call another server toolwindow.openai.callTool(name, args)tools/call through the bridge
Send a follow-up promptwindow.openai.sendFollowUpMessage(...)ui/message
Update model-visible UI statewindow.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:

  1. requestModal exists and gets called.
  2. window.openai is 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 behaviorMigration decision
window.openai.uploadFile, selectFiles, and getFileDownloadUrlKeep the ChatGPT extension when needed, or implement upload and download through your authenticated server. Test a host without the extension.
window.openai.requestModal and requestCloseKeep these behind a capability check. An in-view dialog or close control needs its own fallback and accessibility test.
window.openai.widgetState and setWidgetStateDecide 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/widgetDescriptionKeep ChatGPT metadata if it affects the current experience. There is no field-for-field MCP Apps replacement.
redirect_domainsKeep 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 typeExamplePortable path
Tool result datainvoice rows, search results, chart seriesstructuredContent and outputSchema
UI-only stateselected tab, expanded row, scroll positionReact state; server storage if it must survive sessions
Model-visible UI contextcurrent filter, selected invoice, draft summaryui/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:

  1. Add _meta.ui.resourceUri beside _meta["openai/outputTemplate"].
  2. Add _meta.ui.visibility beside widgetAccessible and openai/visibility.
  3. Convert resource MIME type handling to the MCP Apps MIME type or RESOURCE_MIME_TYPE.
  4. Move CSP to _meta.ui.csp and convert snake case fields to camel case.
  5. Wrap direct window.openai reads in one adapter or framework hook layer.
  6. Replace portable interactions with bridge calls, such as tools/call, ui/message, and ui/update-model-context.
  7. Keep ChatGPT-only APIs behind feature detection.
  8. Add tests that prove both MCP Apps and retained ChatGPT compatibility fields stay in sync.
  9. 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:

  • structuredContent matches outputSchema.
  • Model-visible content is short and useful.
  • UI-only _meta does 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-context after 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:

SearchWhat 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+skybridgeResource uses MCP Apps MIME type
connect_domainsCSP uses connectDomains
resource_domainsCSP uses resourceDomains
frame_domainsCSP uses frameDomains
redirect_domainsChatGPT-specific redirect handling is still intentional
baseUriDomainsAny <base> URI has an explicit, narrow allowlist
openai/toolInvocation/ChatGPT-only status text remains where needed
window.openai.toolInputInput comes from bridge events or framework hooks
window.openai.toolOutputOutput comes from tool result events or framework hooks
window.openai.callToolPortable tool calls use the MCP tool surface
window.openai.setWidgetStateState 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

Documentation →
npx sunpeak new

Further Reading

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.