Build an MCP App with React: Hooks, Tool Data, and Host Styles

A React MCP App connects a bundled view to tool data and host context through the MCP Apps bridge.
React is a natural fit for an MCP App because the view is an interactive web interface. The confusing part is everything around the component. The app does not receive its first data through props or a normal API route. An AI host creates the iframe, connects a bridge, and sends tool input, tool results, host context, and lifecycle events.
This guide builds that mental model first, then shows the React code you need for a reliable app.
TL;DR: Use @modelcontextprotocol/ext-apps/react when you want the official low-level React integration. Register bridge event handlers in onAppCreated, copy validated tool data into React state, call useHostStyles after the app connects, and send server work through app.callServerTool. Treat connection, loading, error, cancellation, and unsupported capability states as normal UI states. Bundle the view as an MCP App HTML resource, then test it inside a host runtime rather than opening the HTML by itself.
React Owns the View, Not the Whole MCP App
An MCP App has three boundaries:
| Part | Runs where | Owns |
|---|---|---|
| MCP server | Your server process | Tools, resources, schemas, auth, database access |
| AI host | ChatGPT, Claude, or another MCP Apps client | Tool selection, iframe policy, bridge, host capabilities |
| React view | Sandboxed iframe | Rendering, local interaction, app-to-host requests |
React only owns the third row. It should not hold service credentials, trust model-generated arguments, or write directly to a protected database.
The normal first-render path looks like this:
Model chooses a tool
-> MCP server runs the tool
-> Tool returns content and structuredContent
-> Host loads the linked ui:// resource
-> React view connects to the host
-> Host sends tool input and tool result
-> React renders structuredContent
The view and server are separate builds even when they live in one repository. The server returns the React build as an HTML resource, but the invocation data arrives through the bridge.
Install the React SDK Layer
For the current direct TypeScript stack, install React, the base MCP SDK, and the MCP Apps extension:
npm install react react-dom zod @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps
npm install -D typescript vite @vitejs/plugin-react
@modelcontextprotocol/ext-apps/react is a subpath export of @modelcontextprotocol/ext-apps. Do not install it as a separate package.
The official React entry point currently exports:
useAppto create and connect the view-sideAppuseHostStyles,useHostStyleVariables, anduseHostFontsuseDocumentThemefor reactive document theme stateuseAutoResizefor the less common case where automatic resize is disabled
The core App class remains available through the React entry point, so your event handlers and button actions use the same typed bridge methods as a plain JavaScript view.
Link the Tool to the React Resource
The server must point the UI tool at the same resource URI that it registers:
import fs from 'node:fs/promises';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from '@modelcontextprotocol/ext-apps/server';
const server = new McpServer({
name: 'inventory-app',
version: '1.0.0',
});
const resourceUri = 'ui://inventory/summary-v1.html';
registerAppTool(
server,
'show-inventory',
{
title: 'Show inventory',
description: 'Show current inventory for one warehouse',
inputSchema: { warehouseId: z.string() },
_meta: {
ui: { resourceUri },
},
},
async ({ warehouseId }) => {
const items = await loadInventory(warehouseId);
return {
content: [
{
type: 'text',
text: `Found ${items.length} inventory items.`,
},
],
structuredContent: {
warehouseId,
items,
},
};
}
);
registerAppResource(
server,
'Inventory summary',
resourceUri,
{},
async () => ({
contents: [
{
uri: resourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: await fs.readFile(
new URL('./dist/inventory.html', import.meta.url),
'utf8'
),
},
],
})
);
The values that must agree are:
_meta.ui.resourceUrion the tool descriptor- The URI passed to
registerAppResource - The
urireturned by the resource handler - The
text/html;profile=mcp-appMIME type
If the tool works but React never appears, inspect these values before debugging the component.
Mount React Like a Client Application
The HTML resource needs a root element and the built React entry:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>Inventory</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
The TypeScript entry is normal React:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { InventoryApp } from './InventoryApp';
import './styles.css';
const root = document.getElementById('root');
if (!root) {
throw new Error('Missing #root element');
}
createRoot(root).render(
<StrictMode>
<InventoryApp />
</StrictMode>
);
Your production build must make every script and stylesheet reachable from the iframe. A self-contained HTML bundle is simple to serve as one MCP resource. External scripts, styles, fonts, and images need absolute URLs plus the matching resource CSP domains. Relative Vite development paths are not a production resource strategy.
Connect Before You Render Tool Data
useApp creates the App, opens a postMessage transport to the parent host, and performs the initialization handshake.
Register event handlers in onAppCreated. That callback runs after the App exists and before it connects, so early tool notifications cannot arrive before your handlers are ready.
import { useEffect, useState } from 'react';
import {
useApp,
useHostStyles,
type McpUiHostContext,
} from '@modelcontextprotocol/ext-apps/react';
type InventoryItem = {
sku: string;
name: string;
available: number;
};
type InventoryData = {
warehouseId: string;
items: InventoryItem[];
};
export function InventoryApp() {
const [data, setData] = useState<InventoryData>();
const [hostContext, setHostContext] = useState<McpUiHostContext>();
const [cancelled, setCancelled] = useState(false);
const [runtimeError, setRuntimeError] = useState<string>();
const { app, isConnected, error } = useApp({
appInfo: {
name: 'inventory-view',
version: '1.0.0',
},
capabilities: {},
onAppCreated: (createdApp) => {
createdApp.ontoolresult = (result) => {
setCancelled(false);
const next = result.structuredContent as InventoryData | undefined;
if (!next || !Array.isArray(next.items)) {
setRuntimeError('The server returned invalid inventory data.');
return;
}
setRuntimeError(undefined);
setData(next);
};
createdApp.ontoolcancelled = () => {
setCancelled(true);
};
createdApp.onhostcontextchanged = (patch) => {
setHostContext((current) => ({ ...current, ...patch }));
};
createdApp.onerror = (nextError) => {
setRuntimeError(nextError.message);
};
},
});
useEffect(() => {
if (app) {
setHostContext(app.getHostContext());
}
}, [app]);
useHostStyles(app, app?.getHostContext());
if (error) {
return <Status role="alert">Could not connect to the host.</Status>;
}
if (!isConnected) {
return <Status>Connecting...</Status>;
}
if (cancelled) {
return <Status>The inventory request was cancelled.</Status>;
}
if (runtimeError) {
return <Status role="alert">{runtimeError}</Status>;
}
if (!data) {
return <Status>Loading inventory...</Status>;
}
return (
<main data-theme={hostContext?.theme}>
<h1>Warehouse {data.warehouseId}</h1>
<ul>
{data.items.map((item) => (
<li key={item.sku}>
<span>{item.name}</span>
<strong>{item.available}</strong>
</li>
))}
</ul>
</main>
);
}
function Status({
children,
role = 'status',
}: {
children: React.ReactNode;
role?: 'status' | 'alert';
}) {
return <p role={role}>{children}</p>;
}
This example keeps the runtime states explicit. A blank screen hides whether the app is connecting, waiting for a result, cancelled, or broken.
The cast from structuredContent is only a short example. Production code should validate the value with the same schema used by the server. TypeScript types disappear at runtime, so they cannot protect the iframe from a stale or malformed result.
Treat Partial Input as Preview Data
Some hosts stream tool arguments before the server call finishes. React can render that preview through ontoolinputpartial:
const [searchPreview, setSearchPreview] = useState('');
createdApp.ontoolinputpartial = ({ arguments: partial }) => {
const query = partial?.query;
setSearchPreview(typeof query === 'string' ? query : '');
};
createdApp.ontoolinput = ({ arguments: input }) => {
const query = input?.query;
setSearchPreview(typeof query === 'string' ? query : '');
};
Use partial input for labels, skeletons, and previews. Do not start a write, charge a card, choose an auth scope, or make any other lasting decision from it. Partial JSON can be syntactically valid while a string or nested value is still incomplete.
Apply Host Styles With Fallbacks
useHostStyles applies the host’s CSS variables, theme, and fonts to the document. Your CSS can then use the standard variables:
:root {
color-scheme: light dark;
font-family: var(--font-sans, system-ui, sans-serif);
background: transparent;
}
body {
margin: 0;
color: var(--color-text-primary, #171717);
background: transparent;
}
main {
padding: 16px;
}
li {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--color-border-primary, #d4d4d4);
}
Every host-context field is optional. Keep CSS fallbacks, and do not infer host identity from a color or font value.
Use useDocumentTheme only when the component must change its rendered content by theme, such as choosing a light or dark chart asset. Most color changes should stay in CSS because that avoids extra React branches.
Call Server Tools Through the Host
An interactive React view often needs fresh data after a click. Send that work back through the host instead of putting backend credentials in the iframe:
async function refreshInventory() {
if (!app?.getHostCapabilities()?.serverTools) {
setRuntimeError('This host cannot refresh inventory from the app.');
return;
}
setRuntimeError(undefined);
try {
const result = await app.callServerTool({
name: 'refresh-inventory',
arguments: {
warehouseId: data.warehouseId,
},
});
if (result.isError) {
setRuntimeError('The inventory refresh failed.');
return;
}
const next = result.structuredContent as InventoryData | undefined;
if (!next || !Array.isArray(next.items)) {
setRuntimeError('The refresh returned invalid inventory data.');
return;
}
setData(next);
} catch {
setRuntimeError('The host could not reach the inventory tool.');
}
}
There are two error paths:
- A tool execution error returns a result with
isError: true. - A bridge, transport, timeout, or host rejection error rejects the promise.
Handle both. The server must still validate the arguments, check the user’s session and permissions, and enforce business rules.
Avoid React Connection Loops
The current official useApp hook has two behaviors worth knowing:
- It reads its options on the initial mount. Changing the object later does not reconnect the app.
- It does not close the
Appon a React Strict Mode development remount.
Those choices prevent duplicate initialization loops. They also mean runtime state such as theme or tool data belongs in React state, not in changing useApp options.
Keep the app identity and declared capabilities static:
const { app, isConnected, error } = useApp({
appInfo: { name: 'inventory-view', version: '1.0.0' },
capabilities: {
availableDisplayModes: ['inline', 'fullscreen'],
},
onAppCreated: registerHandlers,
});
If you need custom App constructor options beyond the hook’s supported autoResize and strict options, create the App manually in an effect and connect it with PostMessageTransport. For most views, useApp is less code and has safer initialization behavior.
Do Not Test the View as a Normal Web Page
Opening inventory.html directly proves that React can mount. It does not prove that the app works because a normal tab has no MCP Apps parent bridge.
Use three test layers.
Component Tests
Extract presentation components and pass plain props:
render(
<InventoryList
items={[
{ sku: 'A-1', name: 'Cable', available: 4 },
{ sku: 'B-2', name: 'Adapter', available: 0 },
]}
/>
);
expect(screen.getByText('Cable')).toBeVisible();
expect(screen.getByText('4')).toBeVisible();
These tests are fast and useful for sorting, forms, empty states, and accessibility. Mock the bridge adapter when a component calls an app action.
Protocol Tests
Connect an MCP client to the server and assert:
tools/listincludes_meta.ui.resourceUriresources/readreturns the matching URI and MCP App MIME typetools/callreturns schema-validstructuredContent- app-only data stays out of model-visible
content - resource metadata declares only the CSP and permissions the view needs
These tests catch broken links between the server and React build.
Browser Tests in a Host Runtime
Render the resource through an MCP App inspector or host test harness. Cover:
- Connecting and initial loading
- Partial and complete tool input
- Success, empty, malformed, error, and cancellation results
- Repeated results without remounting
- Light and dark themes
- Inline, fullscreen, and other supported display modes
- Narrow mobile widths and safe areas
- Missing and denied host capabilities
- Every
callServerTool, link, message, download, or display-mode action
Use deterministic fixtures for these states. A live model can choose a different tool or argument shape on each run, which makes it a poor source for most UI regression tests.
When a React MCP App Framework Helps
The official React entry point solves the connection and host-style layer. You still need to choose conventions for:
- Server and resource file layout
- Schema sharing and runtime validation
- React bundle generation
- Tool and resource registration
- Local host simulation
- Deterministic tool-result fixtures
- Browser, visual, and live-host tests
- Production server and deployment setup
That direct setup is reasonable when you already have an MCP server and want precise control.
sunpeak’s MCP App framework packages those conventions with typed React hooks such as useToolData, useHostContext, useCallServerTool, and useAppState. Its MCP App Inspector renders the same resource in local ChatGPT and Claude runtime replicas, so you can switch tool data, theme, display mode, viewport, and host without redeploying.
You can also point the standalone inspector at an existing server written in any language:
npx sunpeak inspect --server https://your-mcp-server.example.com/mcp
Use the direct SDK when you want to own the bridge adapter and build pipeline. Use a framework when you want the React state, resource build, inspector, and test setup to follow one documented path. In both cases, keep the output portable: normal MCP tools, ui:// resources, the MCP App MIME type, and the standard ui/* bridge.
Get Started
npx sunpeak newFurther Reading
- MCP Apps SDK guide - packages, architecture, and setup
- MCP App lifecycle - connect, tool input, results, and teardown
- End-to-end TypeScript types for MCP Apps
- Interactive MCP Apps with useAppState
- MCP App host styles, dark mode, and CSS variables
- MCP App framework - React hooks, conventions, and local development
- MCP App Inspector - render and debug React views locally
- MCP testing framework - browser, visual, and live-host tests
- Official MCP Apps React API
- Official MCP Apps build guide
- MCP Apps SDK source and React examples
- sunpeak documentation
Frequently Asked Questions
Can I build an MCP App with React?
Yes. React runs inside the MCP App view, which an AI host renders in a sandboxed iframe. The official @modelcontextprotocol/ext-apps/react entry point provides useApp for the bridge connection plus hooks for host styles, fonts, theme, and resize behavior. Your MCP server still owns tools, resources, schemas, authentication, and backend work.
How do I connect a React app to an MCP host?
Call useApp with an appInfo object, declared app capabilities, and an onAppCreated callback. Register tool input, tool result, cancellation, error, and host-context handlers inside onAppCreated because it runs before the initialization handshake. The hook returns app, isConnected, and error for rendering connection states.
How does a React MCP App receive tool results?
The host sends tool results to the view through the ui/notifications/tool-result bridge notification. Register app.ontoolresult or a toolresult event listener before connecting, validate result.structuredContent, and copy the validated value into React state. Keep model-readable text in content and UI data in structuredContent.
What does useApp do in the MCP Apps React SDK?
useApp creates an App instance, opens a PostMessageTransport to the parent host, performs the MCP Apps initialization handshake, and returns the connection state. It uses its options only on the first mount and does not close the App during a React Strict Mode development remount, which avoids repeated bridge connections.
How should a React MCP App match ChatGPT or Claude styling?
Use useHostStyles with the connected App instance and its initial host context. The hook applies host CSS variables, theme, and fonts, then updates them when host context changes. Build components with variables such as --color-background-primary and include normal CSS fallbacks because every host-context field is optional.
Can a React MCP App call another server tool?
Yes when the host advertises the serverTools capability. Call app.callServerTool with a tool name and validated arguments, check result.isError for tool-level failures, and catch rejected promises for transport or host failures. Keep database writes and permission checks in the server tool, not in the iframe.
How do I test a React MCP App?
Use component tests for rendering and local state, protocol tests for tool metadata, resource MIME type, and structuredContent, then browser tests inside an MCP App host runtime. Cover loading, partial input, final results, errors, cancellation, light and dark themes, supported display modes, narrow widths, and denied host capabilities.