Skip to main content
All posts

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

Abe Wheeler
MCP AppsMCP App FrameworkChatGPT AppsChatGPT App FrameworkClaude AppsClaude ConnectorsMigration
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 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, not the whole app. Keep your presentational components and backend. Replace router params, fetch on mount, local browser state, and top-level navigation with MCP tools, resources, structuredContent, app state, and host requests. Test the migrated screen locally with sunpeak before you connect it to 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 conceptMCP App equivalent
Page or routeResource, usually a ui:// HTML resource
API endpointTool on your MCP server
useEffect plus fetch for first paintTool result delivered to the resource
JSON response bodystructuredContent, plus optional _meta
Human-readable response textcontent
Route paramsTool input
Save button calling fetchcallServerTool or useCallServerTool
localStorage for durable UI stateuseAppState or updateModelContext
window.locationopenLink or sendMessage
CSS theme tokensHost 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.

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 res = await fetch(`${process.env.API_BASE}/dashboard`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${extra.authInfo?.token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ timeRange: args.timeRange }),
  });

  const data = await res.json();

  return {
    content: [{ type: 'text', text: `Displayed ${args.timeRange} account activity.` }],
    structuredContent: data,
    _meta: {
      uiOnly: {
        generatedAt: new Date().toISOString(),
      },
    },
  };
}

The pieces matter:

  • description helps the model decide when to call the tool.
  • annotations.readOnlyHint tells the host and model this tool does not mutate data.
  • resource: 'dashboard' links the tool to the UI resource in a sunpeak project.
  • content gives the model a short summary.
  • structuredContent gives the UI typed render data, and may also be visible to the model depending on the host.
  • _meta is for UI-only details when the host supports that separation.
  • outputSchema lets 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.

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, error } = useToolData<
    { timeRange: '7d' | '30d' | '90d' },
    DashboardData
  >();

  if (isLoading) return <Spinner />;
  if (error) return <ErrorView error={error} />;
  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:

KeepReplace
Presentational componentsRoute-bound page components
Zod schemas and TypeScript typesClient-only API wrappers
Formatters and validatorsuseEffect first-load fetches
Chart/table/form componentsBrowser cookie assumptions
Shared CSS tokensDirect 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 { useCallServerTool, useAppState } from 'sunpeak';

export function AccountControls({ accountId }: { accountId: string }) {
  const callServerTool = useCallServerTool();
  const [status, setStatus] = useAppState<'idle' | 'saving' | 'saved'>('status', 'idle');

  async function approveAccount() {
    setStatus('saving');
    await callServerTool({
      name: 'approve_account',
      arguments: { accountId },
    });
    setStatus('saved');
  }

  return <button onClick={approveAccount}>Approve</button>;
}

Use app state for data the model should be able to pick up in later turns: selected rows, active filters, draft choices, status flags, and user decisions. Keep local useState for UI details the model does not need, such as open menus, hover state, and tab focus.

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. The simplest migration is to map your design tokens to host variables with fallbacks:

:root {
  --app-bg: var(--openai-color-bg-primary, #ffffff);
  --app-fg: var(--openai-color-fg-primary, #111111);
  --app-border: var(--openai-color-border-light, #d7d7d7);
  --app-radius: var(--openai-radius-md, 8px);
}

[data-theme='dark'] {
  --app-bg: var(--openai-color-bg-primary, #0f0f0f);
  --app-fg: var(--openai-color-fg-primary, #f5f5f5);
}

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.

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-07-13T12: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, and outputSchema.
  • 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 content or structuredContent.
  • 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. The OpenAI Apps SDK reference is still useful when you need ChatGPT-specific behavior, but new portable work should stay close to the MCP Apps contract where possible.

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 Anthropic MCP connector reference cover the host setup side.

Manual checks should be narrow:

  1. Does the host discover the tool?
  2. Does the model choose the tool for the right prompts?
  3. Does the resource load from the deployed server?
  4. Do auth and permission errors read clearly?
  5. Do display mode, theme, safe area, and mobile width look right?
  6. Do links, downloads, and server-tool calls behave as expected?

Everything else should already be covered by local tests.

A Practical Migration Plan

A realistic first migration for one well-scoped screen:

  • Day 1: Choose the screen, define the tool input and output schema, and call the existing backend from an MCP tool.
  • Day 2: Move the presentational component into the MCP App resource and render from useToolData.
  • Day 3: Replace buttons and filters with useCallServerTool, useAppState, openLink, or sendMessage.
  • Day 4: Add CSP metadata, host theme variables, display-mode handling, and capability checks.
  • Day 5: Add simulations, contract tests, E2E tests, visual tests, and tool-calling evals.
  • Day 6: Validate in real ChatGPT and Claude, then prepare submission or 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

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

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

Yes. The best first migration is usually one high-value screen from an existing React, Next.js, Vue, or Svelte app. Keep the presentational components and business logic, then replace browser data sources such as URL params, fetch-on-mount, and localStorage with MCP App data from tools, structuredContent, app state, and server-tool calls.

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 share components between a web app and an MCP App?

Move shared presentational components, schemas, formatters, and design tokens into a shared package or folder. Keep web-specific hooks and MCP-specific hooks at the boundary. The web build can use router params and normal API clients, while the MCP App build uses useToolData, useCallServerTool, useAppState, useOpenLink, and host style variables.

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.

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.