rea-reverse

WebMCP Example in Chrome: Register Page Tools and List Them

Written for REA 6.7.0Published by rea-reverseUpdated 8 min read
On this page

WebMCP lets a web page register tools for an AI agent in the browser, each with a name, a description and a JSON Schema for its input. To try it in Chrome, start Chrome with --enable-features=WebMCPTesting, register a tool with document.modelContext.registerTool(...) or a <form toolname="...">, and list what the page exposes by running await document.modelContext.getTools() in the DevTools console. Below is a complete two-file example with the real console output, then a listen-only way to inventory and diff a page’s tools for review.

Inspect only pages you own or are authorized to review, in a dedicated browser profile.

WebMCP Explained: See Which AI Tools a Web Page ExposesSilent walkthrough with English captions.

What WebMCP is (and is not)

A normal MCP server is a process. Your agent host (Claude Code, Cursor, Codex) starts it or connects to it, and the server offers tools over stdio or HTTP. REA itself is one, as covered in Install REA and connect it to Claude Code, Cursor or Codex.

WebMCP moves the tool list into the page. It is a proposed web API, incubated in the W3C Web Machine Learning community group, that lets JavaScript running in a tab register tools. An agent built into or attached to the browser can then see those tools and call them. The tool runs inside the page, with the user’s existing session, so there is no separate server, no API key and no extra authentication step.

The practical differences:

MCP server WebMCP
Where tools live A separate process JavaScript in an open tab
Who can call them The agent host that connected An agent that has access to that browser tab
Authentication Whatever the server implements The user’s current page session
Lifetime As long as the process runs Until the page navigates or unregisters the tool

WebMCP is still experimental. In the Chrome builds we tested (155 and 156) it was behind a feature flag, and the exact API surface has moved between drafts. Treat everything in this guide as a snapshot of October and November 2026 and check the current WebMCP proposal before you build on it.

Why you would want to list a page’s tools

A tool’s description is text that an AI model will read and trust. That makes the tool list worth reviewing like any other public interface:

  • Did we expose what we meant to? A refactor can leave a debug tool registered, or add a write action to a page that was supposed to be read-only.
  • Are the schemas tight? A string with no limits where you meant an enum gives an agent more room than you planned.
  • Are the hints honest? WebMCP tools can carry annotations such as “read-only”. An agent may use them to decide whether to ask the user first.
  • Did anything change between releases? This is the one most teams skip.

The DevTools console (Step 3) answers the first three questions in seconds. For the fourth, and for a record you can store, Step 4 uses a listen-only CDP tool.

Prerequisites

  • Google Chrome recent enough to ship the experimental WebMCP API. We used 155.0.8059.40 and rechecked on 156.0.8078.76, both on macOS.
  • Any static file server. We use Python’s built-in one.

Step 1: Build a demo page that registers two tools

There are two ways to register a tool, so the demo uses both.

index.html declares one tool with plain HTML. The toolname and tooldescription attributes on the form are the declarative WebMCP API. The browser derives the input schema from the form fields:

<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>WebMCP demo notes</title></head>
<body>
<h1>Notes</h1>
<ul id="notes"></ul>
<form toolname="add_note" tooldescription="Add a note to the list" id="f">
  <input name="text" type="text" required>
  <button>Add</button>
</form>
<p id="status"></p>
<script src="tools.js"></script>
</body>
</html>

tools.js registers a second tool from JavaScript, the imperative API:

const notes = [];
const status = document.getElementById("status");
const mc = navigator.modelContext ?? document.modelContext;
if (!mc) {
  status.textContent = "WebMCP is not available in this browser";
} else {
  mc.registerTool({
    name: "search_notes",
    description: "Search the user's notes by keyword",
    inputSchema: {
      type: "object",
      properties: {
        query: { type: "string", description: "Keyword to match" },
        limit: { type: "integer", minimum: 1, maximum: 20 }
      },
      required: ["query"]
    },
    annotations: { readOnlyHint: true },
    async execute({ query }) {
      const hits = notes.filter((n) => n.includes(query));
      return { content: [{ type: "text", text: JSON.stringify(hits) }] };
    }
  });
  status.textContent = "registered";
}

