Skip to main content
All posts

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

Abe Wheeler
MCP AppsMCP App ReactMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkReactTypeScript
A React MCP App connects a bundled view to tool data and host context through the MCP Apps bridge.

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:

PartRuns whereOwns
MCP serverYour server processTools, resources, schemas, auth, database access
AI hostChatGPT, Claude, or another MCP Apps clientTool selection, iframe policy, bridge, host capabilities
React viewSandboxed iframeRendering, 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:

  • useApp to create and connect the view-side App
  • useHostStyles, useHostStyleVariables, and useHostFonts
  • useDocumentTheme for reactive document theme state
  • useAutoResize for 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.

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:

  1. _meta.ui.resourceUri on the tool descriptor
  2. The URI passed to registerAppResource
  3. The uri returned by the resource handler
  4. The text/html;profile=mcp-app MIME 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:

  1. It reads its options on the initial mount. Changing the object later does not reconnect the app.
  2. It does not close the App on 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/list includes _meta.ui.resourceUri
  • resources/read returns the matching URI and MCP App MIME type
  • tools/call returns schema-valid structuredContent
  • 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

Documentation →
npx sunpeak new

Further Reading

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.