All posts

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

Abe Wheeler
MCP Apps MCP App Framework ChatGPT Apps ChatGPT App Framework MCP App Testing ChatGPT App Testing Claude Connectors Claude Connector Framework
sunpeak lets you build MCP Apps that run across ChatGPT, Claude, Goose, and VS Code from a single codebase.

sunpeak lets you build MCP Apps that run across ChatGPT, Claude, Goose, and VS Code from a single codebase.

Here’s how I’d approach building an MCP App, not for a single host, but for the standard. The goal is an app that ships to ChatGPT, Claude, Goose, VS Code, and more from the same codebase, with room to add host-specific polish without breaking that portability.

TL;DR: Use sunpeak and build your core against the MCP App standard APIs (useToolData, useHostContext, useDisplayMode, useAppState). Add host-specific features through subpath imports after your portable base works. Test with simulation files that validate against the standard runtime.

Build to the Standard, Not to a Host

The first architectural decision, and the one that shapes everything else, is whether to build for a specific host or for the MCP App standard.

Building for ChatGPT first and adding Claude support later is technically possible, but it means you’ll write host-specific code throughout your components and then surgically remove it when you need to generalize. The code ends up tangled.

The better approach: build to the standard first. The ext-apps specification (now at v1.7.0 with 2,100+ GitHub stars) defines a common iframe sandbox, a JSON-RPC 2.0 communication layer over window.postMessage, and a set of context APIs that every host implements identically. ChatGPT, Claude, Goose, VS Code, Postman, and MCPJam all implement the same standard, with JetBrains IDEs exploring integration. Code written against it runs on all of them without changes.

Host-specific features then become additions, not rewrites. You progressively enhance for each host without touching the portable core.

Adding UI to an Existing MCP Server

If you already have an MCP server with tools, you don’t need to rebuild it. An MCP App is an extension of your existing server: you add a ui field to a tool’s _meta pointing at a hosted resource bundle, and the host renders that bundle in a sandboxed iframe when the tool runs.

With sunpeak, this looks like:

  1. Add sunpeak to your project
  2. Create a resource component in src/resources/
  3. Run pnpm build to generate the bundle
  4. Point your tool’s _meta UI field at the bundle URL

sunpeak’s MCP server handles resource discovery, bundle registration, and host communication automatically. If you’re starting from scratch rather than adding to an existing server, npx sunpeak new scaffolds a complete project in one command:

npx sunpeak new

The scaffolded project includes a working resource, tool, simulation file, and test setup out of the box.

Design Resources Around Views, Not Tools

The most common MCP App design mistake is a one-to-one mapping of Tools to Resources. It seems natural, one tool, one UI, but it creates problems quickly: duplicate components, shared state that lives in neither, and resource registration bloat.

Instead, design Resources around views: the distinct interfaces a user interacts with. A Resource is a screen. A Tool is what causes that screen to appear, or what that screen calls to get data.

A dashboard app might have:

  • One Resource: DashboardResource
  • Three Tools: get_overview, get_breakdown, refresh_data

All three tools render DashboardResource, just with different data. The component handles the display variation; the tools handle the data variation.

This keeps your resource component count manageable and your shared UI logic in one place. For a full breakdown of how MCP concepts map to app architecture, see MCP Concepts Explained.

What’s Portable vs. What’s Host-Specific

The MCP App standard defines these APIs identically across hosts:

import {
  useToolData,       // read tool output data
  useHostContext,    // read host environment (locale, theme, user agent)
  useDisplayMode,   // read and request display mode changes
  useAppState,      // sync interactive state to the host and model
  AppProvider,       // wrap your app for host context
} from 'sunpeak';

import type { ResourceConfig } from 'sunpeak';

Code that only uses these imports runs on every MCP App host unchanged. That’s your portable core.

Beyond reading data, the standard also defines action hooks for two-way interaction:

import {
  useCallServerTool,    // call an MCP tool from the UI
  useSendMessage,       // send a follow-up message to the conversation
  useUpdateModelContext, // push structured data to the model's context
} from 'sunpeak';

These let your app do more than display information. A form submission can call a tool on the MCP server, a status change can send a message back into the conversation, and updated context can inform the model’s next response. All of this works identically across hosts.

