All posts

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

Abe Wheeler
MCP Apps MCP App Framework ChatGPT Apps ChatGPT App Framework Claude Apps Claude Connectors Migration
Bring your existing React app into ChatGPT and Claude as an MCP App.

Bring your existing React app into ChatGPT and Claude as an MCP App.

You already have a working web app. It has a React frontend, a backend, real users. ChatGPT Apps and Claude Connectors landed, your customers are asking when they can use your product inside the chat, and every existing tutorial assumes you are starting from scratch with npx sunpeak new.

This post is for the other case. You have an app. You want it inside ChatGPT and Claude. Here is what transfers, what changes, and the build order that gets you there with the smallest possible rewrite.

TL;DR: Most of your React code transfers directly. You replace browser data sources (URL params, fetch, localStorage) with MCP App hooks (useToolData, useCallServerTool, useAppState). Your backend stays put behind a thin MCP server that exposes tools. Test the migrated app locally with sunpeak before you ever connect to a real host.

What an MCP App Actually Is, in Web App Terms

If you have built a web app before, the mental model is short.

Your web app has pages and an API. An MCP App has Resources (the pages) and Tools (the API). The browser is a sandboxed iframe inside ChatGPT or Claude. The user does not navigate to your URL. The AI calls a tool, the host loads a Resource into the iframe, and the tool’s output is injected as the initial data for that Resource.

So:

Web appMCP App
Page routeResource
API endpointTool
useEffect + fetch for initial datauseToolData
fetch for user actionsuseCallServerTool
localStorage / URL params for stateuseAppState
window.location navigationuseSendMessage or useOpenLink
<a href> external linksuseOpenLink
CSS variables you definedHost CSS variables (--openai-color-bg-primary, etc.)

That is the full conceptual map. Once you see it, the migration is mostly mechanical.

Step 1: Decide What to Migrate

Not every web app makes a good MCP App. Before writing code, decide which features ship.

Good fits inside the chat:

  • Read-only views of data the user already has (dashboards, summaries, lists)
  • Approval and confirmation flows
  • Quick-edit forms (tagging, status changes, filters)
  • Single-screen tools (calculators, configurators, comparators)

Bad fits:

  • Multi-page navigation flows (the iframe has no top-level routing)
  • Anything that needs the full browser viewport for long stretches
  • Features that depend on third-party cookies (the iframe sandbox blocks most)
  • Large file uploads beyond the host’s limits

The pattern that works best: pick the two or three highest-value views from your existing app. A trial dashboard, a settings approval, a quick filter. Migrate those first. You do not need to recreate the whole product inside ChatGPT, because the model can route users back to your full web app for everything else with useOpenLink.

Step 2: Stand Up an MCP Server in Front of Your Backend

You do not rewrite your backend. You add a layer in front of it.

Your existing backend keeps doing what it does. The new MCP server is a small process that exposes tools, calls into your backend over HTTP (or whatever transport you already use), and returns the results. From the AI’s perspective, the MCP server is the interface. From your backend’s perspective, the MCP server is just another client.

A minimal tool, in the sunpeak project structure, looks like this:

// src/tools/get-dashboard.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';

export const tool: AppToolConfig = {
  resource: 'dashboard',
  title: 'Get Dashboard',
  description: 'Show the user a summary of their account activity',
  annotations: { readOnlyHint: true },
};

export const schema = {
  timeRange: z.string().describe('Time range like "7d" or "30d"'),
};

type Args = z.infer<z.ZodObject<typeof schema>>;

export default async function getDashboard(args: Args, extra: ToolHandlerExtra) {
  const userId = extra.authInfo?.userId;
  const res = await fetch(`${process.env.API_BASE}/dashboard`, {
    headers: { Authorization: `Bearer ${extra.authInfo?.token}` },
    method: 'POST',
    body: JSON.stringify({ userId, timeRange: args.timeRange }),
  });
  const data = await res.json();
  return { structuredContent: data };
}

