Skip to main content

What You’re Building

Building a bidirectional MCP-UI app means the AI agent can invoke your code, which then provides UI to the user, which can then invoke the AI again. This creates interactive workflows where the interface evolves based on what the agent decides to do. npx create-webmcp-app scaffolds a bidirectional system with three components:
  1. MCP Server (Cloudflare Worker) - Exposes tools to AI agents
  2. Embedded Web App (React or Vanilla JS) - Runs in an iframe, registers its own tools
  3. Communication Layer - IframeParentTransport ↔ IframeChildTransport
Your embedded app registers tools that AI can call, creating bidirectional interaction.

Quick Navigation

Quick Start

npx create-webmcp-app
  • Vanilla Template
  • React Template
HTML/CSS/JavaScript with no build step. Uses CDN for dependencies.
# Select "vanilla" when prompted
cd your-project
pnpm dev
Runs at: http://localhost:8889
How it works: See MCP-UI Architecture for detailed diagrams and communication flow between the AI agent, MCP server, host application, and your embedded app.

Part 1: The MCP Server

The MCP server (worker/mcpServer.ts) exposes tools that return UI resources.

Example: Template MCP Server

import { createUIResource } from '@mcp-ui/server';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { McpAgent } from 'agents/mcp';

export class TemplateMCP extends McpAgent<Cloudflare.Env> {
  server = new McpServer({
    name: 'webmcp-template',
    version: '1.0.0',
  });

  async init() {
    /**
     * This tool tells the AI "here's an interactive web app you can use"
     * Returns a UIResource that the host application will render
     */
    this.server.tool(
      'showTemplateApp',
      `Display the template web application with WebMCP integration.

After calling this tool, the app will appear and register the following WebMCP tools:
- template_get_message: Get the current message from the app
- template_update_message: Update the message displayed in the app
- template_reset: Reset the message to default`,
      {},
      async () => {
        // Point to your embedded app
        const iframeUrl = `${this.env.APP_URL}/`;

        // Create UI resource with iframe URL
        const uiResource = createUIResource({
          uri: 'ui://template-app',
          content: {
            type: 'externalUrl',  // Tell host to load this in iframe
            iframeUrl: iframeUrl,
          },
          encoding: 'blob',
        });

        return {
          content: [
            {
              type: 'text',
              text: `# Template App Started

The template app is now displayed in the side panel.

**Available tools** (registered via WebMCP):
- \`template_get_message\` - View the current message
- \`template_update_message\` - Change the message
- \`template_reset\` - Reset to default

Try calling these tools to interact with the app!`,
            },
            uiResource,
          ],
        };
      }
    );
  }
}
The tools mentioned in the description are registered by the iframe, not by this server.

Part 2: The Embedded App (Vanilla)

The Vanilla template uses @mcp-b/global via CDN—no build step required.
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>WebMCP Template</title>
  <script src="https://unpkg.com/@mcp-b/global@latest/dist/index.iife.js"></script>
</head>
<body>
  <div id="app">
    <h1>WebMCP Template</h1>
    <div id="status">Connecting...</div>
    <p id="message">Hello from WebMCP!</p>
    <button id="btn-update">Update</button>
    <button id="btn-reset">Reset</button>
  </div>

  <script>
    let message = 'Hello from WebMCP!';

    // Parent-child ready protocol
    window.addEventListener('message', (e) => {
      if (e.data?.type === 'parent_ready') {
        document.getElementById('status').textContent = 'Connected';
      }
    });
    window.parent.postMessage({ type: 'iframe_ready' }, '*');

    // Register WebMCP tools
    window.navigator.modelContext.provideContext({
      tools: [
        {
          name: 'template_update_message',
          description: 'Update the message displayed in the app',
          inputSchema: {
            type: 'object',
            properties: {
              newMessage: { type: 'string', description: 'New message' }
            },
            required: ['newMessage']
          },
          async execute({ newMessage }) {
            message = newMessage;
            document.getElementById('message').textContent = message;
            return { content: [{ type: 'text', text: `Updated: ${message}` }] };
          }
        }
      ]
    });
  </script>
</body>
</html>
Full template with styling and additional tools available via npx create-webmcp-app (select “vanilla”). Tool handlers and UI buttons should share the same state management logic.

Part 3: The Embedded App (React)

src/main.tsx - Initialize WebMCP before rendering:
import { initializeWebModelContext } from '@mcp-b/global';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';

initializeWebModelContext({
  transport: { tabServer: { allowedOrigins: ['*'] } }
});

createRoot(document.getElementById('root')!).render(<App />);
src/App.tsx - Register tools with useWebMCP:
import { useWebMCP } from '@mcp-b/react-webmcp';
import { useState, useEffect } from 'react';
import { z } from 'zod';

export default function App() {
  const [message, setMessage] = useState('Hello from WebMCP!');

  useEffect(() => {
    const handleMessage = (e: MessageEvent) => {
      if (e.data?.type === 'parent_ready') console.log('Connected');
    };
    window.addEventListener('message', handleMessage);
    window.parent.postMessage({ type: 'iframe_ready' }, '*');
    return () => window.removeEventListener('message', handleMessage);
  }, []);

  useWebMCP({
    name: 'template_update_message',
    description: 'Update the message displayed in the app',
    inputSchema: {
      newMessage: z.string().describe('The new message to display'),
    },
    handler: async ({ newMessage }) => {
      setMessage(newMessage);
      return `Message updated to: ${newMessage}`;
    },
  });

  return (
    <div>
      <h1>WebMCP Template</h1>
      <p>{message}</p>
      <button onClick={() => {
        const msg = prompt('Enter message:');
        if (msg) setMessage(msg);
      }}>Update</button>
    </div>
  );
}
Full template with Zod validation, TypeScript types, and additional tools available via npx create-webmcp-app (select “react”).

Development Workflow

1

Start Development Server

Launch your local development environment:
pnpm dev
This starts:
  • MCP server: http://localhost:8888/mcp (or 8889 for vanilla)
  • Embedded app: http://localhost:8888/ (served by the worker)
  • Hot reload: Changes to your app update instantly
2

Connect a Test Client

  • Chat UI
  • Claude Desktop
Use the included chat-ui (in mcp-ui-webmcp repo):
# In a separate terminal
cd ../chat-ui
pnpm dev
# Open http://localhost:5173
3

Test the Interaction

  1. AI calls showTemplateApp → iframe appears
  2. AI calls template_get_message → reads current state
  3. AI calls template_update_message → updates the UI
  4. UI buttons use the same logic as tool handlers

Best Practices

Tool handlers and UI buttons should call the same underlying functions—avoid duplicating state update logic.
Use destructiveHint, idempotentHint, and readOnlyHint to help AI understand tool behavior.
Use JSON Schema (Vanilla) or Zod (React) for type-safe, validated tool inputs.
Wait for the parent_ready message before sending notifications to avoid race conditions.

Deployment

# Build and deploy
pnpm build
pnpm deploy
Update your MCP client to point to the production URL (e.g., https://your-app.workers.dev/mcp). See the mcp-ui-webmcp repository for detailed deployment instructions.

Next Steps

Resources