Build a WebMCP confirmation flow with requestUserInteraction

Kashish Hora

Kashish Hora

Co-founder of MCPcat

Try out MCPcat

The Quick Answer

Use the client.requestUserInteraction() method inside your tool's execute callback to pause execution and prompt the user for confirmation before performing a destructive action:

navigator.modelContext.registerTool({
  name: "delete-account",
  description: "Permanently delete the user's account and all associated data",
  inputSchema: { type: "object", properties: {} },
  execute: async (_input, client) => {
    const confirmed = await client.requestUserInteraction(async () => {
      return confirm("Permanently delete your account? This cannot be undone.");
    });
    if (!confirmed) throw new Error("Account deletion cancelled by user");
    await deleteAccount();
    return { content: [{ type: "text", text: "Account deleted successfully" }] };
  }
});

The second argument to execute is a ModelContextClient object. Its requestUserInteraction method accepts an async callback where you show any UI — a native confirm() dialog, a custom modal, or a framework component. The agent's execution pauses until the callback resolves, and the resolved value is returned to your tool logic.

Prerequisites

  • Familiarity with registering WebMCP tools via navigator.modelContext (see registering your first WebMCP tool)
  • A Chromium-based browser with chrome://flags/#enable-webmcp-testing enabled, or a polyfill like the one in webmcp-react
  • Basic understanding of async/await and Promises

How requestUserInteraction Works

When an AI agent calls a WebMCP tool, the browser invokes your execute callback with two arguments: the input object (matching your schema) and a ModelContextClient instance. The client object exposes a single method — requestUserInteraction — designed specifically for human-in-the-loop flows.

The WebIDL definition from the W3C WebMCP spec:

[Exposed=Window, SecureContext]
interface ModelContextClient {
  Promise<any> requestUserInteraction(UserInteractionCallback callback);
};

callback UserInteractionCallback = Promise<any> ();

The flow is straightforward:

  1. The agent invokes your tool
  2. Your execute callback runs and reaches client.requestUserInteraction(callback)
  3. The browser invokes your callback, which shows a confirmation UI
  4. Execution pauses until the callback's Promise resolves
  5. requestUserInteraction returns the callback's resolved value
  6. Your tool logic continues based on the result

The callback runs in your page's context with full access to the DOM. This means you can show anything — a native browser dialog, a custom modal, a toast notification, or even a multi-step form. The return type is Promise<any>, so you can pass back booleans, strings, objects, or any structured data.

Building a Simple Confirmation with confirm()

The quickest way to gate a destructive action is with the browser's built-in confirm() dialog. It blocks execution and returns true or false:

navigator.modelContext.registerTool({
  name: "cancel-subscription",
  description: "Cancel the user's active subscription immediately",
  inputSchema: {
    type: "object",
    properties: {
      subscriptionId: { type: "string", description: "Subscription ID to cancel" }
    },
    required: ["subscriptionId"]
  },
  execute: async ({ subscriptionId }, client) => {
    const sub = await getSubscription(subscriptionId);

    const confirmed = await client.requestUserInteraction(async () => {
      return confirm(
        `Cancel your ${sub.planName} subscription ($${sub.monthlyPrice}/mo)?\n` +
        `You'll lose access at the end of this billing period.`
      );
    });

    if (!confirmed) {
      throw new Error("Subscription cancellation cancelled by user");
    }

    await cancelSubscription(subscriptionId);
    return {
      content: [{ type: "text", text: JSON.stringify({ status: "cancelled", effectiveDate: sub.currentPeriodEnd }) }]
    };
  }
});

This pattern works well for simple yes/no decisions. The confirm() dialog is universally supported and doesn't require any UI framework. Notice that the callback fetches subscription details before calling requestUserInteraction so the confirmation message can include specifics like the plan name and price — giving the user enough context to make an informed decision.

Throwing an error when the user declines is important. It signals to the agent that the action was explicitly rejected, not that something went wrong technically. The agent can then adjust its approach — perhaps suggesting an alternative or asking what the user wants to do instead.

