Back to Journal
13 min read

WebMCP in React: Exposing Client-Side State and Form Actions to Browser AI Agents

Turn React 19 web applications into agent-operable surfaces using the emerging W3C WebMCP standard, declarative form attributes, and idiomatic useWebMCPTool hooks.

React.jsTypeScriptModel Context Protocol (MCP)LLM AgentsIn-Browser LLMs
WebMCP in React: Exposing Client-Side State and Form Actions to Browser AI Agents

When software engineers integrate large language models with external systems, the Model Context Protocol (MCP) has rapidly become the standard wire format for tool invocation. However, nearly all existing MCP deployments assume a client-server relationship: a client (such as Claude Desktop or an IDE assistant) speaks to an external background process over stdio or Server-Sent Events (SSE) to query databases, call cloud APIs, or inspect file trees.

That model breaks down inside the browser.

Modern web applications are rich, stateful single-page applications (SPAs). Critical state—such as active canvas selections, unsaved form inputs, client-side pagination, sorting parameters, and interactive modal dialogs—lives strictly inside client memory (React component state, Zustand stores, or the DOM). When an autonomous browser agent or extension tries to interact with a web app, it is forced to fall back on fragile visual DOM scraping, accessibility tree parsing, or simulated synthetic mouse clicks.

The emerging WebMCP (Web Model Context Protocol) specification—currently developed within the W3C Web Machine Learning Community Group and previewed in Chromium—solves this problem by bringing the Model Context Protocol directly into the browser tab.

This guide details how WebMCP operates, how it differs from server-side MCP, and how to expose React 19 component state and form actions to in-browser agents using declarative HTML attributes and idiomatic React hooks.

CODE
+-------------------------------------------------------------------------+
| Browser Tab (Main Thread / React 19 Context)                            |
|                                                                         |
|  [ React Component State ] <---> [ useWebMCPTool Hook ]                 |
|            |                                |                           |
|            v                                v                           |
|  [ <form toolname="..." /> ]      [ document.modelContext ]             |
|            |                      (In-Memory Tool Registry)             |
|            +--------------------------------+                           |
+---------------------------------------------|---------------------------+
                                              | Local In-Tab Dispatch
+---------------------------------------------v---------------------------+
| In-Browser Agent Context (Chrome Assistant / Extension / Gemini Nano)   |
|                                                                         |
|  1. Inspects active tab tools via document.modelContext.listTools()     |
|  2. Invokes tool with structured JSON arguments                         |
|  3. React action dispatches state transition -> UI updates instantly    |
+-------------------------------------------------------------------------+

The Client-Side Tool Vacuum

To understand why WebMCP is necessary, consider how an autonomous agent currently attempts to perform a simple task, such as "filter the table to show orders over $500 and export them":

  1. Accessibility Tree Scraping: The agent queries the accessibility tree or captures a screenshot. It attempts to identify the filter dropdown button among dozens of nested <div> and <button> elements.
  2. Selector Fragility: If the team updates CSS modules, Tailwind utility classes, or DOM hierarchies, automated CSS selectors immediately fail.
  3. Context Overhead: Sending a multi-megabyte DOM tree or image snapshot to an LLM on every step consumes tens of thousands of tokens and incurs seconds of round-trip latency.
  4. Synthetic Click Pitfalls: Synthesizing MouseEvent or KeyboardEvent triggers often bypasses React synthetic event handlers, leading to stale closures or missed validations.

WebMCP replaces synthetic DOM interaction with structured, programmatic RPC executed directly inside the client execution context. Instead of guessing selectors, the page registers explicit tools with typed JSON schemas.

WebMCP vs. Backend MCP

The distinction between backend MCP and WebMCP is fundamental:

Dimension Backend MCP (Node.js / Python) WebMCP (Browser Native)
Execution Context Background daemon, container, serverless function Active browser tab execution thread
Transport stdio, Server-Sent Events (SSE), WebSockets Direct JavaScript function reference
Target Data Remote databases, file systems, third-party APIs React state, client-side router, local storage, DOM
Primitives Supported Tools, Resources, Prompts Tools only (scoped to the active document)
Security Boundary Process isolation, OS permissions, API keys Browser sandbox, Same-Origin Policy, user confirmation

WebMCP does not use network sockets or child processes. The web document itself acts as the tool registry through the document.modelContext interface.


The Browser Architecture: document.modelContext

