The Quick Answer
The webmcp-react library is the fastest way to add WebMCP tools to a React app. Wrap your app in <WebMCPProvider> and use the useMcpTool hook to register tools with Zod schemas:
import { useMcpTool } from "webmcp-react";
import { z } from "zod";
function FlightSearch() {
const { state } = useMcpTool({
name: "search-flights",
description: "Search for available flights between two airports",
input: z.object({
origin: z.string().describe("Departure airport code (e.g. SFO)"),
destination: z.string().describe("Arrival airport code (e.g. JFK)"),
date: z.string().describe("Travel date in YYYY-MM-DD format"),
}),
handler: async ({ origin, destination, date }) => {
const flights = await fetchFlights(origin, destination, date);
return { content: [{ type: "text", text: JSON.stringify(flights) }] };
},
});
if (state.isExecuting) return <p>Searching flights…</p>;
return <p>{state.executionCount} searches completed</p>;
}Wrap your app in <WebMCPProvider> (shown in the setup section below) and drop this component anywhere in the tree. The hook manages tool registration/cleanup on mount/unmount and converts the Zod schema to JSON Schema automatically. The library also includes a Chrome extension and MCP server for bridging tools to desktop clients like Claude Code and Cursor.
Prerequisites
- React 18+ project (Vite, Next.js, or CRA) with TypeScript
- Basic familiarity with Zod schemas
- Understanding of the raw
navigator.modelContextAPI (see registering your first WebMCP tool)
Installation
$npm install webmcp-react zod
zod-to-json-schema is bundled as a dependency — no separate install needed. The library has peer dependencies on react >= 18, react-dom >= 18, and zod >= 3.
Setting Up the WebMCP React Provider
Wrap your app in <WebMCPProvider> at the root level. It takes a name and version that identify your app:
import { WebMCPProvider } from "webmcp-react";
export default function App() {
return (
<WebMCPProvider name="my-travel-app" version="1.0.0">
<Routes />
</WebMCPProvider>
);
}On mount, the provider checks if navigator.modelContext exists natively (for example, in Chromium builds where WebMCP testing is enabled). If not, it installs a polyfill that implements the same API. This means your tools work in any browser today and will use the native API when it ships.
The provider is SSR-safe — during server-side rendering, it renders children without installing anything. On hydration, the effect runs and sets up the polyfill on the client. For Next.js WebMCP integration with App Router, add "use client" to any component that uses the provider or hooks.
Multiple providers can coexist in the same app (e.g., micro-frontends). The polyfill is reference-counted and only cleaned up when the last provider unmounts.
Registering a Tool with useMcpTool
The hook accepts a config object with a name, description, Zod input schema, and handler. It returns a state object, an execute function, and a reset function:
import { useState } from "react";
import { useMcpTool } from "webmcp-react";
import { z } from "zod";
function FlightSearch() {
const [results, setResults] = useState<Flight[]>([]);
const { state } = useMcpTool({
name: "search-flights",
description: "Search for available flights between two airports on a given date",
input: z.object({
origin: z.string().describe("IATA airport code for departure (e.g. SFO, LAX)"),
destination: z.string().describe("IATA airport code for arrival (e.g. JFK, LHR)"),
date: z.string().describe("Travel date in YYYY-MM-DD format"),
cabinClass: z.enum(["economy", "business", "first"]).optional()
.describe("Preferred cabin class"),
}),
annotations: { readOnlyHint: true },
handler: async ({ origin, destination, date, cabinClass }) => {
const flights = await api.searchFlights({ origin, destination, date, cabinClass });
setResults(flights);
return { content: [{ type: "text", text: JSON.stringify(flights) }] };
},
});
// ...
}The Zod schema is automatically converted to JSON Schema for the underlying navigator.modelContext.registerTool() call. The describe() calls on each field become the description in the JSON Schema — agents use these to understand what to pass. Setting annotations: { readOnlyHint: true } tells agents this tool only reads data.
The handler is stored in a ref internally, so it always runs the latest version of your function without re-registering the tool. This means the handler can safely reference React state (like setResults above) without stale closure issues. Re-registration only happens when name, description, or the schema shape changes.
You can also pass onSuccess and onError callbacks for side effects like logging or toast notifications. state gives you reactive values for JSX: isExecuting (true while the handler is running), lastResult, error, and executionCount.
Using JSON Schema instead of Zod
If you prefer not to use Zod, pass inputSchema instead of input:
const { state } = useMcpTool({
name: "search-flights",
description: "Search for available flights",
inputSchema: {
type: "object",
properties: {
origin: { type: "string", description: "Departure airport code" },
destination: { type: "string", description: "Arrival airport code" },
date: { type: "string", description: "Travel date in YYYY-MM-DD format" },
},
required: ["origin", "destination", "date"],
},
handler: async (args) => {
const { origin, destination, date } = args as {
origin: string; destination: string; date: string;
};
const flights = await api.searchFlights({ origin, destination, date });
return { content: [{ type: "text", text: JSON.stringify(flights) }] };
},
});Both paths produce the same tool registration. The Zod path gives you type inference on the handler's args parameter and validates input at runtime. The JSON Schema path requires manual type casting. Use this path if you already have JSON Schema definitions from an OpenAPI spec or want to avoid the Zod dependency.
Tool Lifecycle and React
Tools are tied to the component lifecycle. When a component mounts, its tool is registered. When it unmounts, the tool is unregistered. This means tools naturally appear and disappear as the user navigates through your app:
function BookingPage() {
// This tool only exists while BookingPage is mounted
useMcpTool({
name: "book-flight",
description: "Book a flight for the user",
input: z.object({
flightId: z.string().describe("Flight ID to book"),
}),
handler: async ({ flightId }) => {
const booking = await api.bookFlight(flightId);
return { content: [{ type: "text", text: `Booked: ${booking.code}` }] };
},
});
return <BookingForm />;
}If BookingPage is only rendered on the /book route, the book-flight tool is only available to agents while the user is on that page. Navigate away and it's gone.
The hook is safe to use with React StrictMode, which mounts and unmounts components twice in development. Internally, useMcpTool uses Symbol-based ownership tokens to track which component instance owns each tool, preventing double-registration or orphaned tools. It also handles HMR gracefully — cleanup runs before re-registration.
Multiple components can register different tools simultaneously. Each tool is additive to the page's tool set.
Checking WebMCP Availability
Use the useWebMCPStatus hook to check if the WebMCP API is ready:
import { useWebMCPStatus } from "webmcp-react";
function AgentBadge() {
const { available } = useWebMCPStatus();
if (!available) return null;
return <span className="badge">AI tools available</span>;
}This must be used inside a <WebMCPProvider>. It returns { available: true } once the polyfill or native API is set up. During SSR it returns { available: false }. You can use this to conditionally render agent-specific UI, show a status indicator, or gate functionality that only makes sense when an AI agent can interact with the page.
Connecting to Desktop AI Clients
Tools registered via navigator.modelContext live inside the browser. Desktop AI clients like Claude Code and Cursor can't see them directly. The webmcp-react package includes a Chrome extension and a local MCP server that bridge the gap.
1. Install the Chrome extension
Download the extension from the webmcp-react GitHub releases and load it as an unpacked extension via chrome://extensions.
2. Start the MCP server
$npx webmcp-server
This starts a local MCP server that listens for WebSocket connections from the extension on port 12315 and exposes discovered tools to AI clients over stdio.
3. Configure your AI client
For Claude Code:
$claude mcp add --transport stdio webmcp-server -- npx webmcp-server
For Cursor, add to .cursor/mcp.json:
{
"mcpServers": {
"webmcp-server": {
"command": "npx",
"args": ["webmcp-server"]
}
}
}4. Activate the extension
Click the extension icon on a tab with your app. You'll see three activation modes:
- Off — no tools exposed
- Until Reload — tools from this tab only, cleared on reload or navigation
- Always On — all pages on this origin, survives reloads and new tabs
Once active, tools appear in your AI client namespaced as tab-{tabId}:{toolName}. The extension icon shows a green dot when connected to the MCP server and a tool count badge.
The default WebSocket port is 12315. If that conflicts with something else, set the WEBMCP_BRIDGE_PORT environment variable before starting the server.
Common Issues
InvalidStateError in development
React StrictMode double-mounts components in development, which would normally cause registerTool to throw on the second mount since the tool name already exists. useMcpTool handles this internally using ownership tokens — you don't need to do anything. If you see this error, you're probably calling navigator.modelContext.registerTool() directly instead of using the hook.
Tools not appearing in Claude Code / Cursor
Check three things in order:
- The MCP server is running (
npx webmcp-serverin a terminal) - The extension is activated on the tab (click the icon, pick "Until Reload" or "Always On")
- The extension icon shows a green dot (green = connected to MCP server, yellow = server not found)
If the extension shows yellow, the MCP server isn't reachable on port 12315. Make sure it's running and no firewall or other process is blocking the port.
Handler seems to use stale state
The useMcpTool hook stores your handler in a ref, so it always runs the latest version — even if React state it depends on has changed between registration and invocation. You don't need to memoize the handler or wrap it in useCallback. If you're seeing stale values, check that you're passing the handler directly in the config object rather than memoizing it yourself, which would freeze the closure.
Next Steps
For a deeper understanding of the navigator.modelContext API that webmcp-react wraps, see registering your first WebMCP tool. To add server-side MCP tools alongside your browser tools, see adding custom tools to a TypeScript MCP server.
Related Guides
Register your first WebMCP tool with navigator.modelContext
How to use navigator.modelContext to register tools that AI agents can call directly from your website.
Adding custom tools to an MCP server in TypeScript
Step-by-step guide to adding custom tools to your TypeScript MCP server with proper typing and error handling.
Implementing CORS Policies for Web-Based MCP Servers
Configure Cross-Origin Resource Sharing (CORS) policies for web-based MCP servers to enable secure browser access.