Building a Custom Confirmation Modal

For production applications, you'll want a styled modal that matches your UI. The callback passed to requestUserInteraction can create and manage DOM elements directly, resolving a Promise when the user clicks confirm or cancel:

function showConfirmationModal({ title, message, confirmLabel, cancelLabel }) {
  return new Promise((resolve) => {
    const overlay = document.createElement("div");
    overlay.className = "confirmation-overlay";
    overlay.innerHTML = `
      <div class="confirmation-modal">
        <h3>${title}</h3>
        <p>${message}</p>
        <div class="confirmation-actions">
          <button class="btn-cancel">${cancelLabel || "Cancel"}</button>
          <button class="btn-confirm">${confirmLabel || "Confirm"}</button>
        </div>
      </div>
    `;

    const cleanup = (result) => {
      overlay.remove();
      resolve(result);
    };

    overlay.querySelector(".btn-cancel").onclick = () => cleanup(false);
    overlay.querySelector(".btn-confirm").onclick = () => cleanup(true);
    document.body.appendChild(overlay);
  });
}

Then use it inside your tool:

navigator.modelContext.registerTool({
  name: "place-order",
  description: "Place a purchase order for items in the user's cart",
  inputSchema: { /* ... cart items schema ... */ },
  execute: async (input, client) => {
    const cart = await getCartSummary();

    const confirmed = await client.requestUserInteraction(async () => {
      return showConfirmationModal({
        title: "Confirm your order",
        message: `Place order for ${cart.itemCount} items totaling $${cart.total}?`,
        confirmLabel: "Place Order",
        cancelLabel: "Keep Shopping"
      });
    });

    if (!confirmed) throw new Error("Order cancelled by user");

    const order = await submitOrder(cart.id);
    return {
      content: [{ type: "text", text: JSON.stringify({ orderId: order.id, confirmationCode: order.code }) }]
    };
  }
});

The modal approach gives you complete control over styling and behavior. You can add transition animations, show itemized details, include a terms-of-service checkbox, or present any other UI element. The key constraint is that the Promise must eventually resolve — if your modal can be dismissed without a clear accept/reject (for example, by clicking the overlay background), make sure that also resolves with a value (typically false).

[Screenshot: Custom confirmation modal showing order summary with "Place Order" and "Keep Shopping" buttons]

Confirmation Flows in React with webmcp-react

If you're using webmcp-react, the useMcpTool hook gives you access to the same client object in the handler. You can combine it with React state to show a confirmation component inline:

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

function DeleteProjectTool({ projectId }: { projectId: string }) {
  const [pendingConfirm, setPendingConfirm] = useState<{
    resolve: (value: boolean) => void;
    projectName: string;
  } | null>(null);

  const { state } = useMcpTool({
    name: "delete-project",
    description: "Permanently delete a project and all its data",
    input: z.object({
      projectId: z.string().describe("The project ID to delete"),
    }),
    handler: async ({ projectId }, client) => {
      const project = await api.getProject(projectId);

      const confirmed = await client.requestUserInteraction(async () => {
        return new Promise<boolean>((resolve) => {
          setPendingConfirm({ resolve, projectName: project.name });
        });
      });

      setPendingConfirm(null);
      if (!confirmed) {
        return { content: [{ type: "text", text: "Deletion cancelled by user" }] };
      }

      await api.deleteProject(projectId);
      return { content: [{ type: "text", text: `Project "${project.name}" deleted` }] };
    },
  });

  if (pendingConfirm) {
    return (
      <div className="confirmation-banner">
        <p>Delete project "{pendingConfirm.projectName}"? This is permanent.</p>
        <button onClick={() => pendingConfirm.resolve(false)}>Cancel</button>
        <button onClick={() => pendingConfirm.resolve(true)}>Delete</button>
      </div>
    );
  }

  return state.isExecuting ? <p>Processing…</p> : null;
}

