Build a WebMCP confirmation flow with requestUserInteraction
Kashish Hora
Co-founder of 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-testingenabled, 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:
- The agent invokes your tool
- Your
executecallback runs and reachesclient.requestUserInteraction(callback) - The browser invokes your callback, which shows a confirmation UI
- Execution pauses until the callback's Promise resolves
requestUserInteractionreturns the callback's resolved value- 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
requestUserInteractionruns 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.
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.
Add WebMCP tools to a React app with webmcp-react
How to use the webmcp-react library to register WebMCP tools in React with Zod schemas and connect them to desktop clients.
Add WebMCP tools to a Next.js App Router project
Set up webmcp-react in a Next.js 15 App Router project with proper client component boundaries and SSR handling.