Skip to main content

I Ship MCP Apps to Both ChatGPT and Claude — Here's What Actually Works

· 13 min read
MCPBundles

MCP Apps look simple in the spec. Your tool returns HTML, the host renders it in an iframe, the user sees a dashboard instead of a wall of JSON. Build one app, it works everywhere.

In practice, I've shipped MCP Apps to both ChatGPT and Claude over the past few months and learned that "works everywhere" requires handling a surprising number of sharp edges — iframe sandboxing, data format differences, a picky initialization handshake, and an interactive tool-calling pattern that's barely documented anywhere.

Here's everything I've learned, with the exact code for each one.

1. The iframe sandbox eats your external resources

Both ChatGPT and Claude render MCP Apps inside a sandboxed iframe. The iframe's origin is the host's domain, not yours. This has consequences that aren't obvious until everything breaks.

If your HTML references external files with relative paths:

<link rel="stylesheet" href="/styles.css">
<script src="/app.js"></script>

Those paths resolve against the sandbox origin — something like https://xxx.web-sandbox.oaiusercontent.com/styles.css on ChatGPT — not your server. Your CSS silently fails to load. Your JS silently fails to load. The app renders as unstyled, broken HTML with no errors in the console.

Even absolute URLs to your own server hit CSP restrictions. The iframe sandbox only allows domains you've explicitly declared in your CSP configuration, and the declaration format differs between hosts (more on that in section 3).

The fix: inline everything. Bundle all CSS and JS directly into the HTML document. No external files, no relative paths, no CDN links. One self-contained HTML string.

from pathlib import Path

ASSETS = Path(__file__).parent / "assets"

def render_app(name: str, custom_css: str, custom_js: str) -> str:
base_css = (ASSETS / "base.css").read_text()
components_css = (ASSETS / "components.css").read_text()
mcp_client_js = (ASSETS / "mcp-client.js").read_text()
components_js = (ASSETS / "components.js").read_text()

