All posts

Building One MCP App for ChatGPT and Claude (July 2026)

Abe Wheeler
MCP AppsMCP App FrameworkChatGPT AppsChatGPT App FrameworkClaude AppsClaude ConnectorsMCP App TestingChatGPT App Testing
Building one app for ChatGPT and Claude!

Building one app for ChatGPT and Claude!

TL;DR: Build the app around MCP tools, resources, structured output, and resource metadata. Keep host-only APIs behind capability checks. With that shape, one React resource can run in ChatGPT, Claude, and other MCP App hosts, and one sunpeak test suite can cover the shared contract before you test in each real host.

The useful way to think about a cross-host MCP App is simple: the portable part is the protocol contract. ChatGPT, Claude, and other hosts may wrap the iframe differently, expose different app actions, and roll out features at different times, but the app should still be understandable as an MCP server with tools and resources.

That means the app has three layers:

  • A tool layer that accepts typed input and returns content, structuredContent, and optional _meta.
  • A resource layer that renders a ui:// resource in an iframe and reads the tool result.
  • A host adapter layer that handles optional features, display-mode differences, and auth or file flows that vary by host.

Most bugs in cross-host MCP Apps happen when those layers blur together. A component assumes one host’s viewport. A tool hides data in the wrong part of the result. A resource depends on a ChatGPT-only bridge call. A Claude Connector auth flow works in production but has no local fixture, so the first real test finds a broken empty state.

This guide shows the structure I would use in July 2026 if I wanted one app to run in ChatGPT and Claude without maintaining two products.

Start with the portable MCP contract

MCP Apps extend the Model Context Protocol with interactive UI. Your server exposes tools. A tool can link to a resource. The host calls the tool, receives a result, and renders the resource in a sandboxed iframe.

The protocol details matter because they are what make the app portable:

  • Tools describe input with JSON Schema or a schema library such as Zod.
  • Tool annotations tell the host whether a tool is read-only, destructive, idempotent, or open-world.
  • Tool results separate model-visible text from app-visible structured data.
  • Resources declare metadata so the host knows how to frame, secure, and describe the UI.
  • The iframe bridge lets the UI read host context, app state, and display information without hard-coding one host.

If you keep those decisions in MCP, ChatGPT and Claude become hosts for the same app instead of two separate app platforms. If you skip them and depend on a host-only runtime, your second host becomes a port.

Here is the smallest useful example.

// src/tools/show-weather.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';

export const tool: AppToolConfig = {
  resource: 'weather',
  title: 'Show weather',
  description: 'Show the current weather for a city.',
  annotations: {
    readOnlyHint: true,
    openWorldHint: true,
  },
};

export const schema = {
  city: z.string().describe('City name'),
  state: z.string().describe('State or region'),
};

type Args = z.infer<z.ZodObject<typeof schema>>;

export default async function showWeather(args: Args, _extra: ToolHandlerExtra) {
  const weather = {
    city: args.city,
    state: args.state,
    temp: 82,
    condition: 'Sunny',
    humidity: 78,
    wind: '12 mph W',
  };

  return {
    content: [
      {
        type: 'text',
        text: `${weather.city}, ${weather.state}: ${weather.temp}F and ${weather.condition}.`,
      },
    ],
    structuredContent: weather,
    _meta: {
      requestedAt: new Date().toISOString(),
    },
  };
}

The model can read content. The app can render structuredContent. _meta can carry app-only data when the UI needs it and the model does not. That split is one of the highest-value habits for MCP App portability because it keeps model context clear while giving the UI enough data to render.

Keep the resource host-agnostic

The resource should render from the MCP result and host context. It should not need to know whether it is in ChatGPT or Claude for its main path.

import { SafeArea, useDisplayMode, useHostContext, useToolData } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';

export const resource: ResourceConfig = {
  description: 'Display current weather conditions.',
};

interface WeatherData {
  city: string;
  state: string;
  temp: number;
  condition: string;
  humidity: number;
  wind: string;
}

export function WeatherResource() {
  const { output } = useToolData<unknown, WeatherData>(undefined, undefined);
  const displayMode = useDisplayMode();
  const host = useHostContext();

  if (!output) {
    return (
      <SafeArea className="p-4">
        <p>No weather data yet.</p>
      </SafeArea>
    );
  }

  const compact = displayMode === 'inline';

  return (
    <SafeArea className="p-4 font-sans">
      <section
        aria-label={`Weather for ${output.city}, ${output.state}`}
        className={compact ? 'space-y-2' : 'space-y-4 max-w-xl'}
      >
        <header>
          <p className="text-sm opacity-70">{host.locale ?? 'en-US'}</p>
          <h1 className="text-2xl font-semibold">
            {output.city}, {output.state}
          </h1>
        </header>

        <div className={compact ? 'text-4xl' : 'text-6xl'}>{output.temp}F</div>
        <p>{output.condition}</p>

        <dl className="grid grid-cols-2 gap-3 text-sm">
          <div>
            <dt className="opacity-70">Humidity</dt>
            <dd>{output.humidity}%</dd>
          </div>
          <div>
            <dt className="opacity-70">Wind</dt>
            <dd>{output.wind}</dd>
          </div>
        </dl>
      </section>
    </SafeArea>
  );
}

