writing/tutorial/2026/07
TutorialJul 5, 2026·24 min read

WebMCP with Next.js: Expose Your Web App as AI Agent Tools Using @mcp-b (2026)

Learn how to turn your Next.js website into a set of structured MCP tools that AI browser agents can call directly. This hands-on guide covers @mcp-b/react-webmcp, Zod schemas, read-only tools, resources, and human-in-the-loop confirmations with elicitation.

AI agents are learning to use the web, and most of them do it the hard way: parsing your DOM, guessing which button to click, and breaking every time you change a CSS class. WebMCP flips that model. Instead of forcing agents to scrape your pages, your web app declares structured tools — search this catalog, fetch that detail page, submit this form — and any MCP-compatible agent running in the browser can call them directly, with typed inputs and typed outputs.

WebMCP is a proposal incubated in the W3C Web Machine Learning Community Group, championed by engineers from Microsoft and Google. It defines a browser API surface (navigator.modelContext) through which a page registers tools. While browsers ship native support behind flags, the @mcp-b project provides a production-ready polyfill and React bindings you can use today.

In this tutorial you will add WebMCP to a Next.js App Router project: registering read-only search tools, exposing structured resources, and building a lead-capture tool with a human-in-the-loop confirmation step. This is the exact architecture running in production on noqta.tn, where 8 WebMCP tools let AI agents search our services, browse content, and request proposals.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed
  • A Next.js 14+ project using the App Router (or run npx create-next-app@latest)
  • Basic familiarity with React hooks and TypeScript
  • Zod installed (we will use it for tool schemas)
  • Chrome or a Chromium-based browser for testing with the MCP-B extension

What You'll Build

You will build a demo product site that exposes four capabilities to AI agents:

  1. search_products — a read-only tool that searches a product catalog by keyword
  2. get_product_details — a read-only tool that returns full details for one product
  3. A products resource — a structured, addressable JSON document agents can read without calling a tool
  4. request_quote — a write tool that captures a lead, gated behind an explicit user confirmation using WebMCP elicitation

By the end, an AI agent connected to your page (through the MCP-B browser extension or an agentic browser) will be able to answer questions like "find me a laptop under budget and request a quote for it" by calling your tools instead of scraping your HTML.

Step 1: Project Setup

Create a fresh Next.js project if you don't have one:

npx create-next-app@latest webmcp-demo --typescript --app --tailwind
cd webmcp-demo

Install the WebMCP packages and Zod:

npm install @mcp-b/global @mcp-b/react-webmcp zod

If your project already uses Zod 4, you may hit a peer dependency conflict because @mcp-b currently pins Zod 3. Install with the legacy flag: npm install @mcp-b/global @mcp-b/react-webmcp --legacy-peer-deps. The tools still work correctly at runtime.

The two packages play distinct roles:

  • @mcp-b/global is the polyfill. Importing it once patches navigator.modelContext into the browser so that tool registration works even before native browser support lands.
  • @mcp-b/react-webmcp provides React hooks — useWebMCP for tools, useWebMCPResource for resources, and useElicitation for user confirmations — that handle registration and cleanup on mount and unmount.

Step 2: Create the Product Data Layer

Tools need something to operate on. Create a small in-memory catalog at lib/products.ts:

// lib/products.ts
export type Product = {
  slug: string;
  name: string;
  category: string;
  price: number;
  description: string;
  inStock: boolean;
};
 
export const products: Product[] = [
  {
    slug: "aero-14-laptop",
    name: "Aero 14 Ultrabook",
    category: "laptops",
    price: 1299,
    description: "A 14-inch ultrabook with 32GB RAM and 18-hour battery life.",
    inStock: true,
  },
  {
    slug: "titan-desk-pro",
    name: "Titan Desk Pro",
    category: "desks",
    price: 549,
    description: "Electric standing desk with dual motors and memory presets.",
    inStock: true,
  },
  {
    slug: "quiet-buds-x",
    name: "QuietBuds X",
    category: "audio",
    price: 199,
    description: "Noise-cancelling earbuds with adaptive transparency mode.",
    inStock: false,
  },
];
 
export function searchProducts(query: string): Product[] {
  const q = query.toLowerCase();
  return products.filter(
    (p) =>
      p.name.toLowerCase().includes(q) ||
      p.category.toLowerCase().includes(q) ||
      p.description.toLowerCase().includes(q)
  );
}
 
