Announcing CIMD support for MCP Client registration
Learn more

WebMCP Implementation Guide: Declare, Test and Authorize Page Tools

Shri Mithran
Director of Marketing

An agent that drives your web app by reading the DOM is guessing. It finds a button by its label, infers that the second input is probably the customer email, and fills a form it has never been told the shape of. When your frontend team renames a class, the agent breaks. When a dropdown becomes a combobox, the agent breaks. When the agent gets it right, nobody can say which permission check it passed, because it passed the ones your UI passes, as the logged-in user, with the session cookie.

WebMCP replaces the guessing with a declaration. The page says what an agent can do on it, as named tools with JSON schemas, and the agent calls those instead of clicking. What WebMCP does not replace is the last sentence of that paragraph. A page tool runs as whoever is logged into the tab. That is the part you have to design.

TL;DR

  • WebMCP is a browser API, navigator.modelContext, through which a page registers tools an in-browser agent can discover and call. It is a W3C Web Machine Learning Community Group proposal from Google and Microsoft. Chrome announced a public origin trial for Chrome 149 in May 2026. As of this writing, only Gemini in Chrome consumes the tools; no other browser ships it.
  • Declare tools with registerTool (one at a time) or provideContext (a batch); remove them with unregisterTool. Tools must track the UI: register when the relevant view is live, unregister when it is not.
  • Test with the WebMCP panel in Chrome DevTools, the navigator.modelContextTesting interface behind the testing flag, and the Model Context Tool Inspector extension. Feature-detect in production.
  • Authorization is the open design problem. A page tool inherits the user's session, so everything it can do, it can do as the user. Enforce on the backend, never in the tool's JavaScript; gate mutations behind in-page confirmation; and never let a page tool carry a third-party credential.
  • When a page tool has to reach outside your app (post to Slack, file an issue), the browser is the wrong place for that credential. Execute it server-side as the user through a per-user connected account, so the page passes a user and a tool name and the Slack token never exists in the browser.

Where WebMCP stands today

Chrome 146 Canary shipped the first implementation behind chrome://flags/#enable-webmcp-testing in February 2026. On 19 May 2026 the Chrome team announced the public origin trial in Chrome 149, which is the first time a site can ship WebMCP to real visitors without asking them to flip a flag. The specification lives with the W3C Web Machine Learning Community Group. Stable, multi-browser support is not on any announced timeline; the realistic planning assumption is Chrome-only through 2027.

That shapes who should build on it now. Enterprise intranet applications and internal tools, where you control the browser fleet, can use WebMCP for real work today. A consumer-facing product can declare tools to be ready for the agent traffic that arrives, but it cannot assume any particular visitor's browser will call them.

Two consequences of the current state matter for the rest of this guide. The API requires a secure context: HTTPS, or localhost. And the consuming agent today is the browser's own; desktop MCP clients such as Claude Code or Cursor cannot see page tools without a bridge that re-exposes them.

Declare

The API has two registration calls and one removal.

navigator.modelContext.registerTool registers a single tool with a name, a description, an inputSchema in JSON Schema, and an execute function the browser calls with the parsed arguments.

<script> if ("modelContext" in navigator) { navigator.modelContext.registerTool({ name: "search_tickets", description: "Search support tickets visible to the current user. Returns up to 'limit' " + "tickets matching 'query', optionally filtered by status.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Free-text search over subject and body" }, status: { type: "string", enum: ["open", "pending", "resolved", "closed"] }, limit: { type: "integer", minimum: 1, maximum: 50, default: 20 } }, required: ["query"] }, async execute({ query, status, limit = 20 }) { const params = new URLSearchParams({ query, limit: String(limit) }); if (status) params.set("status", status); const res = await fetch(`/api/tickets?${params.toString()}`, { credentials: "same-origin", headers: { Accept: "application/json" } }); if (!res.ok) { return { content: [{ type: "text", text: `Search failed: HTTP ${res.status}` }] }; } const tickets = await res.json(); return { content: [{ type: "text", text: JSON.stringify(tickets) }] }; } }); } </script>

