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

sunpeak lets you build MCP Apps that run across ChatGPT, Claude, and other MCP hosts from a single codebase.
An MCP App adds an interactive view to an MCP server. The server still defines tools and returns useful data, while a compatible host can read a linked HTML resource and show it in an iframe. That split lets you keep one tool contract for ChatGPT, Claude, and other MCP Apps hosts that support the view.
TL;DR: Start with a useful MCP tool and an outputSchema for its structuredContent. Put _meta.ui.resourceUri on the descriptor of a tool that should render UI, register the ui:// HTML resource, and use the MCP Apps bridge for interaction. Test the protocol, fixture states, and a real server call before checking each target host. sunpeak can provide the local inspector and test harness.
The Architecture You Are Building
An MCP App has four parts:
- An MCP server that exposes tools and resources.
- A tool descriptor with schemas, annotations, and an optional UI resource link, plus a handler that validates input and returns results.
- 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 reads the tool’s content and structuredContent. The user may also see an iframe. If a user changes a filter or selects a record, share the selected value through ui/update-model-context only when the model needs it. Keep temporary visual details in the component.
The MCP Apps extension defines tool-to-view linkage and the iframe bridge. The MCP server still owns tool validation, authorization, and resource reads. OpenAI’s ChatGPT UI guide recommends the shared MCP Apps fields first, then optional ChatGPT extensions behind capability checks.
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.
First link the render tool to a resource in its descriptor returned by tools/list:
{
"name": "show-invoices",
"description": "Show open invoices for a customer",
"_meta": {
"ui": { "resourceUri": "ui://invoices/summary.html" }
}
}
The handler returns data separately. For most apps, split its result into three channels:
{
"content": [
{
"type": "text",
"text": "Found 12 open invoices for Acme."
}
],
"structuredContent": {
"customer": "Acme",
"openInvoiceCount": 12,
"totalDue": 4812
},
"_meta": { "nextCursor": "page-2" }
}
Use structuredContent for facts the model should read and content for short fallback text. Result _meta can carry UI-only helper data such as a pagination cursor when the host forwards it to the view. Never put API keys or other server secrets in any field sent to the host or iframe. Declare outputSchema for structuredContent, and keep private business records behind server-side authorization. The descriptor’s resource URI does not belong in the result’s _meta.
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:
dashboardresource for overview, breakdown, and refresh tools.invoice-reviewresource for search, detail, approval, and comment tools.mapresource for route planning, site lookup, and dispatch tools.
Tools define actions; resources are screens the user can work in. Several render tools can share one resource when they provide the same data shape. Keep data-only tools separate when their results do not need a view. OpenAI’s UI guide recommends this split because attaching a resource to every data call can reopen the iframe too often.
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>();
const [state, setState] = 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={state.status}
onChange={(event) => setState({ status: 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 const outputSchema = {
customer: z.string(),
openInvoiceCount: z.number().int().min(0),
totalDue: z.number().nonnegative(),
};
export default async function showInvoices(args: { customerId: string }) {
return {
content: [{ type: 'text' as const, text: 'Found 12 open invoices.' }],
structuredContent: {
customer: args.customerId,
openInvoiceCount: 12,
totalDue: 4812,
},
};
}
The model sees a typed tool and a small result. The user sees the resource. useAppState makes the selected status visible to the model; for a visual-only control, use React state instead. The sunpeak tool’s resource field generates the UI link in the descriptor.
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.
text/html;profile=mcp-appas the returned resource MIME type.- 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.
Put _meta.ui.resourceUri on the tool descriptor and resource metadata such as _meta.ui.csp and _meta.ui.permissions on the resources/read content item. The host reads the URI to fetch the HTML resource before it can render the view. ChatGPT may also need a compatibility alias such as _meta["openai/outputTemplate"] for an existing integration, but new UI should use the shared field as the source of truth. Declare only the origins and permissions the view uses.
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 {
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 fallback behavior clear. A host that does not support an optional feature can still get a useful tool result and working core UI.
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, useRequestDisplayMode } from 'sunpeak';
export function DashboardShell() {
const displayMode = useDisplayMode();
const { requestDisplayMode, availableModes } = useRequestDisplayMode();
if (displayMode === 'fullscreen') {
return <FullDashboard />;
}
return (
<SummaryCard
onExpand={availableModes?.includes('fullscreen')
? () => requestDisplayMode('fullscreen')
: undefined}
/>
);
}
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 committed choice should matter later:
- Selected row, record, file, or map location.
- Filter and sort choices.
- A submitted draft that needs 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 the selected invoice IDs. Keep unsent form text and secrets out of model context. useAppState updates context for the next turn; store durable account data on your server.
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, descriptor to resource, resource to iframe, iframe to host bridge, and host bridge to model context. Test each boundary separately. Start with tools/list and confirm the render tool has _meta.ui.resourceUri; call the tool and validate structuredContent against outputSchema; then read the URI and check for HTML with the MCP Apps MIME type.
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. A small simulation can pin the view data:
{
"tool": "show-invoices",
"toolInput": { "customerId": "acme" },
"toolResult": {
"content": [{ "type": "text", "text": "Found 12 open invoices." }],
"structuredContent": {
"customer": "Acme",
"openInvoiceCount": 12,
"totalDue": 4812
}
}
}
The same simulation feeds a Playwright test:
import { expect, test } from 'sunpeak/test';
test('invoice summary renders in fullscreen', async ({ inspector }) => {
const result = await inspector.renderTool('show-invoices', undefined, {
displayMode: 'fullscreen',
});
expect(result.source).toBe('fixture');
await expect(result.app().getByText('12 open invoices')).toBeVisible();
});
The fixture proves rendering, but it does not call the handler. Pass tool input to exercise the real server and check the result source:
test('invoice summary renders a real tool result', async ({ inspector }) => {
const result = await inspector.renderTool('show-invoices', { customerId: 'acme' });
expect(result.source).toBe('server');
expect(result).not.toBeError();
await expect(result.app().getByText('12 open invoices')).toBeVisible();
});
If the view calls another server tool, mock that response in the fixture’s serverTools field and cover a real follow-up call on the server path. Use live-host tests narrowly, after the deterministic suite is green, to confirm deployment, auth, tool choice, and optional host behavior. The sunpeak E2E guide explains the two render paths.
Build in This Order
This order keeps the app portable while still letting you ship host-specific polish:
- Define the tool result shape and output schema.
- Decide what belongs in
structuredContent,content, and_meta. - Create simulations for success, empty, error, and display modes.
- Build the resource against portable hooks.
- Wire tools to resources.
- Add E2E and visual tests against the local inspector.
- Add a real server-backed render test and check the source.
- Add host-specific features behind capability checks.
- Run narrow live-host tests.
- Deploy the MCP server and resource bundles.
The important part is step 8. 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 newscaffolds a project with tools, resources, simulations, and tests.pnpm devruns a local inspector with replicated ChatGPT and Claude runtimes.- Resource and tool files are discovered from
src/resourcesandsrc/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
npx sunpeak newFurther Reading
- What Is an MCP App? - architecture, hosts, metadata, and examples
- MCP App Tutorial - build and test your first MCP App
- MCP App Resource Metadata - CSP, permissions, and host fields
- MCP App Tool Results - content, structuredContent, and _meta
- MCP App Actions - callServerTool, sendMessage, and updateModelContext
- MCP App Capability Detection - host features, fallbacks, and tests
- MCP App Tool Metadata - resource URI and app-only visibility
- E2E Testing MCP Apps - fixture and server paths
- MCP App Framework - portable framework for ChatGPT and Claude
- MCP App Inspector - test MCP Apps locally
- MCP Testing Framework - automated tests for MCP Apps
- sunpeak Docs - quickstart, framework APIs, inspector, and testing
- MCP Apps Extension - current app UI model
- OpenAI - add UI to an MCP server for ChatGPT
- MCP Apps SDK - tool and resource registration
- Claude - connect a remote MCP server
Frequently Asked Questions
How do I build an MCP App that works across multiple AI hosts?
Build an MCP server with tools that return content and structuredContent. Link only tools that need UI to a ui:// resource through _meta.ui.resourceUri on the tool descriptor. The host reads that resource, renders it in a sandboxed iframe, and sends tool data through the MCP Apps bridge. Test the shared protocol in each host you plan to support; optional host capabilities vary.
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 adds an interactive UI resource linked from a tool descriptor, so a compatible host can show an iframe alongside the tool result. The same server tools should remain useful in a client that does not render UI.
Can I add an MCP App to an existing MCP server?
Yes. Keep the existing server and tool logic, register an HTML resource with MIME type text/html;profile=mcp-app, and add _meta.ui.resourceUri to the descriptor of each tool that should open it. The URI must resolve through resources/read. Add ChatGPT-specific aliases only where an existing integration needs them.
What should go in structuredContent, content, and _meta?
Put model-readable JSON in structuredContent and short fallback text in content. Tool-result _meta can carry UI-only data that the host forwards to the view, but never put API tokens or other server secrets there. Declare outputSchema for structuredContent, and keep private business data behind server-side authorization.
How should I design resources and tools in an MCP App?
Design resources around views and tools around actions. One resource may serve more than one render tool, but data-only tools should work without a UI link. For example, search_invoices can return IDs and summaries, while show_invoices can open an invoice view after the model chooses which records matter.
How do I make an MCP App interactive?
Keep temporary visual state in React state. Use useAppState or ui/update-model-context when a user choice should be visible to the model, and keep durable account data on the server. The UI can call server tools through tools/call and send a follow-up message through ui/message when the host allows it.
How do I test an MCP App before connecting it to ChatGPT or Claude?
Test tools/list, tools/call, and resources/read before rendering. Use deterministic simulations for empty, error, and layout states, then pass input to inspector.renderTool to exercise the real server and assert result.source is server. Finish with a small pass in each target host for authentication, tool choice, and optional UI capabilities.
What is the fastest safe build order for an MCP App?
Define the user task and tool contracts first. Register the UI resource and link only the render tool, create fixtures, build the view, then test the protocol and both fixture and real server paths. Add host-specific capabilities behind checks, validate in the target hosts, and deploy the server with its resource bundle.