All posts

How to Build an MCP App: Architecture for Cross-Host Interactive UI (July 2026)

Abe Wheeler
MCP AppsMCP App FrameworkChatGPT AppsChatGPT App FrameworkMCP App TestingChatGPT App TestingClaude ConnectorsClaude Connector Framework
sunpeak lets you build MCP Apps that run across ChatGPT, Claude, and other MCP hosts from a single codebase.

sunpeak lets you build MCP Apps that run across ChatGPT, Claude, and other MCP hosts from a single codebase.

If you are building an MCP App in 2026, build the app as an MCP App first. ChatGPT Apps and interactive Claude Connectors are host-specific places your app can run, but the durable design is a tool-backed resource with a clear data contract, iframe-safe UI, explicit state sync, and tests that cover host differences.

TL;DR: Start with one typed tool result and one resource view. Return model-readable data in structuredContent, keep UI-only data in _meta, point the tool at a resource with _meta.ui.resourceUri, and render the UI through portable hooks such as useToolData, useHostContext, useDisplayMode, and useAppState. Use host-specific APIs only after this portable layer works. With sunpeak, npx sunpeak new gives you a local inspector, simulations, and Playwright tests for this flow.

The Architecture You Are Building

An MCP App has four parts:

  • An MCP server that exposes tools and resources.
  • A tool that validates input, runs server-side logic, and returns content, structuredContent, and _meta.
  • A resource, usually a web UI bundle, that renders in a sandboxed iframe.
  • A host bridge that passes tool data, host context, display mode, app state, and app actions between the iframe and the host.

That split matters because the model and the user do not see the same thing. The model sees tool output and app state you expose. The user sees the iframe. If the user filters a table, edits a draft, checks a box, or approves an action, your app must sync the selected state back through the bridge when the model needs to reason about it later.

The MCP Apps extension describes the app layer on top of MCP. The MCP specification still supplies the underlying tools, resources, transports, and result fields. OpenAI’s Apps SDK reference documents ChatGPT-specific compatibility fields on top of the same basic pattern.

Start With the Data Contract

Do not start with the UI. Start with the tool result shape, because that shape feeds both the model and the component.

For most apps, split tool output into three channels:

{
  "content": [
    {
      "type": "text",
      "text": "Found 12 open invoices for Acme."
    }
  ],
  "structuredContent": {
    "customer": "Acme",
    "openInvoiceCount": 12,
    "totalDue": 4812
  },
  "_meta": {
    "ui": {
      "resourceUri": "ui://invoices/summary.html"
    },
    "invoices": [
      {
        "id": "inv_001",
        "dueDate": "2026-07-15",
        "status": "open"
      }
    ]
  }
}

Use structuredContent for facts the model should read. Use content for fallback text and plain conversation status. Use _meta for app-only data the resource needs but the model should not inspect. That might include full row lists, IDs, UI configuration, pagination cursors, or data that is safe for the user but too noisy or too private for model context.

For a longer guide on those fields, read MCP App Tool Results.

Design Resources Around Views

The common early mistake is one tool, one resource. That feels tidy for a demo, but it creates duplicate UI once the app grows.

Design resources around views instead:

  • dashboard resource for overview, breakdown, and refresh tools.
  • invoice-review resource for search, detail, approval, and comment tools.
  • map resource for route planning, site lookup, and dispatch tools.

Tools are actions the model can call. Resources are screens the user can work in. Several tools can render the same resource with different structuredContent and _meta.

In sunpeak, the resource is a React component:

// src/resources/invoices/invoices.tsx
import { SafeArea, useAppState, useToolData } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';

type InvoiceSummary = {
  customer: string;
  openInvoiceCount: number;
  totalDue: number;
};

export const resource: ResourceConfig = {
  title: 'Invoices',
  description: 'Review open invoices for a customer',
};

export default function InvoicesResource() {
  const { output } = useToolData<unknown, InvoiceSummary>(undefined, undefined);
  const [status, setStatus] = useAppState('status', 'open');

  if (!output) return null;

  return (
    <SafeArea className="p-4">
      <h1>{output.customer}</h1>
      <p>{output.openInvoiceCount} open invoices</p>
      <strong>${output.totalDue.toLocaleString()}</strong>

      <label>
        Status
        <select value={status} onChange={(event) => setStatus(event.target.value)}>
          <option value="open">Open</option>
          <option value="overdue">Overdue</option>
          <option value="paid">Paid</option>
        </select>
      </label>
    </SafeArea>
  );
}

The matching tool is server-side:

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

export const tool: AppToolConfig = {
  resource: 'invoices',
  title: 'Show Invoices',
  description: 'Show open invoices for a customer',
  annotations: { readOnlyHint: true },
};