navigator.modelContext.provideContext registers a batch, which is the shape you want when a view has several related tools. The confirmInPage helper used by the mutating tool shows the user exactly what is about to happen and resolves to their answer; the minimal version below uses the browser's own dialog, and a production page would render it in the app's UI.

function confirmInPage(title, body) { return Promise.resolve(window.confirm(`${title}\n\n${body}`)); } if ("modelContext" in navigator) { navigator.modelContext.provideContext({ tools: [ { name: "get_ticket", description: "Fetch one ticket by id, including its comment thread.", inputSchema: { type: "object", properties: { ticketId: { type: "string" } }, required: ["ticketId"] }, async execute({ ticketId }) { const res = await fetch(`/api/tickets/${encodeURIComponent(ticketId)}`, { credentials: "same-origin" }); return { content: [{ type: "text", text: await res.text() }] }; } }, { name: "add_comment", description: "Add an internal comment to a ticket. The user is asked to confirm before the comment is posted.", inputSchema: { type: "object", properties: { ticketId: { type: "string" }, body: { type: "string", minLength: 1, maxLength: 5000 } }, required: ["ticketId", "body"] }, async execute({ ticketId, body }) { const ok = await confirmInPage( `Post this internal comment on ticket ${ticketId}?`, body ); if (!ok) { return { content: [{ type: "text", text: "Cancelled by user." }] }; } const res = await fetch(`/api/tickets/${encodeURIComponent(ticketId)}/comments`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ body }) }); return { content: [{ type: "text", text: res.ok ? "Comment posted." : `Failed: HTTP ${res.status}` }] }; } } ] }); }

Three rules for the declaration layer.

Schemas are the hallucination guard. An agent reading the DOM invents field names. An agent reading inputSchema gets enum, minimum, maxLength and required, and the agent runtime has something concrete to validate arguments against before execute runs. Validate again inside execute; the schema is a contract, not a guarantee. Spend the time on the schema; it is where the accuracy comes from.

Tools track the UI. A search_tickets tool registered on the ticket list page should not survive navigation to the billing page. In a single-page app, register in the route's mount and unregister in its unmount, and re-register on auth state changes.