That tool is the MCP equivalent of a single API endpoint in your existing app. The structuredContent it returns is what your Resource component receives via useToolData.

If your existing API requires auth, you wire OAuth 2.1 at the MCP server level. The user authenticates once when they install your connector, and from then on extra.authInfo carries their identity into every tool call. The authorization guide in the docs walks through the full flow.

Step 3: Move Your React Components, Then Patch the Imports

This is the part where you save the most time. Your React components, your Tailwind classes, your design system, your form libraries, your charting code: all of it works inside the iframe. The browser is still a browser. React is still React.

The work is at the import boundary. Take an existing component:

// before: web app
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';

export function Dashboard() {
  const { timeRange } = useParams();
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch(`/api/dashboard?range=${timeRange}`)
      .then(r => r.json())
      .then(setData);
  }, [timeRange]);

  if (!data) return <Spinner />;
  return <DashboardView data={data} />;
}

Now the same component as a Resource:

// after: MCP App
import { useToolData } from 'sunpeak';

export default function Dashboard() {
  const { structuredContent, isLoading, error } = useToolData();

  if (isLoading) return <Spinner />;
  if (error) return <ErrorView error={error} />;
  return <DashboardView data={structuredContent} />;
}

Three things changed: the data source, the URL params went away, and you got loading and error states for free from useToolData. The DashboardView underneath did not change at all.

For user-driven actions (a save button, a filter change), the equivalent of fetch is useCallServerTool:

import { useCallServerTool } from 'sunpeak';

const callTool = useCallServerTool();
await callTool({
  name: 'update_settings',
  arguments: { theme: 'dark' },
});

Same shape as a fetch to one of your endpoints. The host routes it back to your MCP server.

For state that should persist across iframe reloads (and that the model can see), use useAppState instead of localStorage:

const [filter, setFilter] = useAppState('filter', 'all');

For everything else (transient UI state like hover effects, open dropdowns), keep useState.

Step 4: Replace the Things the Iframe Will Not Allow

The iframe sandbox is the part that catches people. A few rules you will run into immediately.

No top-level navigation. window.location.href = '...' does nothing. If you need to take the user to a full page in your web app, use useOpenLink, which opens in a new browser tab outside the chat.

No third-party cookies by default. If your app authenticates via cookies set on a separate domain, that flow does not survive the migration. Move auth to OAuth at the MCP server boundary instead.

Strict Content Security Policy. External fetches from inside the iframe are blocked unless you list the domain in your resource’s connectDomains. The CSP guide covers connectDomains, resourceDomains, and frameDomains in detail. Most apps should not call external APIs from the iframe at all because going through the MCP server is cleaner and avoids the CSP entirely.

No client-side routing. A Resource is a single screen. If your existing app is a multi-page SPA, pick one screen per Resource and let the AI move the user between them by calling different tools. Tools cause Resources to load, the same way URLs cause pages to load in a normal SPA.

No direct DOM access to anything outside the iframe. No reading the parent host’s window, no global event buses across iframes. The only communication channel is postMessage, and the App class wraps that for you.

If your existing app touches window or document outside React (analytics, error reporters), check those work inside a sandboxed iframe with no parent access. Most do, but a few popular tools assume access to the top-level page and silently fail.

Step 5: Reuse Your Styling, Adapt to the Host’s Theme

Your CSS, your Tailwind config, your design tokens: all of it works. The thing that changes is that the host has its own theme (light or dark, ChatGPT or Claude colors), and your app should match.

The host injects CSS variables like --openai-color-bg-primary and --anthropic-color-fg-primary. The simplest path: map your existing design tokens to those host variables in a single CSS layer, so your components keep using your tokens and they automatically adapt.