The Tool that triggers a resource is also portable. Tools live in src/tools/*.ts and import types from sunpeak/mcp:

// src/tools/get-overview.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
  resource: 'dashboard',
  title: 'Get Overview',
  description: 'Show analytics overview for a time range',
  annotations: { readOnlyHint: true },
};

export const schema = {
  timeRange: z.string().describe('Time range (e.g. "7d", "30d")'),
};

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

export default async function (args: Args, _extra: ToolHandlerExtra) {
  return { structuredContent: { visits: 4218, conversions: 83, bounceRate: 0.41 } };
}

Multiple tools can share a single resource. A get_overview, get_breakdown, and refresh_data tool can all render the same DashboardResource with different data.

Host-specific features come from subpath imports:

import { useUploadFile, useRequestModal } from 'sunpeak/chatgpt';

The rule: if a feature only makes sense on one host, it belongs in a subpath import. If it works the same everywhere, it belongs in the core. Use isChatGPT() from sunpeak/chatgpt to guard platform-specific code paths. For the full list of portable hooks and types, see the runtime APIs reference and core types.

Handle Display Modes Portably

Every MCP App host supports three display modes: inline, fullscreen, and picture-in-picture (PiP). The mode changes how much screen space your app has, and your UI needs to adapt.

useDisplayMode gives you the current mode:

import { useDisplayMode } from 'sunpeak';

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

  return (
    <div>
      {displayMode === 'inline' && (
        <button onClick={() => requestDisplayMode('fullscreen')}>
          Expand
        </button>
      )}
      {displayMode === 'fullscreen' && <FullDashboard />}
      {displayMode === 'inline' && <SummaryCard />}
    </div>
  );
}

This works identically on ChatGPT, Claude, and every other host. One thing to note: ChatGPT promotes PiP to fullscreen on mobile, so test both paths if your app uses picture-in-picture. For a full reference on display mode behavior across hosts, see the ChatGPT App Display Mode Reference.

Design your Resource components with display mode in mind from the start. An inline view that ignores the fullscreen mode is a missed opportunity. Most users won’t know to expand your app if you don’t give them a reason to.

Make It Interactive with useAppState

Read-only MCP Apps display data from tool results. Interactive MCP Apps let users take actions, and useAppState is how you build them.

useAppState works like React’s useState, but it syncs state back to the host. When a user clicks a button or fills a form, the updated state becomes visible to the AI model, which means the model can reference what the user did in its next response.

import { useAppState, useToolData } from 'sunpeak';

export default function FilterableList() {
  const { structuredContent } = useToolData();
  const [filter, setFilter] = useAppState('filter', 'all');

  const items = structuredContent.items.filter(
    item => filter === 'all' || item.category === filter
  );

  return (
    <div>
      <select value={filter} onChange={e => setFilter(e.target.value)}>
        <option value="all">All</option>
        <option value="active">Active</option>
        <option value="completed">Completed</option>
      </select>
      <ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>
    </div>
  );
}

The model sees that the user filtered to “active” and can use that context in follow-up messages. This is how MCP Apps go from static dashboards to proper interactive tools. For a deeper walkthrough, see Interactive MCP Apps with useAppState.

Test Against the Standard

MCP App testing is different from regular web app testing because your UI depends on host-injected data and runs inside a sandboxed iframe. You can’t test by opening a file in a browser.

sunpeak’s answer is simulation files: JSON files that define deterministic UI states against the standard runtime.

{
  "tool": "get_dashboard",
  "userMessage": "Show me last week's analytics",
  "toolInput": { "timeRange": "7d" },
  "toolResult": {
    "structuredContent": {
      "visits": 4218,
      "conversions": 83,
      "bounceRate": 0.41
    }
  }
}

These feed multiple test types:

pnpm test          # unit + e2e tests
pnpm test:visual   # visual regression tests
pnpm test:live     # live host validation
pnpm test:eval     # multi-model evals (GPT-4o, Claude, Gemini)

Because simulations test against the standard runtime, not against ChatGPT or Claude specifically, tests that pass locally validate your app for every MCP App host. You don’t need a paid account on any AI host to get comprehensive coverage, nor do you have to burn credits testing.

The local inspector at localhost:3000 replicates both the ChatGPT and Claude runtimes, so you can toggle between hosts with a dropdown or the ?host=claude URL parameter. It also includes a sidebar showing tool input, tool output, display mode, and theme values for debugging.

Write one simulation file per meaningful UI state: happy path, empty state, error state, each meaningful display mode. For a full walkthrough, see the complete guide to testing MCP Apps, or check the more focused guides on unit testing, E2E testing, and visual regression testing.

The Build Order for Cross-Host Apps

Building for multiple hosts from day one doesn’t require more work. It just requires doing things in the right order:

  1. Define your data shape. What does each tool return? Type it before building anything.
  2. Write simulation files. One per UI state. Cover all display modes that matter.
  3. Build Resource components against the portable core. Get these working in the inspector first using useToolData, useHostContext, useDisplayMode, and useAppState.
  4. Write tests. Unit tests with Vitest, E2E tests with Playwright. Both run against the inspector, so they validate every host at once.
  5. Add host-specific enhancements. Runtime features specific to ChatGPT via sunpeak/chatgpt, Claude-specific features via sunpeak/claude. Validate each manually.
  6. Connect to real hosts. ChatGPT via ngrok and the Connector modal. Claude through its MCP connector settings.

Step 3 before step 5 is the one that prevents host lock-in. If you add host-specific code before your portable layer works, you’ll build against it by habit and then have to untangle it when you need to support another host.

For a quicker path from zero to working app, the MCP App Tutorial walks through the full setup in about 15 minutes.

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 against the MCP App standard (ext-apps) instead of any single host. Use sunpeak and keep your core app code in the standard APIs: useToolData, useHostContext, useDisplayMode, useAppState, and ResourceConfig. These work identically on ChatGPT, Claude, Goose, VS Code, Postman, and MCPJam. Add host-specific features through subpath imports (sunpeak/chatgpt, sunpeak/claude) without modifying your portable base code.

What is the difference between building an MCP App and building for a specific host like ChatGPT?

Building for a specific host ties you to that host's proprietary APIs. Building to the MCP App standard means your app runs on every host that implements it. The ext-apps specification (now at v1.7.0 with 2,100+ GitHub stars) defines a common rendering model, iframe sandbox, and JSON-RPC 2.0 communication protocol. sunpeak implements this standard and lets you add host-specific enhancements through separate imports.

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

Yes. If you already have an MCP server with tools, you can add an MCP App to any tool by including a ui field in its _meta pointing at a resource bundle. The resource is an HTML/JavaScript bundle that renders inside a sandboxed iframe when the tool is invoked. sunpeak handles the resource registration via registerAppResource, bundling, and host communication automatically.

What stays the same across all MCP App hosts?

The iframe sandbox model, the JSON-RPC 2.0 communication protocol (window.postMessage), tool data injection (useToolData), host context reading (useHostContext), display mode handling (useDisplayMode), interactive state management (useAppState), and action hooks like useCallServerTool and useSendMessage are all standardized across hosts. Your resource components built against these APIs run identically on ChatGPT, Claude, Goose, VS Code, Postman, and MCPJam.

What is different between ChatGPT and Claude for MCP Apps?

The core rendering model and communication protocol are the same. Differences include UI component conventions (each host has its own design patterns), platform-specific runtime APIs (ChatGPT offers useUploadFile, useRequestCheckout, and useRequestModal), and minor display mode behavior (ChatGPT promotes picture-in-picture to fullscreen on mobile). sunpeak exposes these differences through subpath imports so your core app code stays portable.

How do I make my MCP App interactive with useAppState?

useAppState syncs component state back to the host and makes it visible to the AI model. When a user interacts with your app (clicks a button, fills a form), the updated state is available for the model to reference in follow-up responses. Import useAppState from sunpeak and use it like useState. It handles the host synchronization automatically.

How do I test an MCP App across multiple hosts?

sunpeak's simulation files define deterministic UI states against the MCP App standard. Tests run against the local inspector, which implements the standard runtime for both ChatGPT and Claude. Run pnpm test for unit and e2e tests, pnpm test:visual for visual regression, pnpm test:live for live host validation, and pnpm test:eval for multi-model evals across GPT-4o, Claude, and Gemini. No paid host accounts or credits needed.

How do I add an MCP App to an existing MCP server without using sunpeak?

Add a ui field to your tool _meta with a uri pointing at a hosted HTML/JavaScript bundle using the ui:// URI scheme. The bundle must implement the ext-apps communication protocol: listen for postMessage events from the host, read tool output from the injected context, and respond via JSON-RPC 2.0. You also need to handle display modes, themes, Content Security Policy, and the postMessage security model manually. sunpeak automates all of this.