Dynamically register and unregister WebMCP tools

Kashish Hora

Kashish Hora

Co-founder of MCPcat

Try out MCPcat

The Quick Answer

In React, tool registration is tied to the component lifecycle. Conditionally render the component that calls useMcpTool and the tool appears when the component mounts and disappears when it unmounts:

import { useMcpTool } from "webmcp-react";
import { z } from "zod";

function AdminTools() {
  useMcpTool({
    name: "cancel-booking",
    description: "Cancel a flight booking by confirmation code (admin only)",
    input: z.object({
      confirmationCode: z.string().describe("Booking confirmation code"),
    }),
    handler: async ({ confirmationCode }) => {
      const result = await api.cancelBooking(confirmationCode);
      return { content: [{ type: "text", text: JSON.stringify(result) }] };
    },
  });

  return <AdminPanel />;
}

Then conditionally render AdminTools based on the user's role:

function App() {
  const { user } = useAuth();
  return (
    <WebMCPProvider name="travel-app" version="1.0.0">
      <FlightSearch />
      {user?.role === "admin" && <AdminTools />}
    </WebMCPProvider>
  );
}

Prerequisites

How Dynamic Registration Works

WebMCP tools exist for the lifetime of the page. If you register a tool and never unregister it, it stays available until the user navigates away or closes the tab. In a single-page app, that means a tool registered on the homepage persists even after the user navigates to a completely different view.

The useMcpTool hook from webmcp-react ties tool registration to React's component lifecycle. When a component mounts, the hook calls navigator.modelContext.registerTool(). When it unmounts, it calls unregisterTool(). This means you can control which tools an agent sees by controlling which components are rendered. React's conditional rendering, route-based rendering, and state-driven UI all become mechanisms for dynamic tool management — no imperative registration logic needed.

Role-Based Tools

The most common pattern is showing different tools based on the user's role or permissions. Place each tool in its own component and conditionally render it:

function App() {
  const { user } = useAuth();

  return (
    <WebMCPProvider name="travel-app" version="1.0.0">
      <SearchTools />
      {user?.role === "admin" && <AdminTools />}
      {user?.isPremium && <PremiumTools />}
    </WebMCPProvider>
  );
}

For tools that need the user's identity for authorization, pass it through props or context rather than fetching it inside the tool handler:

function PremiumTools({ userId }: { userId: string }) {
  useMcpTool({
    name: "priority-rebooking",
    description: "Rebook a cancelled flight with priority queue access",
    input: z.object({
      bookingId: z.string().describe("Original booking ID"),
      preferredDate: z.string().describe("Preferred new travel date (YYYY-MM-DD)"),
    }),
    handler: async ({ bookingId, preferredDate }) => {
      const result = await api.priorityRebook(userId, bookingId, preferredDate);
      return { content: [{ type: "text", text: JSON.stringify(result) }] };
    },
  });

  return null; // No UI needed — this component only registers the tool
}

Returning null is fine when a component exists solely to register a tool.

Route-Based Tools

In SPAs with client-side routing, tools should match the current view. A book-flight tool makes sense on the booking page but not on the account settings page. Place tools in the route component where they're relevant:

// routes.tsx
function BookingPage({ flightId }: { flightId: string }) {
  useMcpTool({
    name: "get-booking-options",
    description: "Get available seat classes and prices for a specific flight",
    input: z.object({
      passengers: z.number().min(1).max(9).describe("Number of passengers"),
    }),
    annotations: { readOnlyHint: true },
    handler: async ({ passengers }) => {
      const options = await api.getBookingOptions(flightId, passengers);
      return { content: [{ type: "text", text: JSON.stringify(options) }] };
    },
  });

  return <BookingForm flightId={flightId} />;
}

A different route component registers a completely separate tool:

function AccountPage() {
  useMcpTool({
    name: "update-traveler-profile",
    description: "Update the user's traveler profile with name and preferences",
    input: z.object({
      name: z.string().describe("Full legal name as on passport"),
      seatPreference: z.enum(["window", "aisle", "middle"]).optional()
        .describe("Preferred seat position"),
    }),
    handler: async ({ name, seatPreference }) => {
      const profile = await api.updateProfile({ name, seatPreference });
      return { content: [{ type: "text", text: JSON.stringify(profile) }] };
    },
  });

  return <ProfileForm />;
}

The agent always sees only the tools relevant to the current page.

This works with any React router — React Router, TanStack Router, Next.js App Router, or custom routing. The mechanism is React's component tree, not the router itself.

Feature Flag-Gated Tools

Feature flags let you roll out new tools gradually. Wrap the tool component in a feature flag check so it only mounts when the flag is enabled:

function ExperimentalTools() {
  const { isEnabled } = useFeatureFlag("ai-price-alerts");

  if (!isEnabled) return null;

  return <PriceAlertTool />;
}

The child component registers the tool only when the parent allows it to mount:

function PriceAlertTool() {
  useMcpTool({
    name: "set-price-alert",
    description: "Set an alert for when a flight drops below a target price",
    input: z.object({
      origin: z.string().describe("Departure airport code"),
      destination: z.string().describe("Arrival airport code"),
      maxPrice: z.number().describe("Target price in USD"),
    }),
    handler: async ({ origin, destination, maxPrice }) => {
      const alert = await api.createPriceAlert({ origin, destination, maxPrice });
      return { content: [{ type: "text", text: JSON.stringify(alert) }] };
    },
  });

  return null;
}

