Skip to main content
All posts

Building One MCP App for ChatGPT and Claude (October 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: Link a tool to a ui:// resource with _meta.ui.resourceUri, serve the MCP Apps HTML MIME type, and return useful content when a client has no UI. Put host-only hooks in components that mount only in that host. Test the shared MCP contract first, then check the actual ChatGPT and Claude integrations.

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 October 2026 to keep one app usable in ChatGPT and Claude without maintaining two frontends.

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 can carry short content, typed structuredContent, and client-only _meta. Do not assume structuredContent is hidden from the model.
  • A tool descriptor links to a ui:// resource through _meta.ui.resourceUri; the resource content uses text/html;profile=mcp-app.
  • The iframe bridge lets the UI read host context, app state, and display information without hard-coding one host.

The MCP Apps overview describes this shared bridge. ChatGPT’s UI guide says to start with the MCP Apps fields and add host extensions only when needed. Claude’s interactive connector guide confirms inline and fullscreen connector views. Host support still varies, so a portable server must provide useful text when no view is rendered.

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,
  };
}

The model can read content, and the app can render structuredContent. Some hosts also expose structured data to the model, so keep both fields limited to user-authorized information. Tool result _meta can carry client-only UI data when supported, but never put credentials there. The example returns text as well as structured data, which lets a client without MCP Apps UI still answer the weather request.

Check the Tool and Resource Contract

Before styling the React component, inspect the raw MCP responses. This small contract is what both hosts need:

ResponseCheck
tools/listThe UI tool has _meta.ui.resourceUri pointing to its ui:// resource. Its input schema and annotations match its behavior.
resources/readThe matching content item has the same URI, mimeType: "text/html;profile=mcp-app", and the exact CSP domains the UI needs.
tools/callcontent is useful without UI; structuredContent matches the documented output shape; neither leaks private data.
UI bridgeThe view handles tool input and result notifications, uses host context, and calls server tools only when the host advertises that capability.

The MCP Apps quickstart shows the tool-resource registration. Resource CSP belongs on the returned content item when you read the resource, so checking only resources/list can miss the policy that the host actually uses. Keep ui:// identifiers stable for compatible changes and change the URI when a new bundle cannot safely use a cached resource.

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 missing data. Hosts can render a resource before the result arrives or deliver another result later, and useHostContext() can return null before initialization. A production component should render distinct loading, error, cancelled, and empty states with useToolData() before reading output. Those states belong in the shared component.

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 host features are not portable yet. Keep the main task on shared MCP tools and put the extra behavior behind a host check.

Use this pattern:

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

The original version of this article called a ChatGPT-only hook before checking the host. sunpeak’s runtime docs say those hooks throw outside ChatGPT. Render a separate component after host detection so React hook ordering stays valid:

import { isChatGPT } from 'sunpeak/host';
import { useRequestModal } from 'sunpeak/host/chatgpt';

export function DetailsAction({ details }: { details: string }) {
  if (isChatGPT()) return <ChatGPTDetailsAction />;
  return <details><summary>View details</summary><p>{details}</p></details>;
}

function ChatGPTDetailsAction() {
  const requestModal = useRequestModal();
  return <button onClick={() => requestModal({})}>View details</button>;
}

The host-only hook runs only in ChatGPTDetailsAction, after the host check. A native details element is the fallback for other hosts. The modal is optional presentation; the shared task must work through the tool result. Test both branches. If the details are private or too large for the initial result, fetch them through an authorized MCP tool instead of embedding them in the resource.

Good candidates for host-specific adapters include file picker behavior, modal requests, host-only display transitions, and features that depend on a host account setting. Basic data rendering, empty states, and auth error handling belong in the shared component.

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 content metadata, including CSP, MIME type, and the tool’s resource URI.
  • Inline and fullscreen rendering where supported; request picture-in-picture only after checking host capabilities.
  • 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.
  • A text-only client path that never requests the UI resource.

Use three runs of the same fixture: call the tool without a UI client and read content; render it in a ChatGPT profile; render it in a Claude profile. Compare the data and action outcomes, then inspect each host’s layout, console errors, blocked requests, and auth prompt. A passing local profile is useful evidence, but run a real-host smoke test before shipping because hosts can differ in rollout and account settings.

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

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 open a modal?” 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, when both host setups support MCP Apps UI. Link the tool to a ui:// resource through _meta.ui.resourceUri, return useful text in content for hosts without UI, and keep host-only features optional. Test the actual ChatGPT and Claude integrations before launch because supported capabilities and approval flows can differ.

What makes an MCP App portable across hosts?

A portable MCP App uses MCP tool schemas, useful content, optional structuredContent, a ui:// resource with the MCP Apps HTML MIME type, _meta.ui.resourceUri on the tool, and the standard iframe bridge. Test the tool without UI, then test optional capabilities in each target host.

Should I write separate ChatGPT and Claude frontends?

Usually no. Start with one resource component that reads tool data, theme, and display mode through portable APIs. Put a host-only action in a separate component that mounts only after host detection, since host-specific hooks may throw outside their host. Keep the core workflow available through shared MCP tools.

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

Treat ChatGPT and Claude as separate implementations of the MCP Apps contract. Check each host for UI support, display modes, tool calling, CSP behavior, and auth prompts. Claude interactive connectors support inline and fullscreen views, while other modes depend on the host. Keep a useful text result for clients that do not render the UI.

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

Test tools/list, tools/call, resources/read, the resource MIME type and CSP, the text-only response, model-visible output, theme, small viewports, auth errors, and repeated calls. Add a visual check in each supported host and a live smoke test before release.

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.