The handler stores a Promise's resolve function in state, causing the confirmation UI to render — the user's click resolves it and unblocks the handler.

Note that the handler returns an MCP content response ({ content: [...] }) instead of throwing on cancellation. This is the recommended pattern when bridging to desktop AI clients via webmcp-react's Chrome extension, since structured responses are easier for agents to interpret than caught errors.

Multiple Confirmations in a Single Tool

You can call requestUserInteraction multiple times during a single tool execution. This is useful for multi-step workflows where each step requires separate consent:

navigator.modelContext.registerTool({
  name: "transfer-ownership",
  description: "Transfer project ownership to another user",
  inputSchema: {
    type: "object",
    properties: {
      projectId: { type: "string", description: "Project ID" },
      newOwnerId: { type: "string", description: "User ID of the new owner" }
    },
    required: ["projectId", "newOwnerId"]
  },
  execute: async ({ projectId, newOwnerId }, client) => {
    const project = await getProject(projectId);
    const newOwner = await getUser(newOwnerId);

    // First confirmation: acknowledge the transfer
    const acknowledged = await client.requestUserInteraction(async () => {
      return confirm(
        `Transfer "${project.name}" to ${newOwner.email}?\n` +
        `You will lose admin access.`
      );
    });
    if (!acknowledged) throw new Error("Transfer cancelled");

    // Second confirmation: final safety check
    const finalConfirm = await client.requestUserInteraction(async () => {
      return confirm("This action is irreversible. Are you absolutely sure?");
    });
    if (!finalConfirm) throw new Error("Transfer cancelled at final confirmation");

    await transferOwnership(projectId, newOwnerId);
    return {
      content: [{ type: "text", text: JSON.stringify({ transferred: true, newOwner: newOwner.email }) }]
    };
  }
});

Use multi-step confirmations only for high-stakes irreversible operations like ownership transfers or bulk deletions. For most destructive actions, a single confirmation is sufficient.

Using Tool Annotations to Signal Destructive Intent

WebMCP supports a readOnlyHint annotation that tells agents whether a tool modifies state. When readOnlyHint is false (the default), agents know the tool may make changes and can decide to request confirmation before calling it:

navigator.modelContext.registerTool({
  name: "delete-all-data",
  description: "Permanently delete all user data from the platform",
  inputSchema: { type: "object", properties: {} },
  annotations: { readOnlyHint: false },
  execute: async (_input, client) => {
    const confirmed = await client.requestUserInteraction(async () => {
      return confirm("Delete ALL your data? This is permanent and cannot be undone.");
    });
    if (!confirmed) throw new Error("Data deletion cancelled");
    // ... deletion logic ...
  }
});

Annotations and requestUserInteraction serve complementary purposes. Annotations are pre-call hints — they help agents decide whether to invoke a tool at all, or to warn the user before calling it. requestUserInteraction is an in-call gate — it pauses execution after the tool has been invoked and gives the user a chance to cancel. For destructive tools, use both: set readOnlyHint: false so agents approach the tool cautiously, and add a requestUserInteraction confirmation as a safety net inside the tool itself.

The broader MCP specification defines additional annotations like destructiveHint and idempotentHint that are not yet in the WebMCP WebIDL but may be added in future revisions.

Common Issues

Callback never resolves

If the callback passed to requestUserInteraction creates a Promise that never resolves, the tool execution hangs indefinitely. This typically happens when a custom modal is dismissed without triggering either the confirm or cancel handler — for example, clicking an overlay backdrop or pressing Escape without wiring those events to resolve.

// Always handle all dismissal paths
overlay.addEventListener("click", (e) => {
  if (e.target === overlay) cleanup(false); // backdrop click = cancel
});
document.addEventListener("keydown", (e) => {
  if (e.key === "Escape") cleanup(false); // Escape = cancel
});

Every path that removes or hides the confirmation UI must also resolve the Promise.

requestUserInteraction called outside execute