export function getProduct(slug: string): Product | undefined {
  return products.find((p) => p.slug === slug);
}

In a real application these functions would query your database or call your API routes — the tool layer we build next doesn't care where the data comes from.

Step 3: Register Your First Read-Only Tool

Create a client component at components/WebMCPProvider.tsx. WebMCP tools live in the browser, so this file must start with the use client directive:

// components/WebMCPProvider.tsx
"use client";
 
import "@mcp-b/global";
import { useWebMCP } from "@mcp-b/react-webmcp";
import { z } from "zod";
import { searchProducts } from "@/lib/products";
 
// Define schemas at module scope so they are stable across renders.
const searchInput = {
  query: z.string().describe('Search keyword, e.g. "laptop" or "desk"'),
};
 
const productSchema = z.object({
  slug: z.string(),
  name: z.string(),
  category: z.string(),
  price: z.number(),
  inStock: z.boolean(),
});
 
const searchOutput = {
  results: z.array(productSchema),
};
 
export default function WebMCPProvider() {
  useWebMCP({
    name: "search_products",
    description:
      "Search the product catalog by keyword. Returns matching products with name, price, and stock status.",
    inputSchema: searchInput,
    outputSchema: searchOutput,
    annotations: { readOnlyHint: true, openWorldHint: false },
    handler: async ({ query }) => {
      const results = searchProducts(query).map((p) => ({
        slug: p.slug,
        name: p.name,
        category: p.category,
        price: p.price,
        inStock: p.inStock,
      }));
      return { results };
    },
    formatOutput: (o) => JSON.stringify(o.results, null, 2),
  });
 
  return null;
}

Several details here matter more than they look:

  • Schemas live outside the component. If you define Zod schemas inline, they are recreated on every render, and the hook may re-register the tool repeatedly. Module-scope schemas keep registration stable.
  • description is your prompt. The agent decides whether to call your tool based on this text alone. Write it the way you would document an API for a junior developer: what it does, what it returns, and concrete example inputs.
  • annotations guide agent behavior. readOnlyHint: true tells clients this tool has no side effects, so agents can call it freely. openWorldHint: false says the tool operates on a closed dataset — your catalog — rather than the open web.
  • formatOutput controls the human-readable text representation sent alongside the structured output.

Step 4: Add the Details Tool

Inside the same component, register a second tool below the first one. Tools that fail should return structured errors rather than throwing, so the agent can recover:

const detailsInput = {
  slug: z.string().describe('Product slug, e.g. "aero-14-laptop"'),
};
 
const detailsOutput = {
  error: z.string().optional(),
  name: z.string().optional(),
  category: z.string().optional(),
  price: z.number().optional(),
  description: z.string().optional(),
  inStock: z.boolean().optional(),
};
useWebMCP({
  name: "get_product_details",
  description:
    "Get full details of a product by its slug. Use search_products first to find slugs.",
  inputSchema: detailsInput,
  outputSchema: detailsOutput,
  annotations: { readOnlyHint: true, openWorldHint: false },
  handler: async ({ slug }) => {
    const product = getProduct(slug);
    if (!product) {
      return { error: `Product "${slug}" not found. Try search_products first.` };
    }
    return {
      name: product.name,
      category: product.category,
      price: product.price,
      description: product.description,
      inStock: product.inStock,
    };
  },
  formatOutput: (o) => JSON.stringify(o, null, 2),
});

Notice the error message tells the agent what to do next — "Try search_products first" — turning a dead end into a recovery path. Good tool error messages are agent prompts in disguise.

Step 5: Expose a Resource

Tools answer questions; resources publish documents. A resource is an addressable, read-only payload identified by a URI. Agents can read it directly without composing a query, which is perfect for indexes, catalogs, and company information.

Add the useWebMCPResource hook to your provider:

import { useWebMCP, useWebMCPResource } from "@mcp-b/react-webmcp";
import { products } from "@/lib/products";
 
// Inside the component body:
useWebMCPResource({
  uri: "shop://products/index",
  name: "Product Catalog Index",
  description: "Complete list of products with slugs, prices, and stock status.",
  mimeType: "application/json",
  read: async (uri) => ({
    contents: [
      {
        uri: uri.href,
        text: JSON.stringify(products, null, 2),
      },
    ],
  }),
});