:root {
  --color-bg: var(--openai-color-bg-primary, #fff);
  --color-fg: var(--openai-color-fg-primary, #000);
}

The fallback values are what your web app will use when the host variables are absent. The styling guide has the full mapping for both ChatGPT and Claude.

Theme detection across light and dark is handled by useTheme:

const theme = useTheme(); // 'light' | 'dark'

If your existing app already has a dark mode, plug useTheme into your existing theme switcher and you are done.

Step 6: Decide What Lives in the Same Repo

You have two reasonable options.

Option A: One repo, one codebase, two builds. Move your React components into a packages/ui workspace and import them from both your web app and your MCP App project. Your data hooks live behind an interface; the web app implements it with fetch, the MCP App implements it with useToolData and useCallServerTool. This is the lowest-duplication path if you plan to keep both products evolving together.

Option B: Separate repos that share a component library. Publish your design system as an internal package, and treat the MCP App as a different product that imports from it. This makes sense when the MCP App diverges enough that shared higher-level components stop being useful.

For most teams, Option A is the right starting point. It keeps the MCP App honest as “the same product, in a new surface” rather than letting it drift into a different feature set.

Step 7: Test the Migration Before Connecting to Any Host

The fastest part of the migration, and the part most likely to break in subtle ways, is testing. The reason: your code now depends on data the host injects, theme variables that come from outside, display modes that have no equivalent in a normal browser. You cannot test by opening a file in the browser anymore.

The local sunpeak Inspector replicates the ChatGPT and Claude runtimes in your browser at localhost:3000. Run:

pnpm dev

The Inspector loads your Resource in a sandboxed iframe identical to the one ChatGPT and Claude use, with a sidebar showing tool input, tool output, display mode, and theme. You toggle between hosts with a dropdown. Hot module replacement works, so the loop is the same as developing a normal React app.

For deterministic states, write simulation files:

{
  "tool": "get_dashboard",
  "userMessage": "Show me last week's analytics",
  "toolInput": { "timeRange": "7d" },
  "toolResult": {
    "structuredContent": {
      "visits": 4218,
      "conversions": 83,
      "bounceRate": 0.41
    }
  }
}

One simulation per UI state: happy path, empty state, error state, loading state. These feed multiple test types:

pnpm test          # unit + e2e tests
pnpm test:visual   # visual regression across themes and display modes
pnpm test:live     # validation against real ChatGPT and Claude
pnpm test:eval     # multi-model evals (GPT-4o, Claude, Gemini)

The unit tests cover your component logic with Vitest. The E2E tests drive the Inspector with Playwright and assert on the rendered output. The visual regression tests catch pixel-level changes across light and dark modes for both ChatGPT and Claude. The evals check that real LLMs actually call your tools correctly when given natural language prompts.

If your existing app has a Vitest or Jest test suite, most of the component-level tests transfer with one change: mock useToolData instead of mocking your fetcher. The assertions on the rendered output do not change.

Step 8: Connect to the Real Hosts Last

Once your local tests pass, connect to a real host. For ChatGPT, expose your local MCP server with ngrok and add the URL in the ChatGPT Connector modal. For Claude, the connector setup flow is similar. The deployment guide covers production hosting on Cloudflare, Vercel, Railway, or your own VPS.

Plan to spend a small amount of time validating each host manually. Two things to check that local tests cannot fully cover:

  1. The model actually calls your tools. Tool descriptions matter. Vague descriptions mean the AI does not invoke your tool when it should. The tool design guide covers this.
  2. The visual output looks right inside the real chat. Padding, font, theme colors, and display mode behavior can shift slightly between the local Inspector and production hosts.

For everything else, the local tests catch what matters before you ever touch a real host account.

A Realistic Migration Timeline

A small team migrating one or two views from an existing web app, in my experience:

  • Day 1: Stand up an MCP server, define your first tool, get it returning real data from your existing backend.
  • Day 2-3: Move your first Resource component, replace fetch/useEffect with useToolData, write a couple of simulation files, get it rendering in the local Inspector.
  • Day 4: Wire useAppState for any interactive state. Add useCallServerTool for actions. Map your design tokens to host CSS variables.
  • Day 5: Write E2E tests, visual regression tests, and a multi-model eval. Validate in the Inspector across both ChatGPT and Claude.
  • Day 6: Connect to ChatGPT and Claude with ngrok. Validate manually. Submit to the ChatGPT App directory or Claude Connectors directory.

Five to seven working days, end to end, for one well-scoped view. The second view is faster because the server, the testing setup, and the styling layer are already in place. By the third view you are mostly writing components.

What You Get When You Are Done

A single product that ships in your web app, in ChatGPT, in Claude, and on every other host that implements the MCP Apps standard, from one codebase. Your existing React components, your existing backend, your existing design system, all reused. Tests that run without paid host accounts, in CI, on every push.

Most of the work is unlearning the assumptions baked into a normal SPA: client-side routing, fetch, localStorage, window.location. Once you replace those at the import boundary, the rest is the same React app you already know.

The fastest way to start is to scaffold an empty MCP App with npx sunpeak new, then port your first component into it. Use the MCP App tutorial as the reference for what a working app looks like, the runtime APIs reference for the hook signatures, and the Inspector to see your migration running before you touch any real host.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

Can I turn my existing web app into an MCP App?

Yes, if your app has a React, Vue, or Svelte frontend, most of your component code transfers directly. You replace browser data sources (URL params, fetch calls, localStorage) with MCP App equivalents (useToolData for tool input, useCallServerTool for backend calls, useAppState for persistent state). Existing components, styles, and business logic stay the same.

What parts of my web app cannot move to an MCP App?

Client-side routing (React Router, Next.js routing), top-level navigation, direct cookie or localStorage access, and any code that assumes a full browser window will not work inside the MCP App iframe. The iframe is sandboxed: no top-level navigation, no third-party cookies by default, and a strict Content Security Policy. You also lose any feature that relies on a paid backend the user has not authenticated with through MCP.

Do I need to rewrite my backend to support an MCP App?

No. Your existing REST or GraphQL backend can stay as is. You add an MCP server in front of it that exposes tools mapping to your existing endpoints. The MCP server calls your backend just like any other client. If you already have an authenticated API, the same auth flow works through MCP via OAuth 2.1.

How is data passed from the AI to my MCP App UI?

The AI calls one of your tools with structured arguments. The tool returns structuredContent, which the host injects into your iframe via postMessage. Your React component reads it with useToolData. There is no fetch, no API call from the iframe to start with. The first paint always uses tool data, not network requests.

Can my MCP App make external API calls from the iframe?

Yes, but only to domains you list in connectDomains in your resource Content Security Policy. The host blocks any other network access from the iframe. For most apps, the cleaner pattern is to call your MCP server with useCallServerTool and let the server make the external call, since the server is not sandboxed.

How do I share components between my web app and my MCP App?

Move shared components into a workspace package (in a pnpm or yarn monorepo) or import them from the same source folder. The component code is identical. Wrap any code that touches browser globals (window, document, localStorage) in checks so the same component works in both environments. Replace data hooks at the import boundary: the MCP App version uses useToolData, the web app version uses your existing fetcher.

How do I test the migrated MCP App without a paid ChatGPT account?

Run the sunpeak Inspector locally with sunpeak dev. It replicates the ChatGPT and Claude runtimes in your browser at localhost:3000, with hot module replacement and a sidebar showing tool input, tool output, theme, and display mode. Write simulation files for each UI state and run sunpeak test to validate your migration with unit tests, E2E tests, visual regression tests, and multi-model evals. No paid host accounts needed.

Will my migrated MCP App work in both ChatGPT and Claude?

Yes if you build against the MCP Apps standard (the ext-apps spec) instead of host-specific APIs. The same React component runs unchanged in ChatGPT, Claude, Goose, VS Code, Postman, and MCPJam. Use sunpeak/chatgpt or sunpeak/claude subpath imports for host-specific features like ChatGPT file uploads or modals, and guard them with isChatGPT() or isClaude() checks.