Terug naar Journal
12 min leestijd

WebMCP in React: Client-Side State en Formulieracties Beschikbaar Maken voor Browser-AI-Agents

Transformeer React 19-webapplicaties in agent-vriendelijke interfaces met de W3C WebMCP-standaard, declaratieve formulierattributen en useWebMCPTool-hooks.

React.jsTypeScriptModel Context Protocol (MCP)LLM AgentsIn-Browser LLMs
WebMCP in React: Client-Side State en Formulieracties Beschikbaar Maken voor Browser-AI-Agents

Wanneer softwareontwikkelaars grote taalmodellen integreren met externe systemen, is het Model Context Protocol (MCP) uitgegroeid tot de standaardcommunicatielaag voor tool-aanroepen. Vrijwel alle bestaande MCP-implementaties hanteren echter een traditioneel client-servermodel: een client (zoals Claude Desktop of een IDE-assistent) communiceert via stdio of Server-Sent Events (SSE) met een achtergrondproces om databases te bevragen, cloud-API's aan te roepen of bestandssystemen te inspecteren.

Binnen de webbrowser schiet dit model echter tekort.

Moderne webapplicaties zijn complexe Single Page Applications (SPA's) met een rijke interne status. Essentiële applicatiestatus—zoals actieve selecties op een canvas, niet-opgeslagen invoervelden, lokale paginering, actieve filterinstellingen en interactieve modale dialoogvensters—leeft uitsluitend in het geheugen van de client (React-componentstatus, Zustand-stores of de DOM). Wanneer een autonome browser-agent of browserextensie probeert te interageren met zo'n applicatie, is deze genoodzaakt terug te vallen op fragiele technieken zoals visual DOM-scraping, het parsen van de accessibility-tree of gesimuleerde muisklikken.

De nieuwe specificatie WebMCP (Web Model Context Protocol)—in ontwikkeling binnen de W3C Web Machine Learning Community Group en als experimentele preview beschikbaar in Chromium—lost dit probleem op door het Model Context Protocol rechtstreeks in het browsertabblad te introduceren.

Deze handleiding legt uit hoe WebMCP functioneert, waarin het verschilt van MCP op de server, en hoe React 19-componentstatus en formulieracties met behulp van declaratieve HTML-attributen en React-hooks direct beschikbaar worden gemaakt voor browser-agents.

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    |
+-------------------------------------------------------------------------+

Het Vacuüm van Client-Side Tools

Om het belang van WebMCP te begrijpen, kijken we naar hoe een autonome agent vandaag de dag een ogenschijnlijk eenvoudige opdracht uitvoert, zoals "filter de tabel op bestellingen boven $ 500 en exporteer de resultaten":

  1. Scraping van de Accessibility-Tree: De agent leest de accessibility-tree uit of maakt een schermafbeelding. Hij probeert de filterknop te raden tussen tientallen geneste <div>- en <button>-elementen.
  2. Kwetsbare Selectors: Zodra het frontend-team CSS-modules, Tailwind-classes of de DOM-hiërarchie aanpast, falen geautomatiseerde CSS-selectors onmiddellijk.
  3. Contextuele Overhead: Het verzenden van een complete DOM-structuur of schermafbeelding bij elke interactie kost tienduizenden tokens en veroorzaakt aanzienlijke netwerklatentie.
  4. Problemen met Synthetische Kliks: Het afvuren van synthetische MouseEvent- of KeyboardEvent-gebeurtenissen omzeilt vaak Reacts synthetische event-handlers, met verouderde closures (stale closures) of overgeslagen validaties tot gevolg.

WebMCP vervangt deze onzekere methoden door gestructureerde, programmatische RPC-aanroepen binnen de JavaScript-uitvoeringscontext van de client. In plaats van te gokken naar selectors, registreert de pagina expliciete tools met getypeerde JSON-schema's.

WebMCP versus Backend-MCP

Het onderscheid tussen MCP op de backend en WebMCP is fundamenteel:

Dimensie Backend-MCP (Node.js / Python) WebMCP (Browser-Natief)
Uitvoeringscontext Achtergronddaemon, container, serverless functie Uitvoeringsthread van het actieve browsertabblad
Transport stdio, Server-Sent Events (SSE), WebSockets Directe JavaScript-functiereferentie
Doelgegevens Externe databases, bestandssystemen, API's van derden React-status, client-router, lokale opslag, DOM
Ondersteunde Primitieven Tools, Resources, Prompts Uitsluitend Tools (gebonden aan het actieve document)
Beveiligingsgrens Procesisolatie, OS-rechten, API-sleutels Browser-sandbox, Same-Origin Policy, gebruikersbevestiging

WebMCP maakt geen gebruik van netwerk-sockets of subprocessen. Het webdocument fungeert zelf als gereedschapsregister via de interface document.modelContext.


De Browser-Architectuur: document.modelContext

In recente Chromium-versies (vanaf Chromium 146 achter de vlag #enable-webmcp-testing) biedt de browser een in-memory tool-broker gekoppeld aan het globale document-object:

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;
  }
}

Twee architecturale regels beheersen deze interface:

  1. Documentbereik: Gereedschappen zijn gekoppeld aan de levenscyclus van het document. Zodra de gebruiker navigeert of het tabblad sluit, wordt het register vernietigd.
  2. Afmelden via AbortSignal: In plaats van een unregisterTool-methode wordt de levenscyclus beheerd via het standaard AbortSignal. Wanneer het signaal wordt geaborteerd, verwijdert de browser het gereedschap automatisch.