You can also declare parameterized URIs using template segments, and read the parameters in the callback:

useWebMCPResource({
  uri: "shop://products/by-category/{category}",
  name: "Products by Category",
  description: "Products filtered by category name.",
  mimeType: "application/json",
  read: async (uri, params) => {
    const category = (params?.category as string) || "";
    const filtered = products.filter((p) => p.category === category);
    return {
      contents: [{ uri: uri.href, text: JSON.stringify(filtered, null, 2) }],
    };
  },
});

A good rule of thumb: if the agent would call the same tool with the same arguments every session just to orient itself, publish that data as a resource instead.

Step 6: Build a Write Tool with Human-in-the-Loop Confirmation

Read-only tools are safe by construction. Write tools — submitting forms, creating leads, sending messages — need a consent gate. WebMCP's answer is elicitation: the tool pauses mid-execution and asks the connected client to collect input from the human user.

Add the useElicitation hook and a confirmation helper:

import { useElicitation, useWebMCP } from "@mcp-b/react-webmcp";
 
// Inside the component body:
const { elicitInput } = useElicitation();
 
const confirmWithUser = async (title: string, preview: object) => {
  const details = JSON.stringify(preview, null, 2);
 
  // Preferred path: WebMCP elicitation renders a native confirm UI
  // in the connected MCP client.
  if (typeof window !== "undefined" && window.navigator?.modelContext) {
    try {
      const result = await elicitInput({
        message: `${title}\n\nReview and confirm:\n\n${details}`,
        requestedSchema: {
          type: "object",
          properties: {
            confirm: {
              type: "boolean",
              title: "Confirm",
              description: "Approve and send this request.",
            },
          },
          required: ["confirm"],
        },
      });
      return result.action === "accept" && result.content?.confirm === true;
    } catch {
      // Fall through to window.confirm below.
    }
  }
 
  // Fallback: plain browser confirm dialog.
  if (typeof window !== "undefined" && typeof window.confirm === "function") {
    return window.confirm(`${title}\n\n${details}\n\nSend?`);
  }
 
  // No confirmation UX available: refuse the side effect.
  return false;
};

Now the write tool itself:

const quoteInput = {
  productSlug: z.string().describe("Slug of the product to quote"),
  email: z.string().email().describe("Contact email for the quote"),
  quantity: z.number().min(1).describe("Number of units"),
};
 
const quoteOutput = {
  success: z.boolean(),
  message: z.string().optional(),
  error: z.string().optional(),
};
 
useWebMCP({
  name: "request_quote",
  description:
    "Request a price quote for a product. Requires user confirmation before sending. Use this only after the user has expressed intent to get a quote.",
  inputSchema: quoteInput,
  outputSchema: quoteOutput,
  annotations: { readOnlyHint: false, openWorldHint: false },
  handler: async ({ productSlug, email, quantity }) => {
    const product = getProduct(productSlug);
    if (!product) {
      return { success: false, error: `Unknown product "${productSlug}"` };
    }
 
    const approved = await confirmWithUser("Send quote request?", {
      product: product.name,
      email,
      quantity,
      estimatedTotal: product.price * quantity,
    });
 
    if (!approved) {
      return { success: false, error: "User declined the quote request." };
    }
 
    const res = await fetch("/api/quotes", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ productSlug, email, quantity }),
    });
 
    if (!res.ok) {
      return { success: false, error: `Server error: ${res.status}` };
    }
 
    return { success: true, message: "Quote request sent. Check your email." };
  },
});

The security posture here is deliberate and worth internalizing:

  1. The agent proposes, the human disposes. The tool never sends anything without an explicit boolean confirmation from the user.
  2. The preview shows exactly what will be sent — product, email, quantity, and estimated total — so the user is confirming facts, not vibes.
  3. When no confirmation UI exists, the tool refuses. Failing closed beats silently submitting.
  4. The server endpoint still validates everything. Browser-side tools run in the user's session with the user's cookies; your API must apply the same auth, validation, and rate limiting it would for any client-side call.

Step 7: Wire the Provider into Your Layout

Mount the provider once, at the root, so tools register on every page:

