React MCP SPA
A minimal React single-page app that is bundled into one HTML blob and served by an MCP Apps server. The SPA doesn't use URL path routing — it picks which page to render by reading the current tool name from the MCP host context.
Layout
- packages/ui/ — React + Vite SPA, built to a single-file HTML blob via
vite-plugin-singlefile. - packages/mcp/ — MCP server using
@modelcontextprotocol/ext-apps/server. Registers tools (show-home,show-counter,show-profile) that all point to the sameui://resource. - packages/playground/ — dev-only harness that renders the SPA inside a mock chat UI with JSON inputs for
hostContextandtoolResult, so pages can be exercised without an MCP host.
Install
Requires Node.js ≥ 20 and pnpm ≥ 9.
pnpm install
Develop the UI in isolation
pnpm run dev:ui
Open the printed URL (e.g. http://localhost:5173). The SPA detects that there's no MCP host and shows a route picker so you can preview each page.
Playground — test rendering without MCP
pnpm run dev:playground
Opens on http://localhost:5174. The playground reuses the SPA's real route renderer (RouteRenderer from packages/ui/src/Router.tsx) and wraps it in a mock chat UI. The header has two JSON textareas — one for the hostContext (drives which page renders via toolInfo.tool.name) and one for the toolResult passed to the page. Preset buttons load ready-made context+result pairs for each registered tool; edits to either textarea re-render the assistant's tool-output bubble live.
Build and run the MCP server
pnpm run build # builds packages/ui → dist/index.html, then packages/mcp
pnpm run serve:mcp # starts Streamable HTTP server on http://localhost:3001/mcp
For stdio transport:
pnpm --filter @react-mcp-spa/mcp run serve:stdio
Exposing the server with cloudflared
Some MCP clients (including hosted ones) can't reach localhost. Use cloudflared to open a quick tunnel that assigns a public HTTPS URL.
Install cloudflared via your OS package manager:
# macOS (Homebrew)
brew install cloudflared
# Linux (Debian/Ubuntu)
# See https://pkg.cloudflare.com/ for the apt repo, or grab the .deb from
# https://github.com/cloudflare/cloudflared/releases
# Windows (winget)
winget install --id Cloudflare.cloudflared
Start the server locally:
pnpm run serve:mcp
In another terminal, run:
cloudflared tunnel --url http://localhost:3001
# or: pnpm run tunnel
cloudflared prints a URL like https://<random>.trycloudflare.com. Append /mcp and use it as your MCP server URL:
https://<random>.trycloudflare.com/mcp
The tunnel stays up until you kill cloudflared. For a stable hostname, configure a named Cloudflare tunnel instead of a quick tunnel.
Packaging as a Claude Desktop extension (.mcpb)
The repo ships a packer that produces an installable MCP Bundle for Claude Desktop. The extension runs the server over stdio — no tunnel required.
pnpm run pack:mcpb
This builds both packages, stages the compiled server + UI HTML + prod node_modules under build/mcpb-staging/, and invokes mcpb pack to produce:
build/react-mcp-spa.mcpb
To install it, double-click the .mcpb in Finder/Explorer (or drag it onto Claude Desktop). Claude validates the manifest and registers the server; after installation the three tools (show-home, show-counter, show-profile) are available and each renders its page as an inline React UI.
The bundle manifest lives at mcpb/manifest.json — bump version there (and in packages/mcp/package.json) when cutting a new extension release.
How routing works
The SPA never reads window.location. Instead, packages/ui/src/App.tsx uses useApp() from @modelcontextprotocol/ext-apps/react and:
- Reads
app.getHostContext().toolInfo.tool.nameto know which tool the host invoked — this is the "route". - Subscribes to
app.ontoolresultto receive theCallToolResultfrom the server and pass it to the page as data. - Watches
app.onhostcontextchangedso theme / safe-area / locale updates re-render correctly.
Adding a new page is a two-step change:
- Register a tool in packages/mcp/src/server.ts with
_meta.ui.resourceUripointing at the sharedui://react-mcp-spa/app.htmlresource. - Add a case for the tool name in
renderRoute()inside packages/ui/src/App.tsx.