One MCP App, Two Hosts: UI That Renders in Claude and ChatGPT

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out AgentCat

The same three registrations render in four hosts

A tool declares which resource holds its UI, the resource carries the HTML, and the host renders it in a sandboxed iframe that talks back over JSON-RPC on postMessage. That's the entire model, formalized as SEP-1865 by OpenAI and Anthropic together, and it now ships as the first official MCP extension with support in Claude (web and desktop), ChatGPT, Goose, and Visual Studio Code (MCP Apps). OpenAI describes it identically from its own side: "ChatGPT implements the open MCP Apps UI standard so you can build your UI once and run it across MCP Apps-compatible hosts" (OpenAI app quickstart).

Which means porting an app isn't a rewrite. It's a question of which lines in your server are the standard and which are ChatGPT dialect, and there are fewer of the second kind than you'd guess.

If you don't have a working app yet, start with one of the quickstarts instead: the OpenAI Apps SDK TypeScript quickstart and the Python quickstart each walk the three registrations end to end. Come back once it renders in one host and you want it in the rest.

Prerequisites

  • An MCP server with a ui:// resource bound to a tool, running locally.
  • Claude Desktop, installed and logged in, for the render check.
  • The MCP Apps extension SDK alongside the base SDK.
$npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod

What's standard and what's ChatGPT dialect