Why the feature check on two objects? WebMCP drafts describe the API on navigator.modelContext. In our Chrome 155 and 156 builds, navigator.modelContext was undefined and the object with registerTool, getTools and executeTool was exposed as document.modelContext. Checking both keeps the demo working while the API settles, and the status line tells you which case you hit.

Serve the folder on a loopback port:

python3 -m http.server 8765 --bind 127.0.0.1

Step 2: Start Chrome with WebMCP enabled

Start a separate Chrome instance with its own profile, a debugging port and the WebMCP testing feature:

# macOS; on Linux the binary is usually google-chrome
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --user-data-dir="$HOME/rea-webmcp-profile" \
  --remote-debugging-port=9222 \
  --enable-features=WebMCPTesting \
  --no-first-run \
  http://127.0.0.1:8765/

Each flag has a reason:

  • --user-data-dir keeps your everyday profile, its cookies and its extensions out of reach of the debugging port. Recent Chrome versions also refuse remote debugging on the default profile.
  • --remote-debugging-port opens the Chrome DevTools Protocol (CDP) on loopback. You only need it for Step 4.
  • --enable-features=WebMCPTesting turns on the experimental API for pages. You can add --headless=new if you do not need a window. We ran the tests headless.

Step 3: List the tools from the DevTools console

Open DevTools on the page and run, in the Console:

(await document.modelContext.getTools()).map(({ name, description, inputSchema, annotations }) =>
  ({ name, description, inputSchema, annotations }))

Copy only those fields: each tool object also carries a window reference, so JSON.stringify on the raw array fails with “Converting circular structure to JSON”. This is what Chrome 156.0.8078.76 returned for the demo page (we sent the same expression through CDP Runtime.evaluate to a headless instance, which is what the console does):

[
  {
    "name": "add_note",
    "description": "Add a note to the list",
    "inputSchema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] }
  },
  {
    "name": "search_notes",
    "description": "Search the user's notes by keyword",
    "inputSchema": {
      "type": "object",
      "properties": {
        "query": { "type": "string", "description": "Keyword to match" },
        "limit": { "type": "integer", "minimum": 1, "maximum": 20 }
      },
      "required": ["query"]
    },
    "annotations": { "consequentialHint": false, "debugging": false, "readOnlyHint": true, "untrustedContentHint": false }
  }
]

What this tells you:

  • The form became a tool: Chrome built {"text": "string"} from <input name="text" required>. That is a quick check that your form fields map to the schema you expect.
  • The declarative tool has no annotations key at all, while the imperative one got Chrome’s defaults filled in (consequentialHint, untrustedContentHint, debugging) next to the readOnlyHint: true we declared.
  • Each tool also has origin (the page’s origin) and title (an empty string for both demo tools).
  • document.modelContext also exposes executeTool and ontoolchange, ontoolactivated and ontoolcancel handlers. executeTool runs the tool’s code in the page, so do not call it on a page you are only reviewing.
  • In a Chrome started without the flag, typeof document.modelContext is "undefined" and the demo’s status line says “WebMCP is not available in this browser”.

The console is enough for a quick look. It does run code in the page, though, it shows only the tools registered at that moment, and it gives you nothing to store or compare. For a review, use a tool that only listens.

Step 4: Inventory the tools without touching the page