export function mountTicketTools() { if (!("modelContext" in navigator)) return () => {}; registerToolSafe(searchTicketsTool); registerToolSafe(getTicketTool); return () => { navigator.modelContext.unregisterTool("search_tickets"); navigator.modelContext.unregisterTool("get_ticket"); }; } function registerToolSafe(tool) { try { navigator.modelContext.unregisterTool(tool.name); } catch (e) { // not registered yet } navigator.modelContext.registerTool(tool); }

Descriptions are read by a model, not a person. Say what the tool returns, what it will not do, and whether it asks for confirmation. The add_comment description above tells the agent a confirmation is coming, so the agent does not treat the pause as a failure.

Test

Three surfaces exist for testing, and they do different jobs.

Chrome DevTools WebMCP panel. With the WebMCP flags enabled in chrome://flags, DevTools gains a WebMCP tab listing every tool the current page has registered, with its schema. This is the first check after any registration code: is the tool there, and is the schema what you meant.

navigator.modelContextTesting. Behind the testing flag, Chromium exposes this interface so you can list registered tools and invoke them from the console or from automated tests without an agent in the loop. Use it to drive execute with edge-case arguments (empty query, out-of-range limit, a ticket the user cannot see) and assert on the result. Because the interface is experimental and its shape has changed between Canary builds, keep the calls in a test helper rather than spread through the suite.

Model Context Tool Inspector extension. The Chrome team's extension monitors navigator.modelContext on any page, lets you call tools manually, and validates that each inputSchema is well-formed JSON Schema. It is the right tool for reviewing a page you did not write.

For shipping to real users, register for the Chrome 149 origin trial and serve the token you receive in an origin-trial meta tag or response header; the flag path is for development only. And keep the feature detection in place. On every non-Chrome browser, and on Chrome without the trial, navigator.modelContext is undefined and the page has to behave as if WebMCP had never been proposed.

Authorize

This is the section the explainer does not write for you.

Every execute function above calls your API with credentials: "same-origin". That is the right call, and it is the whole problem. The tool runs inside the page, inside the user's session, with the user's cookies. Whatever the page can do, the tool can do. Whatever the user is allowed to do, the agent is now allowed to do, without the user having to click anything.

Four design rules follow.

Enforce on the backend. The tool's JavaScript is not a security boundary. Any check that lives only in execute can be bypassed by a page script, a browser extension, or an agent that calls your API directly. The /api/tickets endpoint has to enforce "tickets visible to this user" exactly as it does for the UI, and it has to do so without knowing or caring whether the request came from a tool. The tool is a thinner client for the same API, and the API is where the permission model lives.

Scope the tool below the UI, not at it. The user can delete tickets from the UI. That does not mean an agent acting in their session should be able to. A page tool surface should be the minimum set of operations the agent needs for the tasks you are enabling. For a support assistant that triages and drafts, that is search, read and comment. It is not delete, not merge, not reassign. The tools you do not register are the ones no description can talk the agent into calling.

Gate mutations behind the person. add_comment asks the user before it posts. Every tool that changes state should, in the page, in a way that shows the user what is about to happen. This is the WebMCP equivalent of a user-controlled primitive: the model decides to call, the human decides whether it happens. For read tools, the confirmation adds nothing and should be skipped.

Tell the backend an agent is calling. Add a header the tool sets and the UI does not, such as X-Agent-Initiated: webmcp, and have the API log it. The incident review that asks "did a person do this or did the agent" needs the distinction in the audit log, and the session cookie alone does not carry it. A server can also use the header to apply tighter limits to agent-initiated calls than to clicks.

Recommended Reading: Tool Calling Authentication for AI Agents on why an agent's credential for a third-party system should never be the user's session.

When a page tool has to leave the page

The rules above cover tools that call your own API as the user. The harder case is a tool that reaches a third-party system. A support assistant that, after drafting a reply, posts a summary to the on-call Slack channel. A deploy console whose agent files a GitHub issue when a check fails.

The naive implementation puts a Slack token in the page, or proxies it through an endpoint that holds one bot token for everyone. Both are wrong, for the reasons that have nothing to do with WebMCP and everything to do with the browser: the page is the least trusted place in your system, and a bot token means every user's agent posts as the same identity with the same permissions.

The correct shape is that the page tool calls your backend, and the backend executes the third-party action as the logged-in user, through a credential that user individually granted and that never left the server. The page never sees it. The user's own Slack permissions bound what the agent can post, and the audit record names the user, not a bot.

On the page, the tool is just another execute that calls your API with the user's session:

navigator.modelContext.registerTool({ name: "notify_on_call", description: "Post a short message to the on-call Slack channel as the current user. " + "The user confirms the message before it is sent.", inputSchema: { type: "object", properties: { message: { type: "string", minLength: 1, maxLength: 1000 } }, required: ["message"] }, async execute({ message }) { const ok = await confirmInPage("Send this to #on-call as you?", message); if (!ok) return { content: [{ type: "text", text: "Cancelled by user." }] }; const res = await fetch("/api/agent/notify-on-call", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json", "X-Agent-Initiated": "webmcp" }, body: JSON.stringify({ message }) }); return { content: [{ type: "text", text: res.ok ? "Sent." : `Failed: HTTP ${res.status}` }] }; } });

On the backend, the endpoint resolves the session to a user identifier and runs the tool as that user. With Scalekit's execute_tool, the user's Slack connected account supplies the credential on the far side of the call; the backend passes a user identifier and a tool name, and never holds a Slack token.

import os from dotenv import load_dotenv from flask import Flask, jsonify, request, session from scalekit import ScalekitClient from scalekit.common.exceptions import ScalekitServerException load_dotenv() app = Flask(__name__) app.secret_key = os.environ["FLASK_SECRET_KEY"] scalekit_client = ScalekitClient( env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ) actions = scalekit_client.actions # Must match the Slack connection name in AgentKit > Connections exactly. SLACK_CONNECTION = os.environ["SLACK_CONNECTION_NAME"] ON_CALL_CHANNEL = os.environ["ON_CALL_CHANNEL_ID"] @app.post("/api/agent/notify-on-call") def notify_on_call(): user_id = session.get("user_id") if not user_id: return jsonify({"error": "not authenticated"}), 401 if request.headers.get("X-Agent-Initiated") != "webmcp": return jsonify({"error": "agent-only endpoint"}), 403 message = (request.get_json() or {}).get("message", "").strip() if not message or len(message) > 1000: return jsonify({"error": "invalid message"}), 400 account = actions.get_or_create_connected_account( connection_name=SLACK_CONNECTION, identifier=user_id, ).connected_account if account.status != "ACTIVE": link = actions.get_authorization_link( connection_name=SLACK_CONNECTION, identifier=user_id, ).link return jsonify({"error": "slack_not_connected", "authorize_url": link}), 409 try: result = actions.execute_tool( tool_name="slack_send_message", identifier=user_id, connection_name=SLACK_CONNECTION, tool_input={"channel": ON_CALL_CHANNEL, "text": message}, ) except ScalekitServerException as e: app.logger.warning("notify_on_call failed for %s: %s %s", user_id, e.error_code, e.message) return jsonify({"error": e.error_code}), 502 app.logger.info("agent notify_on_call user=%s execution_id=%s", user_id, result.execution_id) return jsonify({"ok": True, "execution_id": result.execution_id})

Three things in that endpoint are doing the authorization work. The user is resolved from the server-side session, not from anything the page sent. The Slack action runs under that user's own connected account, so a user who cannot post to the channel in Slack cannot post to it through the agent either. And the execution_id goes into the log next to the user id and the agent-initiated marker, which is the record the incident review will want.

The 409 path matters too. If the user has never connected Slack, the endpoint returns an authorization link rather than failing silently or falling back to a shared token. The page tool can surface that link, the user authorizes once, and every later call runs as them.

The tradeoff is a round trip through your backend for every outbound action, and a connected account per user per third-party system. That is the cost of being able to say, for every message the agent posted, which human it posted as.

What to build first

Register two read tools on the view where your users already spend time, with tight schemas, and watch them in the DevTools panel. Add one mutating tool with in-page confirmation. Add the agent-initiated header and make sure it lands in your API logs.

Then look at every tool that would need to reach outside your app, and move that call to the backend, as the user, before you register it. The browser became a tool server when WebMCP shipped. Treat it like one: declared surface, backend-enforced authorization, per-user credentials that never reach the page.

Recommended Reading: Access Control for Multi-Tenant AI Agents on the privilege escalation patterns that a shared bot token invites.

The Slack connector and its tools, including the exact input schema for slack_send_message, are documented at docs.scalekit.com/agentkit/connectors/slack.

FAQs

What is WebMCP?

A proposed browser API through which a web page registers named tools with JSON schemas, so an in-browser agent calls those instead of guessing at the DOM. It comes from Google and Microsoft under the W3C Web Machine Learning Community Group.

Which browsers support WebMCP today?

Only Chrome, behind a flag since Chrome 146 Canary and in a public origin trial from Chrome 149. Only the browser's own agent consumes the tools, and multi-browser support has no announced timeline.

How do I keep page tools in sync with the UI?

Register tools when the relevant view mounts and unregister them when it unmounts, re-register on auth state changes, and feature-detect so the page behaves normally where the API is absent.

Why is a page tool an authorization problem?

It runs inside the page with the user's session cookies, so whatever the user can do, the agent can now do without a click. Enforce permissions in the backend, register fewer operations than the UI offers, and confirm mutations in the page.

How should a page tool reach Slack or GitHub?

Never with a token in the page or a shared bot token. The tool calls your backend, which executes the action as the signed-in user through that user's own connected account, so the credential never reaches the browser.

How do I tell agent calls apart from clicks in the logs?

Have the tool send a header the UI never sends, reject calls without it on agent-only endpoints, and log it beside the user id and the execution id so an incident review can say who acted.

No items found.
On this page
Share this article
Scalekit
Find us on ProductHunt

Acquire enterprise customers with
‍zero upfront cost.

Every feature unlocked. No hidden fees.