// app/layout.tsx
import WebMCPProvider from "@/components/WebMCPProvider";
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <WebMCPProvider />
        {children}
      </body>
    </html>
  );
}

Because useWebMCP cleans up on unmount, you can also register page-scoped contextual tools inside individual routes — for example, a get_current_article tool that only exists on article pages and returns the content the user is currently reading. Root-level tools describe what your site can do; page-level tools describe where the user currently is. The combination gives agents both a map and a "you are here" marker.

Testing Your Implementation

Run the dev server:

npm run dev

Then verify the tools are registered. Open the browser console on localhost:3000 and check the polyfill:

// In the browser console:
navigator.modelContext !== undefined; // should be true

For end-to-end testing, install the MCP-B extension from the Chrome Web Store. It acts as an MCP client that discovers tools on whatever page you have open. Open its side panel on your dev site and you should see search_products, get_product_details, and request_quote listed with their descriptions.

The extension also bridges page tools to native MCP hosts. This means desktop agents like Claude Code or Claude Desktop can call tools on the page you have open in Chrome — your website effectively becomes an MCP server without hosting one.

Try a realistic flow in the extension's chat: ask "find me an ultrabook and request a quote for 2 units to test@example.com." A capable agent will chain search_products, then get_product_details, then request_quote — and you will see the elicitation prompt appear, asking you to approve before anything is sent.

Troubleshooting

Tools don't appear in the client. Confirm import "@mcp-b/global" runs before any registration, and that your provider component actually mounts (it must be inside body and must be a client component). Check the console for hydration errors that could unmount it.

Tools register twice in development. React Strict Mode double-mounts components in dev builds. The @mcp-b hooks handle cleanup correctly, but if you registered tools manually via navigator.modelContext.registerTool, you must deregister on unmount yourself — another reason to prefer the hooks.

Peer dependency conflict on install. The @mcp-b packages pin Zod 3 while many 2026 projects use Zod 4. Use --legacy-peer-deps (npm) or accept the resolution warning (pnpm). Keep your tool schemas on the Zod version @mcp-b expects.

Handler changes don't take effect. The hook captures your handler on registration. In development with Fast Refresh this usually resolves itself; in production make sure dynamic values your handler needs come from refs or module state, not stale closures.

The elicitation call throws. Not every MCP client implements elicitation yet. Always wrap elicitInput in try/catch and fall back to window.confirm, as shown in Step 6.

Security Considerations

WebMCP inherits the browser's security model, but agents add new failure modes worth designing for:

  • Treat tool inputs as untrusted. Validate with Zod (the hooks do this for you) and never interpolate inputs into HTML or queries.
  • Scope write tools tightly. One tool per action, minimal parameters, explicit confirmation. Avoid generic "execute this" tools.
  • Remember the session context. Tools run with the logged-in user's cookies and permissions. An agent calling request_quote is indistinguishable from the user clicking the button — which is exactly why the confirmation gate matters.
  • Rate-limit on the server. An agent in a loop can call tools far faster than a human clicks. Your API routes need the same protections they would for any automated client.

Next Steps

  • Add contextual per-page tools that expose the currently viewed content, so agents can answer "summarize this page" from structured data instead of the DOM
  • Publish a tool catalog resource (a JSON document listing all your tools and resources) so agents can orient themselves in one read
  • Explore the Build an MCP Server in TypeScript tutorial to compare the server-side approach with browser-side WebMCP
  • Read our Build an MCP Client in TypeScript guide to understand the other half of the protocol
  • Watch the W3C Web Machine Learning Community Group repository for the evolving native navigator.modelContext specification

Conclusion

WebMCP inverts the relationship between websites and AI agents: instead of agents reverse-engineering your UI, your site publishes a typed, documented, permission-aware API surface that lives right in the page. With @mcp-b/react-webmcp, the implementation cost is remarkably low — a client component, a handful of Zod schemas, and deliberate descriptions.

You built a catalog search tool, a details tool, parameterized resources, and a human-gated write tool. The same pattern scales to real production sites: on noqta.tn, this exact architecture powers service search, content discovery, and proposal requests for any AI agent that visits. The agentic web is arriving quickly, and sites that expose structured tools will be the ones agents can actually use — accurately, safely, and with the user in control.