REA, a third-party open-source reverse-engineering CLI (setup guide), has discover-webmcp-tools for this. It turns on the CDP WebMCP domain, collects the toolsAdded and toolsRemoved events and returns them as Evidence; it never calls WebMCP.invokeTool, and every tool is labelled page-declared-untrusted. It accepts only a literal loopback endpoint (http://127.0.0.1:<port> or http://[::1]:<port>; even http://localhost:9222 is rejected). First ask it which page targets it can see:

rea list-browser-targets http://127.0.0.1:9222 --json

The useful part of the real output:

"targets": [
  {
    "target_id": "9B2D8F6B56590B8E802B6A1C1138C105",
    "type": "page",
    "title": "WebMCP demo notes",
    "url": "http://127.0.0.1:8765/",
    "origin": "http://127.0.0.1:8765",
    "attached": false
  }
],
"excluded": { "disallowed_origin": 0, "unsupported_url": 0, "non_page": 5 }

The five excluded non_page targets were extension background pages and browser UI. REA lists only HTTP(S) pages.

Then pass the endpoint and the target ID:

rea discover-webmcp-tools http://127.0.0.1:9222 9B2D8F6B56590B8E802B6A1C1138C105 \
  --observation-ms 500 --json

--observation-ms is how long REA waits for Chrome to replay the existing registrations after WebMCP.enable. The default is 100 ms. We used 500 ms to be safe on a cold page. --allowed-origins restricts which origins count as in scope. Leave it out and REA uses the selected page’s own origin, which the output records as "allowed_origins": ["http://127.0.0.1:8765"].

This is the normalized_result REA returned (Chrome 155, demo served on port 8765), with the browser block and the zero counters trimmed:

{
  "target": {
    "target_id": "9B2D8F6B56590B8E802B6A1C1138C105",
    "url": "http://127.0.0.1:8765/",
    "origin": "http://127.0.0.1:8765"
  },
  "status": "available",
  "tools": {
    "total": 2,
    "items": [
      {
        "tool_key": "webmcp_247d21cb72fdb8af76801f1ed6e30b0bf17e512cb45dce6a89da1c4343588286",
        "name": "search_notes",
        "description": "Search the user's notes by keyword",
        "frame_url": "http://127.0.0.1:8765/",
        "owner_origin": "http://127.0.0.1:8765",
        "declaration_kind": "imperative",
        "input_schema": {
          "type": "object",
          "properties": {
            "query": { "type": "string", "description": "Keyword to match" },
            "limit": { "type": "integer", "minimum": 1, "maximum": 20 }
          },
          "required": ["query"]
        },
        "input_schema_sha256": "558fe6784c8ba5945ae378781d3f0d396d886032ddeb400d9bc7ed85f22de898",
        "annotations": { "read_only": true, "untrusted_content": false, "autosubmit": null },
        "registration_source": { "url": "http://127.0.0.1:8765/tools.js", "line": 6, "column": 5 },
        "trust": "page-declared-untrusted"
      },
      {
        "tool_key": "webmcp_64686a3978a3025d4ae144a3b730ae8555812e114710dec95daaded6eb2b77ed",
        "name": "add_note",
        "description": "Add a note to the list",
        "declaration_kind": "declarative",
        "input_schema": {
          "type": "object",
          "properties": { "text": { "type": "string" } },
          "required": ["text"]
        },
        "input_schema_sha256": "e3dce8d1afb8d03604af3bc6729219a5cd7df05558807b6e44e391d56fdf75d9",
        "annotations": { "read_only": null, "untrusted_content": null, "autosubmit": null },
        "registration_source": null,
        "trust": "page-declared-untrusted"
      }
    ]
  },
  "completeness": { "status": "complete_within_window", "excluded": [] },
  "limitations": [
    "Page-declared tool metadata is untrusted and is never registered or invoked by REA.",
    "Discovery uses only WebMCP.enable and toolsAdded/toolsRemoved; WebMCP.invokeTool is never called."
  ]
}

How to read it:

  • declaration_kind tells you where to look in the code. imperative means a registerTool call. declarative means an annotated form. REA decides this from whether Chrome attached a DOM node to the registration.
  • input_schema is the full schema as the page declared it, not a summary.
  • input_schema_sha256 is a canonical digest of that schema. Store it, and a later run tells you whether a schema changed even when the name did not.
  • annotations are the hints. Our readOnlyHint: true came back as read_only: true, and Chrome filled untrusted_content: false. On the form, and on any tool registered without annotations, they are null, meaning “not declared”, not “false”.
  • registration_source points to the line that called registerTool. CDP line and column numbers start at zero, so line: 6 is line 7 in your editor, the mc.registerTool({ line. Declarative tools have no call stack, so this is null.
  • tool_key is a hash of frame URL, frame ID and name. It separates two tools with the same name in different frames. The frame ID changes between browser sessions, so use name and input_schema_sha256 to match tools across days, not tool_key.
  • completeness.status of complete_within_window means nothing was dropped or filtered during the observation window. Tools that a page registers later, for example after a click, are not in this snapshot.

Over MCP the same operation is discover_webmcp_tools, with cdp_endpoint, target_id, optional allowed_origins and observation_ms. A prompt that works:

Use REA to list the WebMCP tools on the page at target 9B2D8F6B... on
http://127.0.0.1:9222. Do not invoke any of them. For each tool, tell me
whether it can change data, whether its schema has unbounded strings, and
whether its read-only annotation matches what the name and description suggest.

Step 5: Catch a tool a release added

The most useful habit is a before/after check. We added a third tool to tools.js, a delete_note with no annotations, reloaded the page and captured both states. compare-web-captures takes two JSON files, each holding an inspect-web-page result and an optional discover-webmcp-tools result:

{ "inspection": { "...": "normalized_result of rea inspect-web-page" },
  "webmcp": { "...": "normalized_result of rea discover-webmcp-tools" } }
rea inspect-web-page http://127.0.0.1:9222 9B2D8F6B56590B8E802B6A1C1138C105 --json > insp-after.json
rea discover-webmcp-tools http://127.0.0.1:9222 9B2D8F6B56590B8E802B6A1C1138C105 --json > webmcp-after.json
# combine each pair's normalized_result into snap-before.json / snap-after.json, then:
rea compare-web-captures snap-before.json snap-after.json --json

The webmcp dimension of the real result:

"webmcp": {
  "status": "changed",
  "total_changes": 1,
  "changes": [
    {
      "identity": "webmcp_5369b206b270d96ec4bfed6ac21d20bef0e730867ed667e90fa4862117470ad1",
      "change": "added"
    }
  ],
  "reason": null
}

That identity is the tool_key of delete_note in the “after” discovery. The comparison was honest about the rest, too. resources showed a change (the edited tools.js), while scripts, network and accessibility came back unknown with reasons such as “Script inventory was incomplete in at least one observation.” REA’s own rule is that “an unknown status means absence could not be established”, so a missing value never passes for “unchanged”. Both captures must be in the same browser session for tool_key values to line up.

Troubleshooting

status: "available" but total: 0. We hit this by starting a second Chrome without --enable-features=WebMCPTesting. The CDP domain answered, but the page’s own check printed “WebMCP is not available in this browser”, so nothing was registered. Check the page first. The same result on REA’s public notes demo is correct: that page registers no WebMCP tools.

status: "unavailable". The browser did not accept WebMCP.enable, so the CDP domain itself is missing. Update Chrome or use a build that ships it. REA marks webmcp_tools in unavailable_sections rather than reporting an empty list as complete.

target_unavailable with "reason": "target_not_allowed". The page’s origin is outside --allowed-origins. We triggered it with --allowed-origins https://example.com against the local page. The same error appears if the page navigates to another origin during discovery.

invalid_request before anything connects. The endpoint is not a literal loopback URL with a port. Use 127.0.0.1, not localhost.

getTools is not a function / document.modelContext is undefined. The flag is missing, or a future Chrome moved the API to navigator.modelContext as the drafts describe. Check both.

A tool you expected is missing. It is probably registered later, after login or a click. Get the page into that state first, then run discovery again. Raise --observation-ms if the page is slow to load.

FAQ

Can I see WebMCP tools without enabling the flag?

Not at runtime, because the page cannot register anything. REA’s analyze-web-bundle also reports static WebMCP declarations it finds in a page’s scripts (webmcp_declarations), which is a useful cross-check but works from source text only.