In Chromium implementations (available in Chromium 146+ behind the #enable-webmcp-testing flag), the browser exposes an in-memory tool broker attached to the document global:

TYPESCRIPT
// Core interface for browser-native WebMCP
interface ModelContextTool {
  name: string;
  description: string;
  inputSchema: Record<string, unknown>;
  execute: (input: Record<string, unknown>) => Promise<Record<string, unknown>>;
  annotations?: {
    readOnlyHint?: boolean;
  };
}

interface ModelContext {
  registerTool(tool: ModelContextTool, options?: { signal?: AbortSignal }): void;
  listTools(): Promise<ModelContextTool[]>;
}

declare global {
  interface Document {
    modelContext?: ModelContext;
  }
}

Two architectural rules govern this interface:

  1. Document Scoping: Tools are bound to the document lifecycle. When a user navigates away or closes a tab, the registry is destroyed.
  2. AbortSignal Unregistration: Rather than exposing an unregisterTool method, tool lifecycles are controlled via standard AbortSignal primitives. When the signal aborts, the browser unregisters the tool cleanly.

Pattern 1: Declarative WebMCP with React 19 Forms

The simplest way to expose functionality to browser agents is the Declarative WebMCP API. WebMCP extends standard HTML forms with agent annotations:

  • toolname: The unique identifier for the tool.
  • tooldescription: A concise explanation of the tool's behavior and when the agent should call it.

In React 19, declarative forms integrate directly with useActionState and Server/Client Actions.

TSX
"use client";

import React, { useActionState } from "react";

interface FilterState {
  minAmount: number;
  category: string;
  status: "idle" | "applied";
}

async function applyFilterAction(
  prevState: FilterState,
  formData: FormData
): Promise<FilterState> {
  const minAmount = Number(formData.get("minAmount") || 0);
  const category = String(formData.get("category") || "all");

  // Perform client-side filter computation or query
  return {
    minAmount,
    category,
    status: "applied",
  };
}

export function AgenticOrderFilter() {
  const [state, formAction, isPending] = useActionState(applyFilterAction, {
    minAmount: 0,
    category: "all",
    status: "idle",
  });

  return (
    <form
      action={formAction}
      // WebMCP Declarative Tool Annotations
      toolname="filter-orders"
      tooldescription="Filters the current order ledger by minimum dollar amount and product category."
      className="filter-form"
    >
      <label htmlFor="minAmount">Minimum Amount ($)</label>
      <input
        id="minAmount"
        name="minAmount"
        type="number"
        defaultValue={state.minAmount}
        required
      />

      <label htmlFor="category">Category</label>
      <select id="category" name="category" defaultValue={state.category}>
        <option value="all">All Categories</option>
        <option value="hardware">Hardware</option>
        <option value="software">Software</option>
      </select>

      <button type="submit" disabled={isPending}>
        {isPending ? "Filtering..." : "Apply Filter"}
      </button>

      {state.status === "applied" && (
        <p className="status-text">
          Showing orders &gt; ${state.minAmount} in category &quot;{state.category}&quot;
        </p>
      )}
    </form>
  );
}

When an agent visits the page, the browser scans the DOM for forms with toolname attributes and generates a dynamic tool schema directly from the input element names, types, and required constraints. When the agent invokes filter-orders, the browser synthesizes the form submission into React's formAction handler.


Pattern 2: The Imperative useWebMCPTool React Hook

While declarative forms work well for standard inputs, complex applications require imperative tool registration. For instance, an agent might need to toggle a canvas zoom layer, query an in-memory client table, or step through a multistep wizard.

To make this idiomatic in React, we need a custom hook that solves three critical problems:

  1. Dynamic Lifecycle: Tools must be registered when a component mounts and removed when it unmounts.
  2. Fresh Closure Access: The tool executor must access the latest React props and state without triggering unnecessary re-registrations.
  3. AbortSignal Teardown: Proper resource cleanup when switching SPA routes.

Here is a complete, production-ready useWebMCPTool implementation:

TYPESCRIPT
"use client";

import { useEffect, useRef } from "react";

export interface ToolDefinition<TInput = Record<string, unknown>, TOutput = Record<string, unknown>> {
  name: string;
  description: string;
  inputSchema: Record<string, unknown>;
  execute: (input: TInput) => Promise<TOutput>;
  readOnlyHint?: boolean;
}

/**
 * Registers an imperative tool with document.modelContext, ensuring
 * safe teardown with AbortSignal and fresh closure references.
 */
export function useWebMCPTool<TInput = Record<string, unknown>, TOutput = Record<string, unknown>>(
  tool: ToolDefinition<TInput, TOutput>,
  enabled: boolean = true
) {
  // Store the latest executor in a ref to avoid re-registering on every state change
  const executeRef = useRef(tool.execute);
  useEffect(() => {
    executeRef.current = tool.execute;
  });

  useEffect(() => {
    // Feature detection: Check for browser WebMCP support
    if (typeof document === "undefined" || !document.modelContext || !enabled) {
      return;
    }

    const abortController = new AbortController();

    try {
      document.modelContext.registerTool(
        {
          name: tool.name,
          description: tool.description,
          inputSchema: tool.inputSchema,
          execute: async (args: Record<string, unknown>) => {
            return await executeRef.current(args as TInput);
          },
          annotations: tool.readOnlyHint ? { readOnlyHint: true } : undefined,
        },
        { signal: abortController.signal }
      );
    } catch (err) {
      console.warn(`[WebMCP] Failed to register tool "${tool.name}":`, err);
    }

    // Teardown: AbortSignal unregisters the tool automatically
    return () => {
      abortController.abort();
    };
  }, [tool.name, tool.description, tool.readOnlyHint, enabled]);
}

Implementing the Hook in a Real Component

Consider an interactive document viewer where the agent can search loaded pages and jump to a specific page index:

TSX
"use client";

import React, { useState, useCallback } from "react";
import { useWebMCPTool } from "./useWebMCPTool";

interface DocumentViewerProps {
  totalPages: number;
  documentTitle: string;
}

export function DocumentViewer({ totalPages, documentTitle }: DocumentViewerProps) {
  const [currentPage, setCurrentPage] = useState<number>(1);
  const [selection, setSelection] = useState<string>("");

  // Tool 1: Jump to a specific page (Mutating action)
  useWebMCPTool({
    name: "navigate-document-page",
    description: "Navigates the interactive PDF viewer to a specific page number.",
    inputSchema: {
      type: "object",
      properties: {
        pageNumber: {
          type: "integer",
          minimum: 1,
          maximum: totalPages,
          description: "Target page index to display",
        },
      },
      required: ["pageNumber"],
    },
    execute: async (args: { pageNumber: number }) => {
      if (args.pageNumber < 1 || args.pageNumber > totalPages) {
        return {
          success: false,
          error: `Page ${args.pageNumber} out of bounds (1-${totalPages}).`,
        };
      }
      setCurrentPage(args.pageNumber);
      return {
        success: true,
        activePage: args.pageNumber,
        documentTitle,
      };
    },
  });

  // Tool 2: Read current selection (Read-only query)
  useWebMCPTool({
    name: "get-active-selection",
    description: "Retrieves the currently highlighted text snippet in the viewer.",
    inputSchema: {
      type: "object",
      properties: {},
    },
    readOnlyHint: true,
    execute: async () => {
      return {
        hasSelection: selection.length > 0,
        text: selection,
        pageNumber: currentPage,
      };
    },
  });

  return (
    <div className="viewer-container">
      <header>
        <h3>{documentTitle}</h3>
        <span>Page {currentPage} of {totalPages}</span>
      </header>
      <main
        onMouseUp={() => {
          const selectedText = window.getSelection()?.toString() || "";
          setSelection(selectedText);
        }}
        className="document-canvas"
      >
        <p>Displaying page content for page {currentPage}...</p>
      </main>
    </div>
  );
}

Pattern 3: Safety Guardrails and Human-in-the-Loop Confirmation

Exposing client-side functions directly to AI agents introduces critical security and stability considerations:

  • An agent could execute a destructive client action (such as wiping unsaved form data or submitting a wire transfer).
  • Malicious prompt injections from scraped web content could instruct an assistant to trigger sensitive client tools.

WebMCP provides two mechanisms to mitigate these risks: Read-Only Hints and Human-in-the-Loop Confirmation Barriers.

1. Read-Only Hints

When registering query tools, always annotate the tool with annotations: { readOnlyHint: true }. This signals to the agent and browser runtime that executing this tool produces zero side effects, allowing the model to plan multi-step information-gathering queries without asking for user permission.

2. The Confirmation Barrier Pattern

For state-mutating or destructive actions, do not resolve the tool call immediately. Instead, trigger a React confirmation state that presents the user with an explicit modal or toast. The tool promise only resolves once the user clicks "Approve".

TSX
"use client";

import React, { useState, useRef } from "react";
import { useWebMCPTool } from "./useWebMCPTool";

interface PendingAction {
  id: string;
  description: string;
  resolve: (value: { approved: boolean }) => void;
}

export function DestructiveActionShield() {
  const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);

  useWebMCPTool({
    name: "delete-active-workspace",
    description: "Permanently deletes the current active workspace. Requires user confirmation.",
    inputSchema: {
      type: "object",
      properties: {
        reason: { type: "string", description: "Reason for deletion" },
      },
      required: ["reason"],
    },
    execute: async (input: { reason: string }) => {
      // Pause tool execution and wait for manual user approval
      return new Promise((resolve) => {
        setPendingAction({
          id: crypto.randomUUID(),
          description: `Delete workspace: "${input.reason}"`,
          resolve,
        });
      });
    },
  });

  return (
    <>
      {pendingAction && (
        <aside className="confirmation-modal" role="alertdialog">
          <h4>Agent Action Confirmation</h4>
          <p>An AI assistant requested permission to:</p>
          <blockquote>{pendingAction.description}</blockquote>
          <div className="button-row">
            <button
              type="button"
              onClick={() => {
                pendingAction.resolve({ approved: false });
                setPendingAction(null);
              }}
            >
              Reject
            </button>
            <button
              type="button"
              className="danger-btn"
              onClick={() => {
                pendingAction.resolve({ approved: true });
                setPendingAction(null);
              }}
            >
              Confirm Deletion
            </button>
          </div>
        </aside>
      )}
    </>
  );
}