Patroon 1: Declaratieve WebMCP met React 19-Formulieren

De meest directe manier om functionaliteit aan browser-agents aan te bieden is via de Declaratieve WebMCP-API. WebMCP breidt reguliere HTML-formulieren uit met annotaties:

  • toolname: De unieke identificatie van de tool.
  • tooldescription: Een beknopte uitleg van de werking en wanneer de agent deze moet aanroepen.

In React 19 sluiten declaratieve formulieren naadloos aan op useActionState en 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>
  );
}

Wanneer een agent de pagina bezoekt, analyseert de browser de DOM op formulieren met een toolname-attribuut en genereert dynamisch een JSON-schema op basis van veldnamen, types en vereiste validaties. Zodra de agent filter-orders aanroept, stuurt de browser de gegevens rechtstreeks door naar Reacts formAction.


Patroon 2: De Imperatieve useWebMCPTool-Hook in React

Hoewel declaratieve formulieren volstaan voor standaardinvoer, vereisen geavanceerde applicaties imperatieve registratie. Een agent moet bijvoorbeeld het zoomniveau van een canvas kunnen wijzigen, een in-memory tabel doorzoeken of een meerstapsdialoog doorlopen.

Om dit op een nette React-conforme manier in te richten, bouwen we een aangepaste hook die drie vereisten adresseert:

  1. Dynamische Levenscyclus: Gereedschappen moeten registreren bij het mounten en verdwijnen bij het unmounten.
  2. Toegang tot Actuele State: De uitvoeringsfunctie moet altijd beschikken over de recentste props en state zonder continue herregistraties.
  3. Opruimen met AbortSignal: Nette afhandeling van resources bij SPA-routenavigatie.

Hier is de volledige implementatie van useWebMCPTool:

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]);
}

Praktijkvoorbeeld in een Component

In het onderstaande voorbeeld tonen we een interactieve documentviewer waarin de agent van pagina kan wisselen en de huidige selectie kan uitlezen:

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>
  );
}

Patroon 3: Beveiligingsmaatregelen en Menselijke Bevestiging (Human-in-the-Loop)

Het direct openstellen van client-functies voor AI-agents brengt operationele en veiligheidsrisico's met zich mee:

  • Een agent zou per ongeluk niet-opgeslagen formulieren kunnen wissen of ongewenste transacties kunnen starten.
  • Schadelijke prompt injections in bekeken webteksten kunnen proberen gevoelige tools ongeoorloofd aan te sturen.

WebMCP biedt twee methoden om deze risico's te beheersen: Read-Only Hints (readOnlyHint) en Interactieve Bevestigingsbarrières.

1. Read-Only Hints

Voorzie opvragende tools altijd van annotations: { readOnlyHint: true }. Dit laat de agent en de browser weten dat de aanroep geen bijwerkingen heeft, waardoor het model gerichte informatie kan verzamelen zonder voortdurend om toestemming te vragen.

2. Het Bevestigingsbarrièrepatroon

Bij wijzigende of gevoelige acties mag de promise van het gereedschap niet meteen resolveren. In plaats daarvan toont de React-interface een expliciet bevestigingsdialoogvenster aan de gebruiker. De promise resolveert pas zodra de gebruiker op "Bevestigen" klikt.

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>
      )}
    </>
  );
}

Patroon 4: Lokale Autonome Loop: WebMCP + Chrome Gemini Nano

Een bijzonder krachtige toepassing van WebMCP ontstaat in combinatie met on-device taalmodellen. Door de W3C Prompt API (window.LanguageModel) te koppelen aan document.modelContext, realiseert u een volledig lokale agent met minimale latentie binnen het tabblad.

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();
  }
}

Deze volledige cyclus draait op de lokale processor:

  • De tijd tot het eerste token (TTFT) ligt onder de 30 milliseconden.
  • Er worden geen gegevens naar externe servers verzonden, in overeenstemming met AVG-vereisten.
  • Er zijn geen terugkerende cloud-inferentiekosten.

Productiepraktijk en Progressive Enhancement

Zolang de WebMCP-specificatie in ontwikkeling is bij normalisatie-instanties, dienen productie-applicaties deze te benaderen volgens het principe van geleidelijke verbetering (progressive enhancement):

  1. Feature-Detectie: Controleer altijd op de aanwezigheid van document.modelContext. De React-applicatie moet vlekkeloos functioneren voor menselijke gebruikers, ook wanneer de browser geen agent-API's ondersteunt.
  2. Compacte Schema's: Lokale modellen beschikken over een kleiner contextvenster (meestal 4k tot 8k tokens) dan grote cloud-modellen. Houd parameterbeschrijvingen beknopt en vermijd diepe JSON-nesting.
  3. Atomaire Statusupdates: Zorg ervoor dat tool-acties pas resolveren nadat React alle state-updates in de DOM heeft afgerond. Dit voorkomt race conditions wanneer een agent meerdere acties achter elkaar uitvoert.

Door React 19-formulierbouwstenen te combineren met WebMCP-hooks kunnen developers statische interfaces omvormen tot dynamische actievelden voor moderne AI-agents.

Share this article