rea-reverse

WebMCP Explained: See Which AI Tools a Web Page Exposes

Written for REA 6.4.0Updated 9 min read
On this page

WebMCP lets a web page hand an AI agent a list of tools: “search my notes”, “add an item to the cart”, each with a name, a description and a JSON Schema for its input. That is useful, and it also means a page now carries a second, machine-facing interface that reviewers rarely look at. This guide explains what WebMCP is, builds a small demo page that registers tools both ways, and then uses REA, the open-source project by morluto, to list every tool the page declares without ever calling one.

Who this is for: front-end developers adding WebMCP to their own app, and reviewers or QA engineers who want to know exactly what a page offers to agents before it ships.

What you will learn:

  1. What WebMCP is and how it differs from a normal MCP server
  2. The two ways a page registers a tool: a JavaScript call and an annotated form
  3. How to start Chrome with WebMCP enabled and connect REA 6.4.0 to it
  4. How to read the output of discover-webmcp-tools, field by field
  5. How to catch a tool that a new release added, with compare-web-captures

Only inspect pages you own or are authorized to review. REA attaches to a browser through the Chrome DevTools Protocol (CDP), which gives deep access to that browser profile. Use a dedicated profile and read REA’s DISCLAIMER.md.

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 build we tested (Chrome 155) 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 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.

You could open DevTools and poke at the page, but that runs code in the page and gives you nothing you can store or compare. REA’s discover-webmcp-tools is built for this review. It only listens: it turns on the CDP WebMCP domain, collects the toolsAdded and toolsRemoved events, and returns them as Evidence. In REA 6.4.0’s own words, “WebMCP.invokeTool is never called”, and every tool is labelled page-declared-untrusted.

Prerequisites

  • REA 6.4.0 installed (npm install -g [email protected], or npx -y [email protected]). Check it with rea --version.
  • Google Chrome recent enough to ship the experimental WebMCP domain. We used 155.0.8059.40 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, and REA reports which one each tool used, 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 build, 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 and remote debugging

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 CDP on loopback. REA only accepts an endpoint of the form http://127.0.0.1:<port> or http://[::1]:<port>. Even http://localhost:9222 is rejected as invalid input.
  • --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.

Now ask REA 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.

Step 3: Discover the page’s tools

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 6.4.0 returned, 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. For the form, Chrome built {"text": "string"} from the <input name="text" required>, which is a quick way to check that your form fields map to the schema you expect.
  • 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 4: 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. For a wider look at Evidence across releases, see Evidence, snapshots and version comparison.

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.

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

Is WebMCP the same as MCP?

No. They share the idea of named tools with JSON Schema inputs, but MCP tools are served by a separate process over stdio or HTTP. WebMCP tools are registered by a page and run inside it, using the user’s session.

Does REA call or test the tools it finds?

No. REA 6.4.0 does not expose WebMCP.invokeTool or any way to run page code from this command. It reports only what the page declared. Treat descriptions and annotations as claims to check, not facts.

Can I see WebMCP tools without enabling the flag?

Not with this method, 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.

How does this fit with tracing a page’s API calls?

They answer different questions. This guide covers what a page offers to agents. To follow what the page sends to its back end, read Trace Website API Calls with REA.

Next steps