return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{name}</title>
<style>
{base_css}
{components_css}
{custom_css}
</style>
</head>
<body>
<div id="app"></div>
<script>
{mcp_client_js}
{components_js}
{custom_js}
</script>
</body>
</html>"""

Every CSS file, every JS file, inlined into the HTML. Zero external dependencies. This is ugly, but it works on every host without CSP configuration.

Exception: Google Fonts. If you need custom fonts, you have to declare fonts.googleapis.com in your connectDomains and fonts.gstatic.com in your resourceDomains. This is the one external resource worth the CSP overhead:

resource_meta = {
"ui": {
"csp": {
"connectDomains": ["fonts.googleapis.com"],
"resourceDomains": ["fonts.gstatic.com"]
}
}
}

Everything else — inline it.

2. Tool results populate different fields

The ui/notifications/tool-result spec defines both a content array and a structuredContent object. In practice, different hosts populate different fields when forwarding tool output to your iframe.

ChatGPT tends to populate structuredContent:

{
"structuredContent": {
"revenue": 42000,
"customers": 150
}
}

Claude tends to populate the content array with a TextContent entry containing JSON:

{
"content": [
{
"type": "text",
"text": "{\"revenue\": 42000, \"customers\": 150}"
}
]
}

If you write renderDashboard(result.structuredContent), it works when that field is populated and silently passes undefined when it isn't. If you write JSON.parse(result.content[0].text), it works with TextContent and throws when content is absent.

The fix: a universal extractor that checks both paths.

function extractData(response) {
if (!response || typeof response !== 'object') return null;

// ChatGPT: structuredContent
if (response.structuredContent && typeof response.structuredContent === 'object') {
return response.structuredContent;
}

// Claude: TextContent array with JSON string
if (response.content && Array.isArray(response.content)) {
for (const item of response.content) {
if (item.type === 'text' && typeof item.text === 'string') {
try {
const parsed = JSON.parse(item.text);
if (parsed && typeof parsed === 'object') return parsed;
} catch (e) {
// not JSON, skip
}
}
}
}

// Nested result wrapper
if (response.result && typeof response.result === 'object') {
return extractData(response.result);
}

return response;
}

Use this everywhere instead of accessing the result directly:

window.addEventListener('message', (event) => {
const msg = event.data;
if (msg.method === 'ui/notifications/tool-result') {
const data = extractData(msg.params);
if (data) renderDashboard(data);
}
});

This handles ChatGPT's structuredContent, Claude's TextContent JSON, and the nested result wrapper some hosts add. Put this in your MCP client once and forget about it.

3. Resource metadata used to require dual-format CSP — it doesn't anymore

Early on, ChatGPT and Claude used different metadata schemas for CSP. ChatGPT had its own openai/widgetCSP key with snake_case fields (connect_domains, resource_domains), and the MCP standard had ui.csp with camelCase (connectDomains, resourceDomains). We shipped every app with both sets of keys — same values duplicated in two formats — because omitting either one broke things on that host.

That's no longer necessary. OpenAI's docs now describe openai/widgetCSP, openai/widgetDomain, and openai/widgetPrefersBorder as legacy compatibility aliases for the standard _meta.ui.* keys. Both hosts read the standard format. The clean version is:

def metadata(self) -> dict:
return {
"openai/widgetDescription": self.resource_description(),
"ui": {
"csp": {
"connectDomains": [],
"resourceDomains": [
"https://fonts.googleapis.com",
"https://fonts.gstatic.com",
],
},
"prefersBorder": True,
"domain": "https://mcpapp-yourapp.yourdomain.com",
},
}

There are still variances between hosts though. One worth knowing: openai/widgetDescription has no standard equivalent. The MCP Apps McpUiResourceMeta spec defines exactly four fields (csp, domain, permissions, prefersBorder) — no description. This ChatGPT-specific key gives the model a human-readable summary of the widget when it loads, reducing redundant assistant narration like "Here's your dashboard showing..." Claude doesn't have an equivalent and ignores it.

ChatGPT also has additional _meta keys on tool descriptors (not the resource) that control how your app behaves in the ChatGPT UI:

tool_meta = {
"openai/outputTemplate": "ui://your-app/dashboard",
"openai/widgetAccessible": True,
"openai/visibility": "public",
"openai/toolInvocation": {
"invoking": "Loading dashboard...",
"invoked": "Dashboard ready"
}
}

These control status text during tool execution, whether the widget can call other tools, and visibility in the ChatGPT app store. Claude ignores all of them. The standard equivalents are _meta.ui.resourceUri (for outputTemplate) and _meta.ui.visibility (for visibility), but the toolInvocation status strings are ChatGPT-only — and they're the kind of polish that makes your app feel native.

If you inline all your assets (section 1), your CSP config is usually minimal — just Google Fonts domains. The less you need from CSP, the fewer host differences matter.

4. Your app gets clipped if you don't report size changes

Both hosts render your app in an iframe with a default height. If your app content is taller than that default, it gets clipped. No scrollbar, no overflow — just cut off. The user sees half a dashboard.

The host expects your app to report its actual size via ui/notifications/size-changed through postMessage. If you don't send this, the iframe stays at its default height forever.

The fix: a ResizeObserver that reports every size change.

let lastWidth = 0;
let lastHeight = 0;

new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;

const { body, documentElement: html } = document;
const bodyStyle = getComputedStyle(body);
const htmlStyle = getComputedStyle(html);

const width = Math.ceil(body.scrollWidth);
const height = Math.ceil(
body.scrollHeight +
(parseFloat(bodyStyle.borderTopWidth) || 0) +
(parseFloat(bodyStyle.borderBottomWidth) || 0) +
(parseFloat(htmlStyle.borderTopWidth) || 0) +
(parseFloat(htmlStyle.borderBottomWidth) || 0)
);

if (width === lastWidth && height === lastHeight) return;
lastWidth = width;
lastHeight = height;

window.parent.postMessage({
jsonrpc: '2.0',
method: 'ui/notifications/size-changed',
params: { width, height }
}, '*');
}).observe(document.body);

A few things to note:

  • The height calculation includes border widths. Without this, certain CSS layouts report a height that's a few pixels short, and the host clips the bottom edge.
  • The debounce check (if width === lastWidth && height === lastHeight) prevents flooding the host with redundant messages when CSS animations or transitions trigger multiple observer callbacks.
  • You must observe document.body, not a specific element. The host needs the total document size.
  • This runs automatically on content changes — when tool results load, when sections expand/collapse, when charts render. No manual calls needed.

This was one of the most annoying issues to debug because the app looks fine in a regular browser tab. The clipping only happens inside the host's iframe, and there's no error message — it just silently cuts off.

5. The initialization handshake is order-dependent

MCP Apps communicate with the host through window.parent.postMessage. Before you can call tools or send messages, you need to complete the initialization handshake. The order matters, and skipping steps or getting them wrong results in silent failures.

The sequence:

App → Host:  ui/initialize        (request, with id)
Host → App: response (result, with same id)
App → Host: ui/notifications/initialized (notification, no id)

Only after step 3 can you call tools. Here's the implementation:

const state = {
mcpInitialized: false,
nextRequestId: 1,
toolName: null,
lastToolInput: null
};
const pendingRequests = new Map();

function initializeMCP() {
const id = state.nextRequestId++;

window.parent.postMessage({
jsonrpc: '2.0',
id: id,
method: 'ui/initialize',
params: {
appCapabilities: {},
appInfo: { name: 'Dashboard', version: '1.0.0' },
protocolVersion: '2025-06-18'
}
}, '*');

pendingRequests.set(id, (result) => {
state.mcpInitialized = true;

// Host tells us the tool name that opened this app
if (result.hostContext?.toolInfo?.tool?.name) {
state.toolName = result.hostContext.toolInfo.tool.name;
}

// Step 3: tell the host we're ready
window.parent.postMessage({
jsonrpc: '2.0',
method: 'ui/notifications/initialized',
params: {}
}, '*');
});
}

And the message listener that routes responses and tool results:

window.addEventListener('message', (event) => {
const msg = event.data;
if (!msg || msg.jsonrpc !== '2.0') return;

// Route responses to pending request handlers
if (msg.id && pendingRequests.has(msg.id)) {
const handler = pendingRequests.get(msg.id);
pendingRequests.delete(msg.id);
if (msg.result !== undefined) handler(msg.result);
return;
}

// Tool input preview (host tells us what args it's about to send)
if (msg.method === 'ui/notifications/tool-input') {
state.lastToolInput = msg.params?.arguments || msg.params?.input || msg.params || null;
}

// Tool result (the actual data to render)
if (msg.method === 'ui/notifications/tool-result') {
const data = extractData(msg.params);
if (data) {
state.data = data;
renderDashboard(data);
}
}
});

The key detail: ui/initialize is a request (has an id, expects a response). ui/notifications/initialized is a notification (no id, fire-and-forget). Mixing these up — sending initialized as a request, or sending initialize without waiting for the response — causes the host to ignore all subsequent tool calls.

Call initializeMCP() at the end of your script, after all your DOM and handlers are set up.

6. The initial data load vs. interactive tool calls from inside the app

This is the part that trips up most people building their first MCP App: there are two distinct data flows, and they work completely differently.

Flow 1: Initial data. When the host opens your app, the tool that triggered it has already run. The host sends the tool's output to your app via ui/notifications/tool-result. Your message listener picks it up, calls extractData(), and renders the dashboard. You don't call any tools — the data arrives automatically.

User asks question → Host calls your tool → Tool returns data
→ Host renders your HTML in iframe → Host sends tool-result to iframe
→ Your app receives data and renders

Flow 2: Interactive tool calls. Once your app is rendered, buttons and controls inside the app can call other MCP tools. This is where it gets powerful — your app becomes interactive, fetching new data without the user typing anything in the chat.

The callTool() function sends a tools/call request through postMessage and returns a Promise:

async function callTool(name, args = {}) {
if (!state.mcpInitialized) {
showError('MCP not initialized');
return;
}

const id = state.nextRequestId++;

return new Promise((resolve, reject) => {
pendingRequests.set(id, resolve);
window.parent.postMessage({
jsonrpc: '2.0',
id: id,
method: 'tools/call',
params: { name, arguments: args }
}, '*');

setTimeout(() => {
if (pendingRequests.has(id)) {
pendingRequests.delete(id);
reject(new Error('Request timeout'));
}
}, 60000);
});
}

Each call gets a unique id, the response is routed back through the message listener by matching that id, and the Promise resolves with the result. The 60-second timeout catches cases where the host drops the request.

Here's a concrete example. Your initial tool returns a list of customers. A button in the app drills into a specific customer by calling a different tool:

function renderDashboard(data) {
const container = document.getElementById('app');

const customerList = data.customers.map(c => `
<div class="card" style="cursor: pointer"
onclick="loadCustomerDetail('${c.id}', '${c.name}')">
<div class="stat-value">${c.name}</div>
<div class="stat-label">${c.revenue}</div>
</div>
`).join('');

container.innerHTML = `
<div class="stats-row">${customerList}</div>
<div id="detail-area"></div>
`;
}

async function loadCustomerDetail(customerId, customerName) {
const detailArea = document.getElementById('detail-area');

// Show loading state
detailArea.innerHTML = `
<div class="loading-placeholder">
<div class="loading-spinner"></div>
<span>Loading ${customerName}...</span>
</div>
`;

try {
const result = await callTool('get_customer_detail', {
customer_id: customerId
});
const detail = extractData(result);

detailArea.innerHTML = `
<div class="card">
<h2 class="card-title">${detail.name}</h2>
<p>Revenue: $${detail.totalRevenue.toLocaleString()}</p>
<p>Last order: ${detail.lastOrderDate}</p>
<p>Lifetime orders: ${detail.orderCount}</p>
</div>
`;
} catch (e) {
detailArea.innerHTML = `
<div class="error-banner visible">
<p>Failed to load customer: ${e.message}</p>
</div>
`;
}
}

The pattern: initial load populates the overview, user clicks trigger callTool() for detail views, and each result re-renders a section of the DOM. The app feels like a real interactive dashboard — not a static HTML dump.

A few things that catch people:

  • The tool name you pass to callTool() must be the exact slug the host knows. Not your Python function name, not a display name — the wire-protocol slug. If you're on ChatGPT, that's usually something like get-customer-detail-2e1. On Claude, it might be get_customer_detail. This is host-specific and you'll need to handle it.
  • You can call any tool the MCP server exposes, not just the tool that opened the app. This is what makes MCP Apps powerful — a single dashboard can orchestrate calls to 10 different tools.
  • Loading states matter. Tool calls take 1-5 seconds. If you don't show a spinner, the user thinks the button is broken. The showLoading() / hideLoading() helpers make this trivial.
  • Error handling is not optional. Tool calls can timeout, return errors, or get cancelled by the host. Always wrap callTool() in try/catch.

You can also send messages back to the chat from inside the app — asking the AI to analyze what the user is looking at:

async function askAIAboutView() {
const currentData = state.data;
await sendMessage(
`[Dashboard Context]\n${JSON.stringify(currentData, null, 2)}\n\n` +
`What trends do you see in this data?`,
'user'
);
}

The sendMessage() function posts a ui/message to the host, which appears in the chat as if the user typed it. The AI responds with analysis based on the dashboard data. This creates a two-way conversation between the app and the chat — the user explores data in the UI, then asks the AI to interpret what they're seeing.

Putting it all together

Every MCP App I ship now includes the same ~100 lines of JavaScript that handles all of these issues: inline assets, dual-format data extraction, ResizeObserver size reporting, the initialization handshake, and the callTool / sendMessage wrappers for interactive use.

I extracted all of this into an open-source Python library:

pip install mcpbundles-app-ui

It produces self-contained HTML with everything inlined and the full MCP client protocol built in:

from mcpbundles_app_ui import App, LightTheme, Stats, Stat, Card, Chart, Grid

class RevenueApp(App):
name = "Revenue Dashboard"
subtitle = "Real-time metrics"
theme = LightTheme(accent="#3b82f6")

layout = [
Stats(
Stat("revenue.total", "Total Revenue", primary=True),
Stat("revenue.thisMonth", "This Month"),
Stat("revenue.customers", "Active Customers"),
),
Grid(cols=2)(
Chart.bar("revenue.byMonth", title="Monthly Revenue"),
Chart.distribution("revenue.byProduct", title="By Product"),
),
]

RevenueApp().render() produces a complete HTML document — all CSS/JS inlined, initializeMCP() called automatically, extractData() handling both result formats, ResizeObserver reporting size changes. Zero external dependencies. Works on ChatGPT and Claude.

The library has 18 chart types, a theme system, breadcrumb navigation, toast notifications, CSV export, and a loading state system for paginated tool calls. All rendered as self-contained HTML. Zero Python dependencies beyond stdlib.

The source is on GitHub: thinkchainai/mcpbundles-app-ui.

If you're building MCP Apps and hitting any of these issues, save yourself the debugging time. Or if you want to understand the full MCP client protocol, read through mcp-client.js in the repo — it's 590 lines that handle every edge case I've encountered across both hosts.