The ModelContextClient object is only valid during tool execution. Storing a reference to client and calling requestUserInteraction later (for example, in a setTimeout or event handler outside the execute flow) results in undefined behavior. Keep all interaction requests within the synchronous async flow of your execute callback.

Confirmation UI not visible to the user

When an AI agent calls a tool, the browser tab running your app might not be in the foreground. If requestUserInteraction shows a DOM-based modal, the user may not see it. Native confirm() dialogs typically bring the tab to focus, but custom UI does not. Consider using the Notifications API or the document.visibilitychange event to alert the user when a confirmation is pending:

const confirmed = await client.requestUserInteraction(async () => {
  if (document.hidden && Notification.permission === "granted") {
    new Notification("Action requires your confirmation", {
      body: "Switch to the app tab to approve or cancel."
    });
  }
  return showConfirmationModal({ /* ... */ });
});

Example: Payment Authorization Flow

This example shows a more complete confirmation flow for a payment tool that displays transaction details and collects explicit approval:

navigator.modelContext.registerTool({
  name: "send-payment",
  description: "Send a payment to another user",
  inputSchema: {
    type: "object",
    properties: {
      recipientEmail: { type: "string", description: "Recipient's email address" },
      amount: { type: "number", description: "Amount in USD" },
      note: { type: "string", description: "Optional payment note" }
    },
    required: ["recipientEmail", "amount"]
  },
  annotations: { readOnlyHint: false },
  execute: async ({ recipientEmail, amount, note }, client) => {
    const recipient = await lookupUser(recipientEmail);
    const balance = await getAccountBalance();

    if (amount > balance) {
      throw new Error(`Insufficient balance: $${balance} available, $${amount} requested`);
    }

    const confirmed = await client.requestUserInteraction(async () => {
      return showConfirmationModal({
        title: "Confirm Payment",
        message: `Send $${amount.toFixed(2)} to ${recipient.name} (${recipientEmail})?` +
          (note ? `\nNote: "${note}"` : "") +
          `\n\nYour balance after: $${(balance - amount).toFixed(2)}`,
        confirmLabel: "Send Payment",
        cancelLabel: "Cancel"
      });
    });

    if (!confirmed) throw new Error("Payment cancelled by user");

    const tx = await processPayment({ recipientId: recipient.id, amount, note });
    return {
      content: [{ type: "text", text: JSON.stringify({
        transactionId: tx.id, amount: tx.amount,
        recipient: recipient.name, remainingBalance: balance - amount
      }) }]
    };
  }
});

This tool validates the balance before showing the confirmation, includes the post-payment balance in the dialog so the user can make an informed decision, and returns a detailed response so the agent can confirm the outcome. The readOnlyHint: false annotation ensures agents treat this tool with appropriate caution. Production implementations would add additional safeguards like rate limiting, idempotency keys, and audit logging.

Comparison with MCP Elicitation

If you've worked with server-side MCP, you may know the elicitation/create primitive — the protocol-level equivalent of requestUserInteraction. While they serve the same human-in-the-loop purpose, there are key differences:

  • MCP elicitation sends a JSON Schema to the client, which renders a form. The schema is limited to flat objects with primitive properties (strings, numbers, booleans, enums). The client decides how to present the form.
  • WebMCP requestUserInteraction runs a callback in your page context with full DOM access. You control the entire UI — custom modals, animations, embedded maps, file previews, anything the browser can render.

WebMCP's approach is more flexible for rich confirmation experiences but only works in browser contexts. MCP elicitation works across any MCP transport (stdio, StreamableHTTP) and any client (CLI tools, desktop apps, IDEs), but is limited to structured form inputs.

Next Steps

With confirmation flows in place, explore dynamically registering and unregistering tools based on user state — for example, only exposing destructive tools to admin users. For the foundations of WebMCP tool registration, see registering your first WebMCP tool. If you're building with React, webmcp-react provides hooks that handle tool lifecycle, polyfilling, and bridging to desktop AI clients automatically.