Skip to main content
Glama
billy-yoyo

React MCP SPA

by billy-yoyo
README.md
# React MCP SPA

A minimal React single-page app that is bundled into one HTML blob and served by an [MCP Apps](https://github.com/modelcontextprotocol/ext-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/](packages/ui/) — React + Vite SPA, built to a single-file HTML blob via `vite-plugin-singlefile`.
- [packages/mcp/](packages/mcp/) — MCP server using `@modelcontextprotocol/ext-apps/server`. Registers tools (`show-home`, `show-counter`, `show-profile`) that all point to the same `ui://` resource.
- [packages/playground/](packages/playground/) — dev-only harness that renders the SPA inside a mock chat UI with JSON inputs for `hostContext` and `toolResult`, so pages can be exercised without an MCP host.

## Install

Requires Node.js ≥ 20 and [pnpm](https://pnpm.io/) ≥ 9.

```sh
pnpm install
```

## Develop the UI in isolation

```sh
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

```sh
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](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

```sh
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:

```sh
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](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) to open a quick tunnel that assigns a public HTTPS URL.

Install cloudflared via your OS package manager:

```sh
# 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:

```sh
pnpm run serve:mcp
```

In another terminal, run:

```sh
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](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-remote-tunnel/) instead of a quick tunnel.

## Packaging as a Claude Desktop extension (`.mcpb`)

The repo ships a packer that produces an installable [MCP Bundle](https://github.com/modelcontextprotocol/mcpb) for Claude Desktop. The extension runs the server over stdio — no tunnel required.

```sh
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](mcpb/manifest.json) — bump `version` there (and in [packages/mcp/package.json](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](packages/ui/src/App.tsx) uses `useApp()` from `@modelcontextprotocol/ext-apps/react` and:

1. Reads `app.getHostContext().toolInfo.tool.name` to know which tool the host invoked — this is the "route".
2. Subscribes to `app.ontoolresult` to receive the `CallToolResult` from the server and pass it to the page as data.
3. Watches `app.onhostcontextchanged` so theme / safe-area / locale updates re-render correctly.

Adding a new page is a two-step change:

1. Register a tool in [packages/mcp/src/server.ts](packages/mcp/src/server.ts) with `_meta.ui.resourceUri` pointing at the shared `ui://react-mcp-spa/app.html` resource.
2. Add a case for the tool name in `renderRoute()` inside [packages/ui/src/App.tsx](packages/ui/src/App.tsx).