export const schema = {
  customerId: z.string().describe('Customer ID to look up'),
};

export default async function showInvoices(args: { customerId: string }) {
  return {
    structuredContent: {
      customer: args.customerId,
      openInvoiceCount: 12,
      totalDue: 4812,
    },
  };
}

The model sees a typed tool and a small result. The user sees the resource. The resource owns layout, state, and interaction.

Register Resource Metadata Explicitly

The app will not render unless the host can discover and trust the resource. Treat metadata as part of the contract, not build output detail.

At minimum, plan for:

  • Resource URI, usually a ui:// URI.
  • Human-readable title and description.
  • Content Security Policy fields for external API calls, frames, scripts, images, and assets.
  • Display preference, when the host supports it.
  • Any host-specific compatibility fields you need.

For portable MCP Apps, use the MCP Apps metadata as the base. For ChatGPT, you may also include fields OpenAI documents in the Apps SDK reference, such as _meta["openai/outputTemplate"], _meta["openai/widgetDescription"], and _meta["openai/widgetCSP"]. Those fields are useful when targeting ChatGPT, but they should not be the only way your app connects a tool to a resource if you care about other hosts.

For the current metadata shape and testing checklist, read MCP App Resource Metadata.

Keep the Core Portable

The portable core of an MCP App should avoid direct access to a single host’s global object or SDK until it has to.

In sunpeak, that core usually means:

import {
  AppProvider,
  SafeArea,
  useAppState,
  useCallServerTool,
  useDisplayMode,
  useHostContext,
  useSendMessage,
  useToolData,
  useUpdateModelContext,
} from 'sunpeak';

Use these for:

  • Reading tool input and output.
  • Reading host context such as theme, locale, viewport, and safe area.
  • Adapting to inline, picture-in-picture, and fullscreen display modes.
  • Syncing user choices back to the host.
  • Calling server tools from the UI.
  • Sending a follow-up message or selected context back into the conversation.

Then isolate host-specific features behind small adapters. If ChatGPT has a file upload or checkout flow your app needs, keep that import in a ChatGPT adapter. If Claude has a connector-specific behavior, keep that path separate. The resource can then ask, “is this feature available?” instead of assuming one host.

This keeps your fallback behavior clear. A host that does not support a feature still gets a working app, even if it misses one convenience path.

Make Display Modes a First-Class State

Display mode changes are not decoration. They change the task the user can complete.

Inline mode is good for summaries, status, and quick actions. Fullscreen is better for tables, maps, charts, editors, and multi-step workflows. Picture-in-picture can work for small persistent controls, but hosts may handle it differently on mobile or constrained screens.

Plan display modes in the component:

import { useDisplayMode } from 'sunpeak';

export function DashboardShell() {
  const { displayMode, requestDisplayMode } = useDisplayMode();

  if (displayMode === 'fullscreen') {
    return <FullDashboard />;
  }

  return (
    <SummaryCard
      onExpand={() => requestDisplayMode('fullscreen')}
    />
  );
}

Test each mode with real content, not placeholder text. Empty states, long labels, error messages, and filtered results often break layouts in inline mode first. For current host behavior, read the ChatGPT App Display Mode Reference and MCP App Host Context.

Treat App State as Model Context

Plain React state is invisible to the model. That is fine for local UI details such as an open menu. It is wrong for decisions the model needs to remember.

Use app state when the user’s choice should matter later:

  • Selected row, record, file, or map location.
  • Filter and sort choices.
  • Draft edits that need approval.
  • Checkbox selections for bulk actions.
  • A staged operation waiting for the model or server to continue.

Use private component state for UI-only details:

  • Hovered row.
  • Open dropdown.
  • Local animation state.
  • A temporary input value before the user commits it.

This separation makes later prompts work. If the user says “approve the selected invoices,” the host and model need a selected invoice list, not a hidden React array.

For a deeper walkthrough, read Interactive MCP Apps with useAppState.

Test Before You Connect a Live Host

MCP Apps fail at boundaries: schema to result, result to resource, resource to iframe, iframe to host bridge, host bridge to model context. A browser-only test misses most of those boundaries.

Start with simulation files. A simulation should describe the user message, the tool input, the tool result, the host context, and the display mode. Write one simulation for each meaningful state:

  • Happy path with realistic data.
  • Empty state.
  • Error state.
  • Cancelled or timed-out state.
  • Long text and overflow.
  • Each display mode you support.
  • Each host capability branch.

With sunpeak, pnpm dev opens the local inspector so you can switch hosts, themes, display modes, and tool states without using a live account. The same simulations feed Playwright tests:

