How to Build an MCP App: Architecture for Cross-Host Interactive UI
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, and VS Code 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). 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 MCP App protocol defines a common iframe sandbox, a JSON-RPC 2.0 communication layer, and a set of context APIs that every host implements identically. ChatGPT, Claude, Goose, and VS Code all implement the same standard. Code written against it runs on all four 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 metadata pointing at a hosted resource bundle, and the host renders that bundle in an iframe when the tool runs.
With sunpeak, this looks like:
- Add sunpeak to your project
- Create a resource component in
src/resources/ - Run
sunpeak buildto generate the bundle - Point your tool’s
uimetadata 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, sunpeak new scaffolds a complete project in one command:
pnpm add -g sunpeak && sunpeak new
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.
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
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.
Host-specific features come from subpath imports:
import { /* ChatGPT-specific APIs */ } 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.
Handle Display Modes Portably
Every MCP App host supports at least three display modes: inline, fullscreen, and picture-in-picture. The mode changes how much screen space your app has, and your UI needs to adapt.
useDisplayMode gives you the current mode and a function to request a mode change:
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. The available modes and their exact pixel dimensions differ by host, but the API for reading and requesting them is the same.
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.
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": {
"name": "get_dashboard",
"description": "Get analytics dashboard"
},
"toolInput": { "timeRange": "7d" },
"toolResult": {
"visits": 4218,
"conversions": 83,
"bounceRate": 0.41
},
"userMessage": "Show me last week's analytics"
}
These feed Vitest for unit tests and Playwright for end-to-end tests:
pnpm test # unit tests
pnpm test:e2e # end-to-end against the local simulator
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.
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.
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:
- Define your data shape. What does each tool return? Type it before building anything.
- Write simulation files. One per UI state. Cover all display modes that matter.
- Build Resource components against the portable core. Get these working in the simulator first.
- Write tests. Validate all simulation states. These tests work for every host.
- Add host-specific enhancements. Runtime features specific to ChatGPT via
sunpeak/chatgpt, Claude-specific features viasunpeak/claude. Validate each manually. - 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.
Start Building
sunpeak is open source (MIT) and free:
pnpm add -g sunpeak && sunpeak new
Your app runs on ChatGPT, Claude, Goose, and VS Code Insiders from the first line of code.
- Documentation — guides, API reference, and component docs
- GitHub — source code and issues
- MCP App Framework — cross-host portability features
- ChatGPT App Framework — ChatGPT-specific capabilities
- What Is an MCP App? — architecture deep-dive
- How to Build a ChatGPT App — ChatGPT-focused build plan
Frequently Asked Questions
How do I build an MCP App that works across multiple AI hosts?
Use sunpeak and keep your core app code in the standard MCP App APIs: useToolData, useHostContext, useDisplayMode, and ResourceConfig. These work identically on ChatGPT, Claude, Goose, and VS Code Insiders. 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 standard defines a common rendering model, iframe sandbox, and 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 adding a ui field to its metadata pointing at a resource bundle. The resource is an HTML/JavaScript bundle that renders inside an iframe when the tool is invoked. sunpeak handles the resource registration, 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), and display mode handling (useDisplayMode) are all standardized across hosts. Your resource components built against these APIs run identically on ChatGPT, Claude, Goose, and VS Code.
What is different between ChatGPT and Claude for MCP Apps?
The core rendering model is the same. Differences include UI component conventions (each host has its own design patterns), display mode availability (both support inline, fullscreen, and picture-in-picture), and platform-specific runtime APIs. sunpeak exposes these differences through subpath imports (sunpeak/chatgpt) so your core app code stays portable.
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 simulator, which implements the standard runtime. Since all hosts implement the same standard, tests that pass against the simulator are valid for ChatGPT, Claude, Goose, and VS Code. For host-specific behavior, add targeted manual validation against each host.
How do I add an MCP App to an existing MCP server without using sunpeak?
Add a ui field to your tool metadata with a uri pointing at a hosted HTML/JavaScript bundle on the same server. The bundle must implement the MCP App 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, and the postMessage security model manually. sunpeak automates all of this.
What should go in a Resource versus a Tool for an MCP App?
A Resource is a UI view — one screen, one component, one piece of your app's interface. A Tool is an action or query that causes that view to render. Design one Resource per distinct user-facing interface (a dashboard, a form, a data explorer), and one Tool per distinct data state or action that drives it. Avoid one-Resource-per-tool mapping for complex apps: it leads to redundant components and hard-to-maintain code.