Technical architecture of the MCP UI + WebMCP starter.
This starter combines three technologies:
- MCP (Model Context Protocol): Standardized protocol for AI assistants to interact with services
- MCP UI: Extension allowing servers to return interactive UI components
- WebMCP: Browser-based protocol for embedded apps to register tools back to the MCP server
Result: Bidirectional integration where:
- AI assistants can display interactive web UIs
- UIs can dynamically register tools for the AI to use
┌─────────────────────────────────────────┐
│ AI Assistant │
│ ┌──────────────────────────────────┐ │
│ │ MCP Client │ │
│ │ - Calls showTicTacToeGame │ │
│ │ - Receives iframe URL │ │
│ │ - Gets tools from WebMCP │ │
│ └──────────────────────────────────┘ │
└────────────┬────────────────────────────┘
│ HTTP/SSE
↓
┌─────────────────────────────────────────┐
│ Cloudflare Worker │
│ ┌──────────────────────────────────┐ │
│ │ Hono Router (worker/index.ts) │ │
│ │ - /mcp → MCP protocol │ │
│ │ - /sse → Server-sent events │ │
│ │ - / → TicTacToe app (static) │ │
│ │ - /api/stats → Stats endpoints │ │
│ └──────────────────────────────────┘ │
│ ┌──────────────────────────────────┐ │
│ │ MyMCP (Durable Object) │ │
│ │ - MCP server implementation │ │
│ │ - 5 tools registered │ │
│ └──────────────────────────────────┘ │
│ ┌──────────────────────────────────┐ │
│ │ GameStatsStorage (DO) │ │
│ │ - WebSocket connections │ │
│ │ - Real-time stats tracking │ │
│ └──────────────────────────────────┘ │
└────────────┬────────────────────────────┘
│ Serves iframe
↓
┌─────────────────────────────────────────┐
│ TicTacToe App (iframe) │
│ ┌──────────────────────────────────┐ │
│ │ React App (src/main.tsx) │ │
│ │ - Initializes WebMCP │ │
│ │ - Renders TicTacToeWithWebMCP │ │
│ └──────────────────────────────────┘ │
│ ┌──────────────────────────────────┐ │
│ │ WebMCP (useWebMCP hook) │ │
│ │ - Registers 3 tools │ │
│ │ - Handles tool calls │ │
│ │ - Returns results via postMsg │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
Responsibilities:
- Route HTTP requests using Hono
- Serve MCP protocol endpoints
- Serve static TicTacToe app
- Handle game statistics API
Key Routes:
// MCP Protocol
app.all('/mcp', ...) → MyMCP.serve()
app.all('/sse/*', ...) → MyMCP.serveSSE()
// Game Statistics API
app.get('/api/stats', ...) → GameStatsStorage
app.get('/api/stats/ws', ...) → GameStatsStorage (WebSocket)
app.post('/api/stats/game-complete', ...) → GameStatsStorage
// 404 Handler
app.notFound(...) → JSON error responseWhy Hono?
- Lightweight (~12KB) and fast
- Built for edge runtimes
- Clean routing API with CORS support
- Type-safe with TypeScript
Extends McpAgent from the agents library which handles:
- Durable Object lifecycle
- HTTP/SSE transport
- Session management
- Request routing
Tools Registered:
showExternalUrl- Display external website in iframeshowRawHtml- Render raw HTML contentshowRemoteDom- Execute JavaScript to build DOMshowTicTacToeGame- Launch TicTacToe game (WebMCP enabled)tictactoe_get_stats- Get global game statistics
Prompts Registered:
PlayTicTacToe- Pre-configured prompt to start a game
Real-time statistics tracking using WebSocket and Durable Objects.
Features:
- WebSocket hibernation support (cost-effective)
- Live game counting via connection tracking
- Automatic stats broadcasting to all connected clients
- Tracks: Clankers wins, Carbon Units wins, draws, live games
Endpoints:
GET /stats- Get current statisticsPOST /game-complete- Record game result- WebSocket upgrade - Real-time updates
Architecture:
src/
├── main.tsx # Entry point, initializes WebMCP
├── TicTacToe.tsx # Pure game component (no WebMCP)
├── TicTacToeWithWebMCP.tsx # WebMCP integration layer
├── ErrorBoundary.tsx # Error handling
└── lib/utils.ts # Utility functions
Separation of Concerns:
TicTacToe.tsx- Pure, reusable game logic (can work standalone)TicTacToeWithWebMCP.tsx- WebMCP wrapper that registers tools
This allows:
- Easier testing (pure component)
- Reusability (use TicTacToe without WebMCP)
- Clear separation of concerns
Initialization (src/main.tsx):
import { initializeWebModelContext } from '@mcp-b/global';
// MUST be called BEFORE React renders
initializeWebModelContext({
transport: {
tabServer: {
allowedOrigins: ['*'],
// postMessageTarget defaults to window.parent
},
},
});Tool Registration (src/TicTacToeWithWebMCP.tsx):
import { useWebMCP } from '@mcp-b/react-webmcp';
import { z } from 'zod';
useWebMCP({
name: "tictactoe_ai_move",
description: "Make a move as the AI player",
inputSchema: {
position: z.number().min(0).max(8)
},
handler: async ({ position }) => {
// Game logic executes here
const result = performMove(position, agentPlayer);
return formatMoveMarkdown(result);
}
});Tools Registered by TicTacToe:
tictactoe_get_state- Get current board statetictactoe_ai_move- Make a move (AI player)tictactoe_reset- Reset the game
- AI calls
showTicTacToeGametool - MCP server returns UI resource with iframe URL (
APP_URL/) - AI assistant displays iframe
- TicTacToe app loads and initializes WebMCP
- TicTacToe registers tools via
useWebMCP - AI receives tool registrations
- AI can now call
tictactoe_*tools
Tool Registration (iframe → parent):
{
type: "webmcp-tool-register",
tool: {
name: "tictactoe_ai_move",
description: "...",
inputSchema: { /* Zod schema */ }
}
}Tool Call (parent → iframe):
{
type: "webmcp-tool-call",
toolName: "tictactoe_ai_move",
params: { position: 4 },
callId: "unique-id"
}Tool Result (iframe → parent):
{
type: "webmcp-tool-result",
callId: "unique-id",
result: "Markdown formatted result"
}The iframe handles multiple readiness signals to ensure reliable communication:
// Iframe sends on load
{ type: 'ui-lifecycle-iframe-ready' }
// Parent may respond with any of:
{ type: 'parent-ready' }
{ type: 'ui-lifecycle-iframe-render-data' }
{ type: 'ui-message-response', payload: { status: 'ready' } }This prevents race conditions where tools are registered before the parent is listening.
The iframe notifies the parent of its dimensions for proper sizing:
{
type: 'ui-size-change',
payload: {
width: 800,
height: 600
}
}Sent:
- Once on initial load
- (Could be sent on resize if needed)
Single-entry build with multiple plugins:
export default defineConfig({
plugins: [
react({
babel: {
plugins: ['babel-plugin-react-compiler'] // React 19 compiler
}
}),
tailwindcss(), // Tailwind CSS v4
cloudflare() // Cloudflare Workers integration
],
server: {
port: 8888,
strictPort: true
}
});Development (pnpm dev):
- Vite dev server at
http://localhost:8888 - Hot module replacement (HMR)
- Cloudflare Workers local simulation
Production (pnpm build):
tsc -b- TypeScript compilationvite build- Creates two bundles:dist/client/- TicTacToe web app (HTML, CSS, JS)dist/mcp_ui_with_webmcp_my_mcp_server/- Cloudflare Worker bundle
pnpm deployRuns deploy.sh which:
- Runs
pnpm build - Loads
.prod.varsenvironment variables - Deploys to Cloudflare Workers with
wrangler deploy
pnpm devstarts Vite + Cloudflare Workers local- Visit
http://localhost:8888/→ TicTacToe app - Visit
http://localhost:8888/mcp→ MCP endpoint - Connect AI assistant to
http://localhost:8888/mcp - Call
showTicTacToeGame→ loads iframe from same origin
- Deploy to Cloudflare Workers
- Worker serves at
https://your-worker.workers.dev - MCP endpoint at
/mcp - TicTacToe app served from root
/ - Iframe URL uses
APP_URLfrom.prod.vars
.dev.vars (development):
APP_URL=http://localhost:8888.prod.vars (production):
APP_URL=https://your-worker.workers.dev
# Or custom domain:
APP_URL=https://beattheclankers.comThe MCP server uses APP_URL to construct iframe URLs that work in any environment.
For MCP Server:
- Session persistence across requests
- Multiple AI clients can share state
- Automatic scaling per client
For GameStatsStorage:
- Centralized statistics storage
- WebSocket connection management
- Atomic updates to stats
This starter intentionally uses a simple single-app architecture:
- Easier to understand - Less abstraction
- Easier to customize - Modify one app, not a platform
- Still extensible - Can add more apps as separate workers if needed
For multi-app platforms, consider:
- Separate Cloudflare Workers per app
- Monorepo with shared packages
- Turborepo for orchestration
TicTacToe.tsx (Pure component):
- No WebMCP dependency
- Can be used in any React app
- Easy to test
- Controlled/uncontrolled modes
TicTacToeWithWebMCP.tsx (Integration layer):
- Wraps pure component
- Handles WebMCP registration
- Manages parent communication
- Tracks AI/human roles
This pattern is recommended for complex UI components.
TabServer is the WebMCP transport for iframe communication:
- Uses
postMessageAPI (standard browser API) - Works in any modern browser
- No WebSocket needed (simplifies deployment)
- Handles bidirectional communication
Alternative transports:
- HttpServer - For standalone servers
- StdioServer - For CLI tools
- Custom - Build your own transport
- Cold start: ~5-10ms (minimal)
- Edge location: Runs close to users
- Durable Objects: Slightly higher latency (centralized) but worth it for state
- WebSocket hibernation: Reduces costs for idle connections
The starter uses React 19's experimental compiler:
- Automatic memoization
- Reduced re-renders
- No need for
useMemo/useCallback
Enable in vite.config.ts:
react({
babel: {
plugins: ['babel-plugin-react-compiler']
}
})Production build sizes:
- TicTacToe app: ~450KB (includes React 19, WebMCP, Tailwind)
- Worker bundle: ~800KB (includes Hono, MCP SDK, agents)
Optimization tips:
- Tree-shaking works automatically
- Tailwind purges unused CSS
- Vite code-splits automatically
Worker uses wildcard CORS for development:
cors({
origin: '*',
allowHeaders: ['*'],
allowMethods: ['*']
})For production, tighten this:
cors({
origin: 'https://your-ai-assistant.com',
allowHeaders: ['Content-Type', 'X-Anthropic-API-Key'],
allowMethods: ['GET', 'POST']
})TabServer accepts messages from any origin (allowedOrigins: ['*']).
For production, specify allowed origins:
initializeWebModelContext({
transport: {
tabServer: {
allowedOrigins: ['https://your-ai-assistant.com']
}
}
});.dev.vars and .prod.vars are committed to git because they contain:
- Public URLs only
- No secrets
For actual secrets (API keys), use:
- Cloudflare Workers secrets:
wrangler secret put SECRET_NAME .vars.localfiles (gitignored)
- Check WebMCP initialization happens before React renders
- Verify
allowedOriginsincludes the AI assistant's origin - Check browser console for WebMCP errors
- Ensure parent window is ready (check readiness protocol)
- Verify
APP_URLin.dev.varsor.prod.vars - Check CORS configuration
- Ensure static assets are built (
pnpm build) - Check worker logs (
wrangler tail)
- Run
pnpm typecheckto check TypeScript errors - Clear build cache:
rm -rf dist/ node_modules/.vite/ - Reinstall dependencies:
pnpm install
- Check Durable Object bindings in
wrangler.jsonc - Verify WebSocket upgrade headers
- Check browser console for WebSocket errors
- Ensure GameStatsStorage is deployed
- MCP Specification
- WebMCP Documentation
- Cloudflare Workers Docs
- Durable Objects Docs
- Hono Documentation
- React 19 Release
This architecture balances simplicity with real-world production patterns. It's designed to be a starter, not a framework.