The component uses useToolData, useDisplayMode, useHostContext, and SafeArea. Those are the kinds of APIs you want in the shared layer. They describe the app’s runtime needs without turning the component into a ChatGPT component or a Claude component.

The component also handles the missing-data state. That sounds small, but it matters. Hosts can render resources before all data is ready, rerender after a tool call, or preserve state across turns. Empty states, loading states, and stale-data states should be part of the portable component, not an afterthought in one host adapter.

Use resource metadata deliberately

Resource metadata is where many cross-host bugs start. The UI may render locally, but the real host may refuse an external API call, use the wrong iframe border text, or pick a display mode that makes the layout awkward.

For a portable app, review these fields every time you add a resource:

  • The ui:// URI is stable and matches the tool’s resource link.
  • The resource description explains what the UI does in plain language.
  • CSP metadata includes every connect, resource, and frame domain the UI needs.
  • Display hints match the layout the component can actually support.
  • The component works in light mode, dark mode, small inline frames, and fullscreen.
  • The page does not depend on top-level navigation, cookies, or unsandboxed browser APIs.

The MCP App Resource Metadata guide covers the metadata fields in more detail. The short version is that metadata is not just descriptive. It is part of the app’s execution contract with the host.

Put host-specific behavior behind capability checks

Some features are not portable yet, and that is fine. The mistake is making them required for the core workflow.

Use this pattern:

  1. Build a portable path that works everywhere.
  2. Detect the host or capability at runtime.
  3. Add the host-specific behavior as an upgrade.
  4. Keep the fallback tested.

For example, a purchase button might use a ChatGPT-specific checkout flow when available, but still work as a normal link elsewhere.

import { isChatGPT } from 'sunpeak';
import { useRequestCheckout } from 'sunpeak/chatgpt';

export function BuyButton({ sku }: { sku: string }) {
  const requestCheckout = useRequestCheckout();

  if (isChatGPT() && requestCheckout) {
    return <button onClick={() => requestCheckout({ sku })}>Buy</button>;
  }

  return <a href={`/checkout?sku=${encodeURIComponent(sku)}`}>Buy</a>;
}

That structure keeps the shared app honest. The portable path still works in Claude and other hosts. ChatGPT gets the richer host-native behavior. Your tests can cover both branches.

Good candidates for host-specific adapters include checkout, file picker behavior, modal requests, host-only display transitions, and any feature that depends on a host account setting. Bad candidates include basic data rendering, empty states, auth error handling, and primary navigation. Those should be portable.

Test the same app in multiple host profiles

One codebase does not mean one viewport. Each host wraps the iframe with its own chrome, theme defaults, safe area, and display-mode behavior. A cross-host app needs a test matrix that reflects that.

At minimum, test:

  • Tool input validation with good, missing, and malformed fields.
  • Tool annotations for read-only, destructive, idempotent, and open-world behavior.
  • content, structuredContent, and _meta shape.
  • Resource metadata, including CSP and resource links.
  • Inline, fullscreen, and compact rendering when the host supports them.
  • Light and dark themes.
  • Small, medium, and wide iframe sizes.
  • Auth success, auth expired, and auth denied states.
  • Repeated tool calls where the same resource receives new data.
  • Fallback branches for host-specific features.

sunpeak is built for this workflow. npx sunpeak new scaffolds an MCP App project with a local inspector and test setup. pnpm dev runs the inspector so you can render the app with simulated tool calls. pnpm test runs the automated suite. You can split that into pnpm test:unit, pnpm test:e2e, pnpm test:visual, pnpm test:live, and pnpm test:eval as the project gets more serious.

The important part is not the command list. The important part is that simulations let you pin every app state before you test in real hosts. A production app should not need a live ChatGPT or Claude session to reproduce “OAuth expired after a partial tool result with a small inline iframe in dark mode.” That should be a fixture.

Use one project structure

A portable sunpeak app can stay boring:

sunpeak-app/
+-- src/
|   +-- resources/
|   |   +-- weather/
|   |       +-- weather.tsx
|   +-- tools/
|   |   +-- show-weather.ts
|   +-- server.ts
+-- tests/
|   +-- simulations/
|   |   +-- show-weather.json
|   +-- e2e/
|   |   +-- weather.spec.ts
|   +-- visual/
|       +-- weather.spec.ts
+-- package.json