Six things in an app touch the host. Four of them have a standard form, and the openai/* spellings of those four are compatibility aliases that ChatGPT still honors.

PieceMCP Apps standardChatGPT dialectNotes
Tool → UI binding_meta.ui.resourceUri_meta["openai/outputTemplate"]Direct alias; the migration reference maps one to the other.
Resource MIME typetext/html;profile=mcp-apptext/html+skybridgeExported as RESOURCE_MIME_TYPE.
Runtime API in the iframenew App() from @modelcontextprotocol/ext-appswindow.openaiNot aliases. Two different objects with different method names.
TheminghostContext.styles.variableswindow.openai.themeChatGPT ships the standard variables too.
Visibility control_meta.ui.visibility, an array of "model" and/or "app"openai/visibility, openai/widgetAccessibleopenai/visibility is deprecated in favor of ui.visibility; widgetAccessible maps into the array.
Status text during a callno standard equivalentopenai/toolInvocation/invoking and /invokedIgnored elsewhere, so harmless to keep.

The visibility row is the one with a live deadline on it. _meta["openai/visibility"] was deprecated on July 21 2026 in favor of _meta.ui.visibility (Apps SDK changelog), and the migration reference spells out the shape change: the old boolean openai/widgetAccessible: true becomes "app" in the array, and the old string openai/visibility: "public" becomes "model" (MCP Apps migration reference).

The diff: ChatGPT-only to cross-host

The change on the server is two helper swaps and one _meta key. registerAppResource and registerAppTool come from @modelcontextprotocol/ext-apps/server and wrap the base SDK's registerResource / registerTool, defaulting the MIME type and normalizing the UI metadata for you.

Start with the resource, where the only real change is the MIME type:

-server.registerResource(
+registerAppResource(
+  server,
   "Greeting widget",
   WIDGET_URI,
-  { mimeType: "text/html+skybridge" },
+  { mimeType: RESOURCE_MIME_TYPE },
   async () => ({
-    contents: [{ uri: WIDGET_URI, mimeType: "text/html+skybridge", text: html }],
+    contents: [{ uri: WIDGET_URI, mimeType: RESOURCE_MIME_TYPE, text: html }],
   }),
 );

RESOURCE_MIME_TYPE is exported from both @modelcontextprotocol/ext-apps and its /server entry point, and its value is exactly text/html;profile=mcp-app. Importing the constant instead of typing the string is worth doing for one boring reason: the profile parameter has no space before it, and a text/html; profile=mcp-app typo is a silent non-render rather than an error.

The tool changes by one key. Keep the openai/* entries if you like; nothing outside ChatGPT reads them.

-server.registerTool(
+registerAppTool(
+  server,
   "greet_user",
   {
     title: "Greet user",
     description: "Render a personalized greeting card.",
     inputSchema: { name: z.string() },
     _meta: {
+      ui: { resourceUri: WIDGET_URI, visibility: ["model", "app"] },
       "openai/outputTemplate": WIDGET_URI,
       "openai/toolInvocation/invoking": "Creating greeting…",
     },
   },
   handler,
 );

Driving that server over an in-memory transport and reading the raw tools/list response back, the _meta on the wire carries three binding keys rather than the two we wrote:

{
  "ui": {
    "resourceUri": "ui://greeting/widget.html",
    "visibility": ["model", "app"]
  },
  "openai/outputTemplate": "ui://greeting/widget.html",
  "ui/resourceUri": "ui://greeting/widget.html"
}

registerAppTool adds the flat ui/resourceUri key itself. It's the older backward-compatible spelling of the same binding, kept for hosts that haven't moved to the nested object, and the helper mirrors your nested value into it automatically. Writing it by hand is unnecessary, and the same request confirms the resource comes back with mimeType: "text/html;profile=mcp-app". The one thing that still has to be exact is the URI string itself, matching character for character between the resource registration and _meta.ui.resourceUri, in every host.

Seeing it render in Claude

Claude Desktop reads local servers from claude_desktop_config.json, reachable through Settings → Developer → Edit Config. The quickest sanity check is somebody else's app: pick one of Anthropic's runnable examples, confirm the render pipeline works on your machine, then swap in your own server.

{
  "mcpServers": {
    "qr": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/qr-server", "--stdio"]
    }
  }
}

The map-server, customer-segmentation-server, shadertoy-server, and sheet-music-server packages follow the same shape (Anthropic MCP Apps docs). Save, restart Claude Desktop, and ask for something the server handles. Claude asks permission to display the App before it renders anything, and choosing "Always allow" renders it inline in the conversation.

Your own server usually isn't stdio, though. If it's already deployed on Streamable HTTP, the way to get Claude Desktop pointed at it during development is the mcp-remote proxy, which speaks stdio to Claude and Streamable HTTP to you:

{
  "mcpServers": {
    "greeting": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://your-server.example.com/mcp"]
    }
  }
}

That's the documented path for testing remote MCP Apps locally (Anthropic MCP Apps docs). For production you'd add the server as a custom connector instead, which is a different setup path covered in building a custom Claude connector with a remote MCP server. If you haven't deployed on Streamable HTTP yet, building a Streamable HTTP MCP server covers the transport end to end.

[Screenshot: Claude Desktop showing the "Allow this app to display?" permission prompt for a local MCP App server, with the Always allow option visible]

Theming without hardcoding colors

A widget styled for ChatGPT's palette looks wrong in Claude, and vice versa. The standard fix is the host handing you its design tokens as CSS custom properties at initialization, updated over a ui/notifications/host-context-changed notification whenever the theme changes. ChatGPT has provided these since May 28 2026 (Apps SDK changelog).

The client SDK gives you an App object plus helpers that write those tokens onto the document for you:

import {
  App,
  applyDocumentTheme,
  applyHostStyleVariables,
} from "@modelcontextprotocol/ext-apps";

const app = new App({ name: "greeting-app", version: "1.0.0" });

// Register handlers before connect(): events can fire immediately after.
app.ontoolresult = (result) => render(result.structuredContent);
app.onhostcontextchanged = (ctx) => {
  if (ctx.theme) applyDocumentTheme(ctx.theme);
  if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
};

await app.connect();

Handler registration has to come before connect(), because the host can push tool input and host context in the same tick the connection opens (MCP Apps migration reference). applyDocumentTheme sets data-theme and color-scheme on the root element so native controls and scrollbars follow along, and applyHostStyleVariables sets each token as a CSS variable. Your stylesheet then stops naming colors at all:

body {
  background: var(--color-background-primary);
  color: var(--color-text-primary);
  font-family: var(--font-sans, system-ui, sans-serif);
}
.card {
  background: var(--color-background-secondary);
  border: 1px solid var(--color-border-primary);
}

Swapping window.openai for App is the largest single edit in a port, because the two aren't aliases. window.openai.toolOutput (a property you read) becomes app.ontoolresult (a callback you set), callTool(name, args) becomes app.callServerTool({ name, arguments }), sendFollowUpMessage({ prompt }) becomes app.sendMessage({ role, content }), and the openai:set_globals event becomes app.onhostcontextchanged. The migration reference has the full table, and the SDK auto-detects the OpenAI environment so one build serves both.

mcp-ui and MCP Apps

Two names show up around the same idea, and they're not competitors.

  • MCP Apps is the standard: SEP-1865, the ui:// resource, _meta.ui.resourceUri, and the @modelcontextprotocol/ext-apps SDK that hosts implement.
  • mcp-ui is the community project that got there first. It "pioneered the concept of interactive UI over MCP," its patterns "directly influenced the MCP Apps specification," and it now describes itself as "an SDK implementing the MCP Apps standard for UI over MCP" (mcp-ui README).

So it's not a fork you have to pick a side in. The @mcp-ui/* packages implement the same standard, the MCP blog is explicit that "MCP-UI isn't going anywhere" and that migration to the official extension is straightforward whenever you want it (MCP Apps), and the Client SDK is the recommended path for anyone building a host rather than an app.

Our take on picking one: reach for @modelcontextprotocol/ext-apps on new servers, since it's what the hosts ship against and what the migration tooling targets. Reach for mcp-ui when you're outside TypeScript, because it ships mcp_ui_server for Ruby and mcp-ui-server for Python and the official extension SDK doesn't, or when you're writing a host and want its client-side rendering work. Existing mcp-ui servers don't need a migration sprint.

Where the hosts still differ

Portability covers rendering, not distribution or the full API surface. Four gaps are worth planning around.

  1. window.openai is ChatGPT-only. Anything you wrote against it keeps working in ChatGPT and does nothing anywhere else. Guard it, or move to App and let the SDK handle detection.
  2. Status strings have no standard. openai/toolInvocation/invoking and /invoked give ChatGPT its "Creating greeting…" line. Other hosts silently skip them.
  3. Claude's directory wants screenshots. MCP Apps submitted to the Connectors Directory need 3 to 5 PNG carousel images at least 1000px wide, cropped to the app response with the prompt excluded, plus the paired prompt text supplied separately. Video and GIF aren't accepted, and the submission portal requires a Team or Enterprise organization (Anthropic submission docs).
  4. External links are declared, not assumed. If your app calls ui/open-link, Claude wants an allowed-link-URI list of origins and custom schemes you own, so users aren't asked to confirm every link. Undeclared destinations still work, just with a prompt each time.

One practical consequence of all this: a single server now answers two hosts with different tool-calling behavior, so per-host tool-call and error data is the only way to tell which one is actually failing.

Where to go next

The MCP Apps Quickstart and API reference cover the server helpers and the App surface in full, and the ext-apps examples directory has runnable servers in vanilla JS, React, Vue, and Svelte. If you're porting rather than starting fresh, the OpenAI Apps SDK migration reference is the key-by-key mapping.

There's also a skills plugin for coding agents, which is the fastest way to convert an existing app without reading a mapping table yourself:

$/plugin marketplace add modelcontextprotocol/ext-apps
$/plugin install mcp-apps@modelcontextprotocol-ext-apps

It works in Claude Code, Cursor, Gemini CLI, and anything else supporting the Agent Skills standard. Once installed, "Migrate from OpenAI Apps SDK" is a prompt rather than a project (Anthropic MCP Apps docs).