All posts

Migrate OpenAI Apps SDK Code to MCP Apps

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.

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

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. 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.

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

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.

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.

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 positionapp state hook, local storage, or server state
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 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:

  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 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:

  • 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.

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

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:

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
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 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.