When the feature flag is toggled on, PriceAlertTool mounts and the tool becomes available. Toggle it off and the tool disappears. This is useful for A/B testing new tools with a subset of users, or for disabling a tool that's misbehaving in production without a deploy.

Keep the feature flag check in a parent component and the useMcpTool call in a child. This avoids calling hooks conditionally, which violates React's rules of hooks.

Responding to State Changes

Sometimes a tool's behavior needs to change based on app state — for example, a checkout tool should only work when the cart has items. Use React state to control whether the tool component renders:

function CheckoutTools() {
  const { items } = useCart();

  if (items.length === 0) return null;

  return <CheckoutTool itemCount={items.length} />;
}

function CheckoutTool({ itemCount }: { itemCount: number }) {
  useMcpTool({
    name: "checkout",
    description: `Complete purchase for ${itemCount} item(s) in the cart`,
    input: z.object({
      shippingMethod: z.enum(["standard", "express"]).describe("Shipping speed"),
    }),
    handler: async ({ shippingMethod }) => {
      const order = await api.checkout(shippingMethod);
      return { content: [{ type: "text", text: JSON.stringify(order) }] };
    },
  });

  return null;
}

The tool only exists when the cart has items. When the last item is removed, the component unmounts and the tool disappears.

Note that useMcpTool re-registers the tool when name or description changes. In the example above, the description includes itemCount, so the tool is re-registered whenever the cart size changes. This keeps the agent informed about the current state. The handler itself is stored in a ref, so it always runs the latest closure without triggering re-registration.

Manual Registration with the Raw API

If you're not using React, or need lower-level control, use navigator.modelContext directly with cleanup logic:

// Register tools based on auth state
function setupTools(user) {
  const registeredTools = [];

  // Always register search
  navigator.modelContext.registerTool({
    name: "search-flights",
    description: "Search for available flights",
    inputSchema: { /* ... */ },
    execute: async (input) => { /* ... */ }
  });
  registeredTools.push("search-flights");

  // Admin-only tool
  if (user.role === "admin") {
    navigator.modelContext.registerTool({
      name: "cancel-booking",
      description: "Cancel any booking by confirmation code",
      inputSchema: { /* ... */ },
      execute: async (input) => { /* ... */ }
    });
    registeredTools.push("cancel-booking");
  }

Return a cleanup function that unregisters everything that was registered:

// Return cleanup function
  return () => {
    for (const name of registeredTools) {
      try {
        navigator.modelContext.unregisterTool(name);
      } catch (e) {
        // Tool was already unregistered
      }
    }
  };
}

The cleanup function follows the same pattern as React's useEffect return value. Call it when the user logs out, when navigation happens, or when roles change. Track registered tool names so you can clean up exactly what you registered.

For vanilla JavaScript SPAs, wire this into your router's lifecycle. For example, with a hash router:

let cleanup = null;

window.addEventListener("hashchange", () => {
  if (cleanup) cleanup();

  if (location.hash === "#/booking") {
    cleanup = setupBookingTools();
  } else if (location.hash === "#/admin") {
    cleanup = setupAdminTools();
  } else {
    cleanup = null;
  }
});

This gives you the same route-based scoping that React components provide, but without a framework.

Common Issues

Tools persist after the user logs out

If tools remain available after logout, the component registering them is still mounted. This usually happens when the auth check is inside the tool component instead of controlling whether it renders. Move the condition up:

// Wrong — tool is registered even when user is null
function AdminTool() {
  const { user } = useAuth();
  useMcpTool({
    name: "admin-action",
    // This tool is registered regardless of user state
    // ...
  });
  if (!user?.isAdmin) return null;
  return <AdminUI />;
}

// Right — tool is only registered when user is admin
function App() {
  const { user } = useAuth();
  return (
    <>
      {user?.isAdmin && <AdminTool />}
    </>
  );
}

The hook runs on mount regardless of what the component returns. The condition must control whether the component mounts at all.

InvalidStateError when re-registering after state change

If you're using the raw navigator.modelContext API and a tool's config depends on state, unregister before re-registering. The API throws if a tool with the same name already exists:

function updateTool(tool) {
  try { navigator.modelContext.unregisterTool(tool.name); } catch (e) {}
  navigator.modelContext.registerTool(tool);
}

With useMcpTool, this is handled automatically — the hook unregisters the old tool and registers the new one when the name, description, or schema changes.

Hooks called conditionally cause React errors

Never call useMcpTool inside an if block. React hooks must be called in the same order on every render. Instead, split the tool into its own component and conditionally render that component:

// Wrong — violates rules of hooks
function Tools({ isAdmin }) {
  if (isAdmin) {
    useMcpTool({ name: "admin-tool", /* ... */ });
  }
}

// Right — conditional rendering, not conditional hooks
function Tools({ isAdmin }) {
  return isAdmin ? <AdminTool /> : null;
}

Next Steps

This guide covered patterns for controlling when tools are available. For the full useMcpTool API including Zod schemas, annotations, and desktop client bridging, see adding WebMCP tools to a React app. To add user confirmation before destructive tools execute, see building a WebMCP confirmation flow with requestUserInteraction.