Field guide · MCP apps

How to make MCP widgets work across ChatGPT and Claude

One build renders in ChatGPT and goes blank in Claude. Before blaming the host, check five strings that have to agree exactly. There is an order for this.

Last verified . Hosts and protocols move fast, so details here may be stale. Check the primary sources before you build.

The widget worked in ChatGPT. In Claude, the same server and the same tool call produced a clean, confident, completely blank frame. The reflex at that moment is to blame the host. It is almost never the host. The suspect worth interrogating first is the resource contract.

Five strings that must agree exactly

A widget is more than HTML a tool happens to return. It is an MCP Apps resource with a URI, a MIME type, descriptor metadata, a sandbox policy, and a message bridge, and every one of them has to line up with the others. The whole portable contract fits in a breath: register the component at a stable, versioned ui:// URI, serve its HTML through resources/read, give it the MIME type text/html;profile=mcp-app, point the tool descriptor at that exact URI with _meta.ui.resourceUri, and speak to the host over the standard ui/* JSON-RPC bridge.

resource: ui://sheets/diff-v3
mimeType: text/html;profile=mcp-app
tool._meta.ui.resourceUri: ui://sheets/diff-v3

Exactly means exactly. Hosts cache widget bundles, which makes a stale resource URI one of the easiest bugs to miss. Version the URI whenever the component's contract changes.

The standard bridge carries the render path

Build calls and notifications on the MCP Apps bridge. OpenAI's own recommendation is to use window.openai only for ChatGPT-specific capabilities, and to feature-detect it when you do. The classic blank-frame bug is a render path that waits on a host-only global: the HTML loads correctly in another client, then stands there waiting for a ChatGPT that never arrives. I keep a small adapter at the edge and let the portable code ignore it.

const host = window.openai ? "chatgpt" : "mcp-app";
// Standard MCP Apps calls remain the default.
// Optional host enhancement is isolated behind this check.

The widget is an isolated application

The component runs in a sandbox, so every script, image, font, API call, and nested frame has to satisfy the host's content-security rules. Bundle your dependencies. If you genuinely need external domains, declare them through the supported metadata and confirm both hosts accept the policy. Then delete every assumption about a parent page: relative asset URLs resolve against nothing useful, window.parent is out of reach, and first-party cookies do not follow you into a hosted iframe. The widget is an application, and it is alone.

One result, two readers

A tool result serves two readers with different appetites. structuredContent carries the concise state the model should reason about. The result's _meta carries component-only data, which reaches the UI without spending model context: details, warnings, a proposal identifier. And never make the component scrape prose out of the assistant's reply. Hosts phrase and order conversational text differently, so hand the widget a stable, typed object.

Design for a frame you have not measured

ChatGPT and Claude promise nothing identical about width, height, typography, or surrounding chrome. Use responsive layout, avoid viewport-sized fixed elements, respect reduced motion, and let long content scroll inside its own container. The app should stay usable when a host gives it less room than your local preview ever did.

When the frame is blank

Debug in this order

I walk this list top to bottom and stop at the first failure. Each step assumes everything above it passed.

Confirm the client sees the tool

Start with discovery. If the client never lists the tool and its descriptor, nothing downstream matters.

Diff the descriptor's resource URI

_meta.ui.resourceUri must be present and match the registered URI character for character.

Read the resource yourself

Call resources/read directly and confirm it returns the expected URI with the MIME type text/html;profile=mcp-app.

Boot the HTML without the host

The bundle must start without any host-only global. A render path gated on window.openai passes this step in ChatGPT and nowhere else.

Audit the sandbox policy

Confirm the CSP, remote assets, and network domains are actually allowed by the host you are debugging.

Watch the bridge come up

Verify the component initializes standard JSON-RPC communication with the host.

Check the result shape

The tool must return the object shape the component expects, in structuredContent and _meta both.

Run one real workflow in each host

The same deployed build should complete one full workflow in ChatGPT and one in Claude, end to end.

Three kinds of proof

A protocol inspector proves the portable path. A local screenshot proves layout. Only real hosts prove interoperability, which is why the last step on that list does not get skipped. The Slack Preview and Sheets Diff pages in this collection show the same application states inside both host frames; that toggle earns its keep in design review and settles nothing about interoperability. For that, deploy the server and run it in the real hosts. And when the frame still comes up blank, blame the strings first. The host is innocent more often than anyone wants to admit.

Primary references

Continue reading