How to Turn Your Existing Web App into an MCP App (October 2026)

Bring your existing React app into ChatGPT and Claude as an MCP App.
You already have a working web app. It has components, auth, a backend, real users, and a test suite. Now you want part of that product to run inside ChatGPT or Claude as an MCP App.
This post is for that migration. You are not starting from an empty demo. You are deciding which parts of a normal web product belong in a chat-hosted iframe, how to put an MCP server in front of your current backend, and how to test the result before a real host sees it.
TL;DR: Start with one useful screen. Keep its presentational components and backend. Move initial data loading into an MCP tool, render its result in a ui:// resource, and route writes through server tools. Keep durable data on the server and test the screen in a local host before connecting ChatGPT or Claude.
The Short Mental Model
A normal web app has pages, routes, API endpoints, and browser state. An MCP App has resources, tools, tool results, and host state.
| Web app concept | MCP App equivalent |
|---|---|
| Page or route | Resource, usually a ui:// HTML resource |
| API endpoint | Tool on your MCP server |
useEffect plus fetch for first paint | Tool result delivered to the resource |
| JSON response body | structuredContent, plus optional _meta |
| Human-readable response text | content |
| Route params | Tool input |
Save button calling fetch | callServerTool or useCallServerTool |
| Saved records and preferences | Authenticated server storage |
| Selection the model needs later | updateModelContext or a framework hook that syncs it |
window.location | openLink or sendMessage |
| CSS theme tokens | Host CSS variables and theme context |
The most important shift is data flow. In a normal app, the user opens a URL and the client fetches data. In an MCP App, the model calls a tool first. The tool returns data and points the host at a UI resource. The host loads the resource and sends the tool input and tool result into the iframe.
That means your React, Vue, or Svelte view is still a web view, but it no longer owns the first data fetch.
Before moving code, write down the screen’s route inputs, initial API calls, buttons that write data, auth checks, and state that must survive a reload. That inventory tells you which values become tool arguments, which become tool results, and which remain on your server. OpenAI’s current UI guide separates authoritative business data, temporary UI state, and cross-session storage for the same reason.
Pick One Screen First
Do not migrate your whole product at once. A chat-hosted app works best when it gives the user a focused surface that makes the conversation better.
Good first screens:
- A read-only dashboard with filters.
- A review or approval queue.
- A settings editor for one workflow.
- A document, invoice, ticket, or record viewer.
- A calculator, configurator, or comparison table.
- A job status screen for a long-running task.
Poor first screens:
- A full multi-page admin app.
- A route-heavy onboarding flow.
- A feature that only works with third-party cookies.
- A page that needs the full browser for a long editing session.
- A screen that requires unrestricted client-side network access.
The migration boundary should be small enough that you can name the tool in one sentence. For example, “show the last 30 days of account activity” is a good first tool. “open the product” is too broad.
Add an MCP Server in Front of Your Backend
You usually do not rewrite the backend. You add an MCP server that exposes a small set of tools. Those tools call your current backend over the same internal APIs your web app already uses.
Here is a minimal sunpeak-style tool for a migrated dashboard:
// src/tools/get-dashboard.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
title: 'Get dashboard',
description: 'Show account activity for a selected date range.',
resource: 'dashboard',
annotations: {
readOnlyHint: true,
},
};
export const schema = {
timeRange: z.enum(['7d', '30d', '90d']).describe('Date range for the dashboard'),
};
export const outputSchema = {
visits: z.number(),
conversions: z.number(),
conversionRate: z.number(),
accounts: z.array(
z.object({
id: z.string(),
name: z.string(),
plan: z.string(),
}),
),
};
type Args = z.infer<z.ZodObject<typeof schema>>;
export default async function getDashboard(args: Args, extra: ToolHandlerExtra) {
const token = extra.http?.authInfo?.token;
if (!token) throw new Error('Authentication required');
const res = await fetch(`${process.env.API_BASE}/dashboard`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ timeRange: args.timeRange }),
});
if (!res.ok) throw new Error(`Dashboard request failed: ${res.status}`);
const data = z.object(outputSchema).parse(await res.json());
return {
content: [{ type: 'text', text: `Displayed ${args.timeRange} account activity.` }],
structuredContent: data,
_meta: {
uiOnly: {
generatedAt: new Date().toISOString(),
},
},
};
}
The pieces matter:
descriptionhelps the model decide when to call the tool.annotations.readOnlyHinttells the host and model this tool does not mutate data.resource: 'dashboard'links the tool to the UI resource in a sunpeak project.contentgives the model a short summary.structuredContentgives the UI typed render data. Treat it as model-visible when choosing fields, because hosts such as ChatGPT expose it to the model._metais for UI-only details when the host supports that separation.outputSchemalets you test the result shape before a host renders it.
For a hand-rolled MCP server, the same contract maps to tool metadata and resource metadata. The portable pattern is a tool linked to a UI resource through _meta.ui.resourceUri, a declared outputSchema when you return structuredContent, and CSP metadata on the resource. The sunpeak tool and resource contract lists the fields to check.
Do not attach a UI resource to every tool. A data-only search or mutation tool can return a useful result without remounting the iframe. Attach _meta.ui.resourceUri to the tool that should render the final view. OpenAI recommends this split for UI workflows where the model fetches or refines data before presenting it. Keep a readable content result for clients that cannot render MCP Apps.
Move Components, Then Replace Data Hooks
Your presentational components are the asset you keep. Tables, cards, charts, date formatters, empty states, icons, Tailwind classes, and design-system components should move with little change.
The boundary code changes. A normal web component might look like this:
// before: web app
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
export function DashboardPage() {
const { timeRange = '30d' } = useParams();
const [data, setData] = useState<DashboardData | null>(null);
useEffect(() => {
fetch(`/api/dashboard?range=${timeRange}`)
.then((res) => res.json())
.then(setData);
}, [timeRange]);
if (!data) return <Spinner />;
return <DashboardView data={data} />;
}
The MCP App resource should render from the tool result:
// after: MCP App resource
import { useToolData } from 'sunpeak';
export default function DashboardResource() {
const { output, input, isLoading, isError } = useToolData<
{ timeRange: '7d' | '30d' | '90d' },
DashboardData
>();
if (isLoading) return <Spinner />;
if (isError) return <ErrorView />;
if (!output) return <EmptyState />;
return <DashboardView data={output} selectedRange={input?.timeRange ?? '30d'} />;
}
DashboardView stays the same. The fetcher and router glue are gone.
This is the migration pattern I would use across the codebase:
| Keep | Replace |
|---|---|
| Presentational components | Route-bound page components |
| Zod schemas and TypeScript types | Client-only API wrappers |
| Formatters and validators | useEffect first-load fetches |
| Chart/table/form components | Browser cookie assumptions |
| Shared CSS tokens | Direct parent-window access |
If you have a monorepo, put the shared pieces in packages/ui, packages/schemas, or the equivalent folder you already use. Keep the host-specific code in small adapter components.
Turn User Actions into Server Tools
A migrated view often needs buttons: save, approve, refresh, filter, export, retry. In a normal app, those buttons call fetch. In an MCP App, prefer server tools.
import { useState } from 'react';
import { useCallServerTool } from 'sunpeak';
export function AccountControls({ accountId }: { accountId: string }) {
const callServerTool = useCallServerTool();
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
async function approveAccount() {
setStatus('saving');
try {
const result = await callServerTool({
name: 'approve_account',
arguments: { accountId },
});
setStatus(result && !result.isError ? 'saved' : 'error');
} catch {
setStatus('error');
}
}
return (
<div>
<button type="button" disabled={status === 'saving'} onClick={approveAccount}>
Approve
</button>
{status === 'error' && <p>Approval failed. Try again.</p>}
</div>
);
}
The server tool must check that the account belongs to the authenticated user and that the approval is allowed. A disabled button prevents duplicate clicks while the request runs, but it is not an authorization check. If the model needs to know the final decision, send a compact result through a model-visible tool result or context update after the server confirms it. Keep transient button status in local useState.
Use app state for data the model should be able to pick up in later turns, such as selected rows, active filters, and draft choices. In sunpeak, useAppState syncs that state through updateModelContext. Keep open menus, hover state, and tab focus in local useState.
For actions that should send the user back into the conversation, use sendMessage. For actions that should open your full web app, use openLink or useOpenLink. Do not call window.location.
Fix Browser Assumptions Early
Most migration bugs come from normal browser assumptions that are false inside a host iframe.
Routing
An MCP App resource is a focused view, not a whole SPA. You can still use client-side state to switch tabs or panels inside the view, but do not depend on route changes for core navigation. Use separate tools and resources for separate jobs.
Auth
Do not depend on browser cookies inside the iframe. Put auth at the MCP server boundary. If your existing product already uses OAuth, reuse that identity flow and issue tokens your MCP server can use when it calls your backend. The sunpeak authorization guide covers per-server auth, per-tool auth, and step-up flows.
Network Access
If the iframe fetches external domains, those domains must appear in resource CSP metadata such as connectDomains. Many apps should skip direct iframe fetches and call their own MCP server instead. That keeps credentials out of the iframe and puts policy checks in one place.
Top-Level Navigation
The iframe cannot own the host window. Use openLink to open your full app in a browser tab, or use sendMessage when the next step belongs in chat.
Parent Window Access
No direct reads from the parent host page. The host bridge is the communication channel. Use host context, app state, tool events, and request methods instead of reaching for window.parent.
Layout
Your view may run inline, fullscreen, or picture-in-picture depending on host support. Read display mode and container size from host context, and make the component responsive to a narrow iframe. The display mode guide covers the edge cases.
Update Styling Without Rebuilding Your Design System
Your CSS can come with you. The part to change is the outer theme layer.
Hosts provide style variables and theme context. In sunpeak, the app framework includes hooks and helpers for host styles, fonts, theme, safe area, and viewport. Map your design tokens to the MCP Apps host CSS variables with fallbacks:
:root {
--app-bg: var(--color-background-primary, Canvas);
--app-fg: var(--color-text-primary, CanvasText);
--app-border: var(--color-border-primary, #d7d7d7);
--app-radius: var(--border-radius-md, 8px);
}
Then keep your components on your own tokens:
export function Panel({ children }: { children: React.ReactNode }) {
return (
<section className="rounded-[var(--app-radius)] border border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)]">
{children}
</section>
);
}
You get host-aware color and spacing without rewriting every component. These variables can update when the host theme changes, so a separate dark-mode override is usually unnecessary. Keep the fallbacks because each host may supply only part of the token set. The host styling guide covers typography, focus rings, and narrow layouts.
Add Capability Detection
The broader MCP App ecosystem is still moving quickly. Hosts may differ on display modes, file downloads, app-side tools, model context updates, fonts, safe areas, or app-only tool behavior. Treat host features as capabilities, not assumptions.
In practice:
- Keep the first paint dependent only on tool input and
structuredContent. - Check whether a host supports the request before showing a button for it.
- Keep ChatGPT-only and Claude-only code in small host adapters.
- Add a non-UI fallback for every important tool result through
content. - Test the same simulation in both ChatGPT and Claude host modes.
This lets the same migrated screen run in a host with full UI support and still degrade cleanly in a client that only understands normal MCP tool results.
Test the Migration Locally
You cannot validate this by opening the old route in a browser. The view now depends on tool input, tool result data, host context, display mode, resource metadata, and iframe policy.
With sunpeak, run the inspector in a framework project:
pnpm dev
Or inspect an existing MCP server:
npx sunpeak inspect --server http://localhost:8000/mcp
Then add simulations for the states your old app already had to support:
{
"tool": "get-dashboard",
"userMessage": "Show me account activity for the last 30 days",
"toolInput": { "timeRange": "30d" },
"toolResult": {
"content": [{ "type": "text", "text": "Displayed 30d account activity." }],
"structuredContent": {
"visits": 4218,
"conversions": 83,
"conversionRate": 0.0197,
"accounts": []
},
"_meta": {
"uiOnly": {
"generatedAt": "2026-10-22T12:00:00.000Z"
}
}
}
}
Write one simulation per state: happy path, empty state, auth expired, permission denied, validation error, large result set, and slow backend. Those simulations should feed tests.
pnpm test # unit and e2e tests
pnpm test:visual # screenshots across themes, widths, and display modes
pnpm test:live # checks against real hosts when credentials are available
pnpm test:eval # tool-calling evals across model providers
Good migration tests check both sides of the contract:
- Tool tests assert input schemas, annotations,
content,structuredContent,_meta, andoutputSchema. - Resource tests render the iframe with a simulation and assert the UI.
- Visual tests catch theme, safe-area, and display-mode regressions.
- Security tests assert that tokens, internal IDs, cursors, raw provider payloads, and private fields do not leak into model-visible
contentorstructuredContent. - Evals test whether real models choose the right tool from natural language prompts.
If you already have component tests for the web app, keep them. Replace mocks for fetch or route params with mocks for useToolData, useCallServerTool, and app state.
Connect Real Hosts Last
Once local tests pass, deploy the MCP server or expose it with a temporary tunnel for manual host checks.
For ChatGPT, current app distribution runs through OpenAI’s plugin flow for MCP-backed apps. Developer mode is required for local development apps, and public distribution uses the OpenAI submission path. Follow the OpenAI MCP server and UI quickstart for the current connection flow, and keep portable UI behavior on the MCP Apps bridge.
For Claude, connectors use remote MCP servers. The interactive app pattern still comes down to the same pieces: tools, resources, structured results, resource metadata, auth, and host rendering. The Claude connector tutorial and the Claude MCP connector reference cover the host setup side.
Manual checks should be narrow:
- Does the host discover the tool?
- Does the model choose the tool for the right prompts?
- Does the resource load from the deployed server?
- Do auth and permission errors read clearly?
- Do display mode, theme, safe area, and mobile width look right?
- Do links, downloads, and server-tool calls behave as expected?
Everything else should already be covered by local tests.
A Practical Migration Plan
A practical order for one screen:
- Inventory the existing route, API calls, auth checks, state, and user actions. Pick the smallest flow that is useful in chat.
- Define tool inputs and an output schema, then call the existing backend from an authenticated MCP tool. Keep a text result for hosts without UI support.
- Move the presentational component into a
ui://resource and render the first view from the tool result. Keep data-only tools separate if the model needs to fetch or refine data before rendering. - Replace write buttons with server-tool calls. Put durable decisions on the server and send only compact selections into model context.
- Add resource CSP, host CSS variables, narrow-screen layout, and capability checks. Test both supported and unsupported host features.
- Add simulations and contract, browser, visual, and tool-calling tests. Validate in real ChatGPT and Claude before rollout.
The second screen should be faster because the MCP server, auth, styling layer, testing setup, and deployment path already exist.
What to Build First
If you are looking at a large existing product, start with the screen that has the clearest conversation value. The user should be able to ask a natural language question, get the right tool call, see an interactive view, adjust something, and continue the conversation with the model aware of what changed.
That is usually not your homepage or your settings area. It is a small workflow with real data: “review these invoices,” “compare these accounts,” “approve this campaign,” “show the failed jobs,” “summarize this customer.”
Build that first. Put an MCP server in front of the backend you already trust. Render your existing component from structuredContent. Test the view with simulations. Then connect it to real hosts.
With sunpeak, you can start a new MCP App with:
npx sunpeak new
Or inspect a server you already have:
npx sunpeak inspect --server http://localhost:8000/mcp
Use the MCP App tutorial if you want a full app from scratch, the add UI to an MCP tool guide if you already have a tool, and the testing docs once the first migrated view renders.
Get Started
npx sunpeak newFurther Reading
- Add a UI to an MCP Tool - official sunpeak guide to resourceUri, structuredContent, outputSchema, and CSP
- MCP App Tool and Resource Contract - server checklist for migrated apps
- What Is an MCP App? - architecture and working code example
- How to Build an MCP App - cross-host architecture from scratch
- Migrate from OpenAI Apps SDK to MCP Apps - mapping OpenAI fields to MCP Apps
- MCP App Tool Results - content, structuredContent, _meta, and outputSchema
- MCP App CSP Domains - connectDomains, resourceDomains, frameDomains
- Interactive MCP Apps with useAppState - two-way state management
- MCP App state persistence - model context, widget state, and server storage
- MCP App capability detection - fallbacks across hosts
- Complete Guide to Testing MCP Apps - E2E, visual, and live host testing
- Official MCP Apps overview
- OpenAI plugin UI guide - tool and render separation
- Claude MCP connector reference
Frequently Asked Questions
Can I turn my existing web app into an MCP App?
Yes. Start with one screen from an existing React, Next.js, Vue, or Svelte app. Keep the presentational components and backend, then replace browser-specific data loading and navigation with MCP tools, structuredContent, host requests, and server-owned durable state.
What parts of a normal web app do not transfer cleanly to an MCP App?
Full-page routing, top-level navigation, browser cookie auth, direct access to the parent window, and unrestricted network calls do not transfer cleanly. MCP App views run in sandboxed iframes inside the host, so you need explicit resource metadata, a Content Security Policy, host-approved link opening, and OAuth or server-side auth through the MCP server.
Do I need to rewrite my backend for an MCP App?
Usually no. Add an MCP server in front of the backend you already have. MCP tools call your REST, GraphQL, database, or internal service APIs, then return concise content for the model and structuredContent for the iframe resource. Your backend keeps the same core API and permissions model.
How does data get from the model to my migrated UI?
The model calls an MCP tool with structured arguments. The tool returns content, structuredContent, and optional _meta. The host links the tool to a UI resource through metadata such as _meta.ui.resourceUri, loads the resource in an iframe, then sends the tool input and result to the view through the MCP Apps bridge. In sunpeak, useToolData wraps that data for React components.
Should my MCP App iframe call my existing API directly?
For initial data and authenticated actions, call the MCP server instead. Direct iframe fetches require connectDomains in the resource CSP and expose more browser-sandbox behavior. Server tools are easier to secure, easier for the model to understand, and easier to test. Direct iframe fetches are still fine for public assets, maps, media, or narrowly scoped app-only APIs.
How do I test the migrated MCP App without paid host accounts?
Use the sunpeak inspector locally. Run pnpm dev in a sunpeak project, or run npx sunpeak inspect --server URL for an existing MCP server. Add simulation files for tool input, structuredContent, _meta, themes, display modes, and error states, then run E2E, visual, unit, and eval tests in CI before validating in real ChatGPT or Claude.
Should every MCP tool open the migrated UI?
No. Keep data and mutation tools useful without an iframe. Attach _meta.ui.resourceUri only to a tool that should render the view. This avoids remounting the UI on every data fetch or button click and lets clients without MCP Apps support use the tools.
Will one migrated MCP App work in both ChatGPT and Claude?
Yes, if you build the core view around the MCP Apps contract: tools, resources, structuredContent, _meta, resource metadata, host context, app state, and capability detection. Keep ChatGPT-only or Claude-only APIs behind feature checks or separate imports so the shared view still renders in other compatible hosts.