WebMCP Example in Chrome: Register Page Tools and List Them
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.
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
stringwith 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-dirkeeps 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-portopens the Chrome DevTools Protocol (CDP) on loopback. You only need it for Step 4.--enable-features=WebMCPTestingturns on the experimental API for pages. You can add--headless=newif 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
annotationskey at all, while the imperative one got Chrome’s defaults filled in (consequentialHint,untrustedContentHint,debugging) next to thereadOnlyHint: truewe declared. - Each tool also has
origin(the page’s origin) andtitle(an empty string for both demo tools). document.modelContextalso exposesexecuteToolandontoolchange,ontoolactivatedandontoolcancelhandlers.executeToolruns 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.modelContextis"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_kindtells you where to look in the code.imperativemeans aregisterToolcall.declarativemeans an annotated form. REA decides this from whether Chrome attached a DOM node to the registration.input_schemais the full schema as the page declared it, not a summary.input_schema_sha256is a canonical digest of that schema. Store it, and a later run tells you whether a schema changed even when the name did not.annotationsare the hints. OurreadOnlyHint: truecame back asread_only: true, and Chrome filleduntrusted_content: false. On the form, and on any tool registered without annotations, they arenull, meaning “not declared”, not “false”.registration_sourcepoints to the line that calledregisterTool. CDP line and column numbers start at zero, soline: 6is line 7 in your editor, themc.registerTool({line. Declarative tools have no call stack, so this isnull.tool_keyis 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 usenameandinput_schema_sha256to match tools across days, nottool_key.completeness.statusofcomplete_within_windowmeans 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.