import { expect, test } from 'sunpeak/test';

test('invoice summary renders in fullscreen', async ({ inspector }) => {
  const result = await inspector.renderTool('show-invoices', {
    displayMode: 'fullscreen',
  });

  await expect(result.app().getByText('open invoices')).toBeVisible();
});

Use local tests for broad coverage. Use live-host tests narrowly, after the deterministic suite is already green, to confirm deployment, auth, model tool choice, and host-specific behavior. For the full plan, read the complete guide to testing ChatGPT Apps and MCP Apps.

Build in This Order

This order keeps the app portable while still letting you ship host-specific polish:

  1. Define the tool result shape and output schema.
  2. Decide what belongs in structuredContent, content, and _meta.
  3. Create simulations for success, empty, error, and display modes.
  4. Build the resource against portable hooks.
  5. Wire tools to resources.
  6. Add E2E and visual tests against the local inspector.
  7. Add host-specific features behind capability checks.
  8. Run narrow live-host tests.
  9. Deploy the MCP server and resource bundles.

The important part is step 7. Host-specific code is useful, but it should not leak through the whole resource. Keep the common path plain, testable, and easy to reason about.

What sunpeak Handles

You can build all of this with raw MCP server code, a bundler, an iframe resource, and host-specific metadata. The work is manageable for a small demo and gets repetitive as soon as you add tests, display modes, host branches, and multiple resources.

sunpeak handles the repetitive parts:

  • npx sunpeak new scaffolds a project with tools, resources, simulations, and tests.
  • pnpm dev runs a local inspector with replicated ChatGPT and Claude runtimes.
  • Resource and tool files are discovered from src/resources and src/tools.
  • Simulation files make host states reproducible.
  • Playwright fixtures render resources through the inspector.
  • Test commands cover unit, E2E, visual, integration, live-host, and eval flows.

That does not remove the need to design your data contract. It gives you a shorter loop for checking whether the contract works across hosts.

Start with the MCP App tutorial if you want a hands-on build. Read What Is an MCP App? if you want the protocol model first. When you are ready to test the real thing, use the MCP App Inspector and keep the live-host loop small.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

How do I build an MCP App that works across multiple AI hosts?

Build the core app against the MCP Apps model: tools return content, structuredContent, and app metadata, while resources render UI in a sandboxed iframe. Keep portable code in standard hooks such as useToolData, useHostContext, useDisplayMode, useAppState, useCallServerTool, useSendMessage, and useUpdateModelContext. Add host-specific features only after the portable path works.

What is the difference between an MCP server and an MCP App?

An MCP server exposes tools, resources, and prompts to a host. An MCP App connects a tool result to an interactive resource, so the model receives structuredContent and the user receives a UI. The app still depends on an MCP server, but the resource adds forms, dashboards, maps, review screens, editors, and other interactive views.

Can I add an MCP App to an existing MCP server?

Yes. Keep the existing tool logic, add a resource bundle, and return metadata that tells the host which resource to render. In the MCP Apps extension, the portable field is _meta.ui.resourceUri. ChatGPT integrations may also include compatibility fields such as openai/outputTemplate and openai/widgetCSP when targeting ChatGPT-specific behavior.

What should go in structuredContent, content, and _meta?

Put model-readable data in structuredContent, fallback prose or status text in content, and app-only data in _meta. Do not put secrets, raw API payloads, or UI-only blobs in structuredContent because the model can read it. If the component needs private display data, keep it in _meta and render it in the resource.

How should I design resources and tools in an MCP App?

Design resources around views and tools around model-callable actions. One resource can serve multiple tools when those tools feed the same screen with different data. For example, get_overview, get_breakdown, and refresh_data can all render a dashboard resource instead of creating three duplicated dashboard resources.

How do I make an MCP App interactive?

Use app state and bridge actions. useAppState syncs user selections back to the host so the model can reference them later. useCallServerTool lets the UI call server tools, useSendMessage can send a follow-up message into the conversation, and useUpdateModelContext can expose selected structured context to the model.

How do I test an MCP App before connecting it to ChatGPT or Claude?

Use deterministic simulation files for tool inputs, tool results, display modes, themes, host context, empty states, errors, and cancelled states. sunpeak's inspector renders those simulations in replicated ChatGPT and Claude runtimes, and its Playwright fixtures let you run E2E, visual, integration, and live-host tests in CI without spending host credits on every change.

What is the fastest safe build order for an MCP App?

Define the tool result shape first, create simulations next, build the resource against portable hooks, add tests, then add host-specific features. This order keeps the core app portable and makes ChatGPT-specific or Claude-specific APIs optional layers instead of assumptions spread through the component tree.