Pattern 4: Closed-Loop Local Agent: WebMCP + Chrome Gemini Nano

One of the most powerful applications of WebMCP is closing the loop with on-device language models. By combining the W3C Prompt API (window.LanguageModel) with document.modelContext, you can construct an entirely client-side, zero-latency autonomous loop.

CODE
+-------------------------------------------------------------+
| Browser Tab Memory Space                                    |
|                                                             |
|  [ User Goal ] ---> [ window.LanguageModel Session ]        |
|                               |                             |
|                               v                             |
|                   [ Tool Call JSON Output ]                 |
|                               |                             |
|                               v                             |
|               [ document.modelContext.execute ]             |
|                               |                             |
|                               v                             |
|                 [ React State Mutates DOM ]                 |
|                               |                             |
|                               v                             |
|             (Observation Result fed back to model)          |
+-------------------------------------------------------------+
TYPESCRIPT
// Client-side agent loop executing in the browser tab
export async function runLocalTabAgent(userPrompt: string): Promise<string> {
  // 1. Verify availability of on-device LLM and WebMCP
  if (!("ai" in window) || !document.modelContext) {
    throw new Error("On-device AI or WebMCP is not supported in this browser.");
  }

  // 2. Discover available tools exposed by mounted React components
  const availableTools = await document.modelContext.listTools();
  
  // Format tool descriptions for system prompt
  const toolDeclarations = availableTools.map((t) => ({
    name: t.name,
    description: t.description,
    parameters: t.inputSchema,
  }));

  // 3. Instantiate local session with Gemini Nano
  const session = await (window as unknown as {
    ai: {
      languageModel: {
        create: (options: { systemPrompt: string }) => Promise<{
          prompt: (msg: string) => Promise<string>;
          destroy: () => void;
        }>;
      };
    };
  }).ai.languageModel.create({
    systemPrompt: `You are an in-tab browser assistant. You can control the active page using tools: ${JSON.stringify(
      toolDeclarations
    )}. Output JSON tool invocations as {"tool": string, "args": object}.`,
  });

  try {
    const response = await session.prompt(userPrompt);
    
    // Parse structured tool call
    const action = JSON.parse(response);
    const targetTool = availableTools.find((t) => t.name === action.tool);

    if (targetTool) {
      const result = await targetTool.execute(action.args);
      return `Tool executed successfully: ${JSON.stringify(result)}`;
    }

    return response;
  } finally {
    session.destroy();
  }
}

This entire execution takes place within device silicon:

  • TTFT is under 30 milliseconds.
  • Data never leaves the client machine, satisfying GDPR and strict healthcare privacy requirements.
  • Zero cloud inference costs are incurred.

Production Realities and Progressive Enhancement

As WebMCP progresses through the standards track, production web applications should adopt it as a progressive enhancement:

  1. Feature Detection: Always guard document.modelContext checks. React components should continue to function normally for human users regardless of whether an agent API is present.
  2. Compact Schemas: Foundation models running on client devices have tighter context budgets (often 4k to 8k tokens) than large cloud clusters. Keep tool parameter descriptions concise and avoid deeply nested JSON schemas.
  3. Atomic State Updates: Ensure tool executors resolve only after React has completed state updates. If an agent executes Tool B immediately after Tool A, Tool B must see the latest updated state to prevent race conditions.

By combining React 19's declarative form primitives with imperative WebMCP hooks, web developers can transform passive user interfaces into agent-ready application surfaces.

Share this article