There is no chatgpt/ app and no claude/ app. The resource is the resource. The tool is the tool. The simulations define states the host can produce.

If the app grows, add host adapters under a clear boundary:

src/
+-- host/
|   +-- capabilities.ts
|   +-- chatgpt.ts
|   +-- claude.ts
+-- resources/
+-- tools/

Keep those files thin. They should answer questions such as “can this host request checkout?” or “which display transition API is available?” They should not own the business logic or the main rendering path.

Migration checklist for existing ChatGPT or Claude apps

If you already built for one host, use this checklist before calling the app cross-host:

  1. Move host-only global access behind a small capability wrapper.
  2. Replace host-specific data reads with content, structuredContent, and _meta.
  3. Add a ui:// resource contract for each rendered component.
  4. Review CSP metadata for external API, image, script, and frame domains.
  5. Test the component without the host-only bridge available.
  6. Add fixtures for empty, loading, success, partial, and error states.
  7. Check inline and fullscreen layouts with real content, not placeholder text.
  8. Run visual tests in light and dark mode.
  9. Add one live-host smoke test per target host before launch.

You do not have to make every feature identical. The goal is that the core task works everywhere and optional host features make the app better where they exist.

Where sunpeak fits

sunpeak is an open-source MCP App framework and MCP App testing framework. It is also a ChatGPT App framework and Claude Connector framework because it builds against MCP instead of one host’s private shape.

Use sunpeak when you want:

  • A local inspector that replicates ChatGPT and Claude-style MCP App runtimes.
  • Typed React hooks for tool data, app state, display mode, theme, and host context.
  • Simulation fixtures for deterministic app states.
  • E2E and visual tests that run in CI.
  • Live-host tests when you need a final check against real ChatGPT or Claude behavior.
  • A project structure that keeps tools, resources, and tests in one place.

Start with:

npx sunpeak new my-mcp-app
cd my-mcp-app
pnpm dev

Then build the first resource around MCP data flow, not host data flow. If the app renders correctly in the inspector, passes the shared test suite, and keeps host-only APIs optional, you have the right foundation for one app across ChatGPT, Claude, and the next MCP App host that shows up.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

Can one MCP App run in both ChatGPT and Claude?

Yes, if you build against the MCP Apps protocol instead of a host-only bridge. Keep your UI as an MCP resource, return structuredContent from tools, use protocol metadata for the rendered component, and isolate host-specific features behind capability checks. sunpeak follows that model so one React component and one MCP server can target ChatGPT, Claude, and other MCP App hosts.

What makes an MCP App portable across hosts?

A portable MCP App keeps its core contract in MCP: tools define schemas and annotations, tool results return content, structuredContent, and _meta, resources are declared with ui:// URIs and widget metadata, and the iframe talks to the host through the MCP Apps bridge. Host-specific APIs should be optional layers, not the only way the app works.

Should I write separate ChatGPT and Claude frontends?

Usually no. Start with one resource component that reads tool data, app state, host context, theme, and display mode through portable hooks. Add small host-specific branches only for features that do not exist everywhere, such as a checkout request, upload picker, or a host-only display transition.

How should I handle differences between ChatGPT Apps and Claude Connectors?

Treat ChatGPT and Claude as different hosts for the same MCP contract. Test the same tool input, structured output, resource metadata, iframe sizing, theme, and auth flow in both host profiles. When one host exposes an extra feature, detect it at runtime and provide a fallback so the app still works elsewhere.

What should I test before shipping one MCP App to multiple hosts?

Test tool schemas, tool annotations, structuredContent, resource links, widget metadata, display modes, light and dark themes, small viewports, auth errors, empty states, and repeated tool calls. For UI apps, add visual regression tests because each host wraps the iframe with different chrome and spacing.

How does sunpeak help with cross-host MCP App testing?

sunpeak provides a local inspector that replicates ChatGPT and Claude-style MCP App runtimes, simulation fixtures for deterministic tool states, typed React hooks, and test utilities for unit, E2E, visual, live-host, and eval tests. You can test the shared MCP contract locally and in CI before spending time in each real host.

Do I need a paid ChatGPT or Claude account to start building?

No. You can build and test the shared MCP App locally with sunpeak first. Real-host testing is still useful before launch, but local inspection and CI tests catch most resource, metadata, data-flow, layout, and state bugs before you connect to production hosts.

What is the safest architecture for a cross-host MCP App?

Put shared MCP tools, schemas, resource components, and simulation fixtures in one project. Keep host-specific adapters thin, optional, and easy to delete. If a feature matters to the core workflow, design a portable path first and add host-specific behavior as an upgrade.