1. What You’ll Learn
This guide walks you through:
- Building a ChatGPT App using React + Vite
- Creating an MCP (Model Context Protocol) server to power tools and widgets
- Implementing a Vite version of NextChatSDKBootstrap (by Vercel) for stable embedding
- Handling CORS, asset prefixing, and hydration quirks inside ChatGPT

2. Why ChatGPT Apps Use MCP
OpenAI’s ChatGPT Apps run interactive mini-apps directly inside ChatGPT. They communicate over the Model Context Protocol (MCP)—a simple HTTP-like interface defining:
- Tools (functions the app can execute)
- Resources (HTML/JSON data returned as widgets)
When ChatGPT invokes a tool, it can also fetch a resource (e.g., resource://weatherWidget
) and render it as an inline widget.
3. Project Setup
mkdir chatgpt-app-mcp-react && cd $_ npm create vite@latest . -- --template react-ts npm i express cors @modelcontextprotocol/sdk zod npm i -D ts-node nodemon concurrently
File Structure
chatgpt-app-mcp-react/
├─ src/
│ ├─ App.tsx
│ ├─ main.tsx
│ └─ ViteChatSDKBootstrap.tsx
├─ server/
│ ├─ mcpServer.ts
│ └─ index.ts
├─ widgets/
│ └─ weather.html
├─ vite.config.ts
└─ .env.local
4. Vite Config (Asset Prefix)
ChatGPT apps load assets from their real origin—not from ChatGPT’s sandbox iframe.
Use the base
option in vite.config.ts
(equivalent to Next.js assetPrefix
):
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], base: process.env.BASE_URL ? new URL(process.env.BASE_URL).pathname || '/' : '/', })
💡 Tip: Set
BASE_URL=https://your-app.vercel.app/
in production so/assets/
resolve correctly in ChatGPT.
5. The ViteChatSDKBootstrap
Component
This is the React + Vite equivalent of NextChatSDKBootstrap. It patches:
history.pushState
/replaceState
→ keeps relative URLswindow.fetch
→ routes same-origin calls to your real host<html>
hydration guard → reduces mismatch warnings
'use client' import { useEffect } from 'react' type Props = { baseUrl: string } export default function ViteChatSDKBootstrap({ baseUrl }: Props) { useEffect(() => { // 1️⃣ History guards const wrapHistory = (method: 'pushState' | 'replaceState') => { const original = history[method] return function patched(this: History, data: any, title: string, url?: string | URL | null) { try { if (url) { const u = new URL(String(url), baseUrl) const relative = u.pathname + u.search + u.hash return (original as any).call(this, data, title, relative) } } catch {} return (original as any).call(this, data, title, url as any) } as History['pushState'] } const origPush = history.pushState const origReplace = history.replaceState history.pushState = wrapHistory('pushState') as any history.replaceState = wrapHistory('replaceState') as any // 2️⃣ Fetch rewriter const origFetch = window.fetch.bind(window) const isRelOrSame = (input: RequestInfo | URL) => typeof input === 'string' ? !/^https?:\/\//i.test(input) : input instanceof URL ? input.origin === location.origin : input instanceof Request ? isRelOrSame(input.url) : false window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { if (isRelOrSame(input)) { const url = new URL(typeof input === 'string' ? input : input.toString(), baseUrl) return origFetch(url.toString(), init) } return origFetch(input as any, init) }) as typeof window.fetch // 3️⃣ Hydration guard const html = document.documentElement const observer = new MutationObserver(() => { if (!html.hasAttribute('lang')) html.setAttribute('lang', 'en') }) observer.observe(html, { attributes: true }) return () => { history.pushState = origPush as any history.replaceState = origReplace as any window.fetch = origFetch observer.disconnect() } }, [baseUrl]) return null }
Use it in your app:
// src/App.tsx import ViteChatSDKBootstrap from './ViteChatSDKBootstrap' export default function App() { const baseUrl = import.meta.env.BASE_URL || '/' return ( <main style={{ padding: 24 }}> <ViteChatSDKBootstrap baseUrl={baseUrl} /> <h1>ChatGPT App (React + Vite + MCP)</h1> <p>Weather demo inside ChatGPT.</p> </main> ) }
6. MCP Server (Tools + Resources)
// server/mcpServer.ts import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' import fs from 'node:fs/promises' import path from 'node:path' export function createMcpServer() { const server = new McpServer({ name: 'vite-mcp', version: '1.0.0' }) server.resources.register({ name: 'weatherWidget', inputSchema: z.object({ city: z.string(), summary: z.string(), temp: z.number() }).passthrough(), }, async (ctx) => { const { city, summary, temp } = ctx.input as any const raw = await fs.readFile(path.join(process.cwd(), 'widgets', 'weather.html'), 'utf8') const html = raw.replace(/{{city}}/g, city).replace(/{{summary}}/g, summary).replace(/{{temp}}/g, temp) return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }) }) server.tools.register({ name: 'getWeather', description: 'Fetch current weather and render a widget', inputSchema: z.object({ city: z.string() }), metadata: { 'openai/resultCanProduceWidget': true, 'openai/outputTemplate': 'resource://weatherWidget', 'openai/widgetAccessible': true, }, }, async ({ input }) => { const data = { city: input.city, summary: 'Sunny', temp: 27 } return { data, templateUri: 'resource://weatherWidget' } }) return server }
7. Express Host (CORS + Static)
// server/index.ts import express from 'express' import cors from 'cors' import path from 'node:path' import { createMcpServer } from './mcpServer' const app = express() const PORT = process.env.PORT || 4321 const BASE = process.env.BASE_URL || 'http://localhost:5173' app.use(cors({ origin: BASE, credentials: true })) app.options('*', cors()) const mcp = createMcpServer() app.all('/mcp', async (req, res) => { const rsp = await mcp.handle({ method: req.method, url: req.url, headers: req.headers as any, body: req as any, }) res.status(rsp.status || 200) rsp.headers && Object.entries(rsp.headers).forEach(([k, v]) => res.setHeader(k, String(v))) const buf = Buffer.from(await rsp.arrayBuffer()) res.end(buf) }) app.use(express.static(path.join(process.cwd(), 'dist'))) app.listen(PORT, () => console.log(`→ MCP ready at http://localhost:${PORT}/mcp`))
8. Widget Template
<!-- widgets/weather.html --> <section class="card"> <h2>Weather for {{city}}</h2> <p>{{summary}}, {{temp}}°C</p> </section>
9. Run It
npm run dev # App: http://localhost:5173 # MCP: http://localhost:4321/mcp
In ChatGPT → Settings → Connectors → Create → set:
https://your-app.vercel.app/mcp
Then run the tool:
getWeather { "city": "San Francisco" }
You’ll see your widget rendered inline in ChatGPT.