Dev

WebMCP on a real site: registering tools with document.modelContext, and why my navigator.modelContext script registers nothing

The API moved from navigator to document in May, lost three methods before that and turned its registration call into a promise after. A script written to satisfy a detector in April can expose zero tools to a current browser. The working registration, a test that needs no agent, and where a plain MCP endpoint is the better door.

In April I added two tools to this site through WebMCP: get_contact and list_products, both read-only, both registered from a small inline script in the page head. On 11 October I pointed a current Chrome at the homepage and asked the browser which tools the page exposes. It answered with an empty list.

Nothing on the site had broken. The script still looks for navigator.modelContext, and the specification now defines the entry point on document. The less comfortable part is in my own notes from April: I never tested that script against a browser, only against a scanner, and the scanner never reported the tools either. So this is a walk-through of registering tools the way the draft describes it today, with the commands I ran to check, and with my own script as the worked example of what a detector cannot tell you.

What is WebMCP, and where does the API live now?

WebMCP is a proposed browser API that lets a page hand an agent a list of named JavaScript functions, each with a description and a JSON Schema for its arguments. As of the draft dated 9 October 2026, the entry point is document.modelContext, in secure contexts only. Abridged to the three methods this walk-through uses:

webidl
partial interface Document {
  [SecureContext, SameObject] readonly attribute ModelContext modelContext;
};
 
interface ModelContext : EventTarget {
  Promise<undefined> registerTool(ModelContextTool tool,
      optional ModelContextRegisterToolOptions options = {});
  Promise<sequence<RegisteredTool>> getTools(
      optional ModelContextGetToolOptions options = {});
  Promise<DOMString> executeTool(RegisteredTool tool,
      optional object inputObject,
      optional ModelContextExecuteToolOptions options = {});
};

It is a Draft Community Group Report, which the document itself says is not a W3C Standard and not on the standards track. The repository history shows how much moved under anyone who integrated early:

DateChangePull request
5 March 2026provideContext() and clearContext() removed#132
26 and 27 March 2026registerTool() takes an AbortSignal, unregisterTool() dropped from the explainer#147, #156
27 May 2026the modelContext getter moved from Navigator to Document#184
8 June 2026registerTool() returns a promise#200
8 October 2026the declarative, form-based API removed from the spec "for now"#338

Chrome announced an early preview in February and an origin trial from Chrome 149 in June. The spec repository's implementation status also lists an origin trial in Edge 150, experimental support in Brave's Leo, and ChatGPT Desktop as supported. Firefox and Safari appear there only as links to standards-position and tracking issues.

Why does a WebMCP script written for navigator.modelContext register nothing?

Because in a current Chrome there is no such object, and a careful script treats a missing object as "not supported" and exits quietly. Mine did exactly that. Here is the core of what I shipped on April 18, reformatted and without its outer try:

js
function register() {
  if (done) return true;
  var mc = typeof navigator !== 'undefined' && navigator.modelContext;
  if (!mc) return false;
  if (typeof mc.registerTool === 'function') {
    tools.forEach(function (t) { try { mc.registerTool(t); } catch (e) {} });
  }
  if (typeof mc.provideContext === 'function') {
    try { mc.provideContext({ tools: tools }); } catch (e) {}
  }
  done = true;
  return true;
}

Around it sat a 50 ms polling loop that retried for ten seconds, plus listeners on DOMContentLoaded, readystatechange and load. The commit messages from that day say why: I expected a scanner's shim to inject navigator.modelContext after my script had run, so I kept retrying until it would. The log shows four commits in eleven minutes. The first added the tools, the next three reworked the registration so a scanner's probe would see it. My notes from the same day record how that ended: the scanner still reported no tools registered, and I left the script in.

That is the part I would do differently. A browser implementation does not drift in at some point during page load. With a flag, or a trial token in the response, document.modelContext is there before the first script runs. The polling was fitted to a checker I could not see inside, the checker never confirmed anything, and no browser was involved at any point. In Chrome 155 with WebMCP enabled, typeof navigator.modelContext is undefined, the loop gives up after ten seconds, and nothing is registered. No error and no warning.

I can only vouch for the version I tested. I have not verified in which Chrome release the navigator name stopped working.

How do you register a WebMCP tool with document.modelContext.registerTool?

Feature-detect document.modelContext, call registerTool() once per tool, and handle the promise it returns. This is the replacement for the script above, tested as written (the returned data is cut down for the listing):

js
(function () {
  var mc = document.modelContext;
  if (!mc || typeof mc.registerTool !== 'function') return;
 
  var empty = { type: 'object', properties: {} };
  var readOnly = { readOnlyHint: true };
 
  var tools = [
    {
      name: 'get_contact',
      description: 'Return contact information for the person behind this site.',
      inputSchema: empty,
      annotations: readOnly,
      execute: async function () {
        return JSON.stringify({ email: 'max@nardit.com', site: 'https://max.nardit.com' });
      },
    },
    {
      name: 'list_products',
      description: 'List the products built by the site owner, with canonical URLs and status.',
      inputSchema: empty,
      annotations: readOnly,
      execute: async function () {
        return JSON.stringify([{ name: 'Beetroot', url: 'https://max.nardit.com/beetroot', status: 'active' }]);
      },
    },
  ];
 
  tools.forEach(function (tool) {
    mc.registerTool(tool).catch(function (err) {
      console.warn('WebMCP: ' + tool.name + ' not registered: ' + err.name);
    });
  });
})();

Against the April version the differences are small, and most of them fail without a sound.

There is no polling. If document.modelContext is missing on the first line, the browser is not offering WebMCP to this page. The one ordering rule left: if you inject an origin trial token from script, register after it.

registerTool() returns a promise, so a try around the call catches nothing. A second registration under the same name rejected with InvalidStateError: Duplicate tool name in my run. The spec also rejects an empty name or description and an invalid schema. Without a .catch() those land as unhandled rejections.

There is no unregisterTool(). A tool lives until its AbortSignal fires:

js
var controller = new AbortController();
// the signal has to go in with the first registration of this name
await document.modelContext.registerTool(
  {
    name: 'get_cart',
    description: 'Return the items in the current cart.',
    inputSchema: { type: 'object', properties: {} },
    annotations: { readOnlyHint: true },
    execute: async function () { return JSON.stringify([]); },
  },
  { signal: controller.signal }
);
// later, for example when the user logs out:
controller.abort();

After abort() the tool was gone from getTools() in my test. This matters for a single-page app that registers tools per view.

Annotations are worth setting even on trivial tools. readOnlyHint defaults to false, and Chrome's security page says the hint helps an agent decide when to ask the user for confirmation. What the agent does with it stays the agent's policy, so the hint promises nothing, but a read-only tool that does not say so has told the agent less than it could. The two other safety hints are untrustedContentHint, for output that carries user-generated or third-party text, and consequentialHint, for actions like a payment. The imperative API page lists a fourth annotation, debugging, from Chrome 156.

In a cross-origin iframe registration is off by default. The spec gates the API behind a tools permissions policy with a default allowlist of 'self', so the embedding page has to grant it with allow="tools".

How do you test WebMCP tools without an agent?

Use the page's own API: getTools() lists the tools available to the document, its own and those of eligible child frames, and executeTool() runs one, so the browser console is enough. For local work Chrome's overview names the flag chrome://flags/#enable-webmcp-testing. I worked from the command line, where the feature switch below exposed the API. I ran it headless over the DevTools protocol. Headed, the launch is the same, pointed at any static server on a local port (the page needs a secure context, and localhost counts):

bash
google-chrome --version
# Google Chrome 155.0.8059.39
 
google-chrome --enable-features=WebMCPTesting \
  --user-data-dir=/tmp/webmcp-probe http://localhost:8765/

Then, in the console of the page under test, the same calls I sent over the protocol:

js
const tools = await document.modelContext.getTools();
console.log(tools.map((t) => t.name));
const tool = tools.find((t) => t.name === 'get_contact');
if (tool) console.log(await document.modelContext.executeTool(tool, {}));

I made the same calls against three pages. Without the feature switch neither document.modelContext nor navigator.modelContext exists in this build. With it:

PagegetTools()
the April registration logic on a local page[] after 12 seconds
the script from the previous sectionget_contact, list_products
this site's homepage on 11 October 2026[]

executeTool() gave back a string in both cases I tried: the JSON text unchanged when execute returned a string, and the serialized form when it returned a plain object. Chrome's imperative API page writes its examples with string results, so I return strings.

If you prefer a UI, Chrome DevTools has a WebMCP pane under Application that lists the tools on the active tab and runs one by hand. I did not use it for this test.

What this test proves is narrow: the browser accepted the registration and the function returns what you expect. It says nothing about whether any agent will choose to call it.

What can an agent do with WebMCP tools, and what can it not?

An agent can call your functions while your page is open in a supporting browser, and that sentence carries every limit. Chrome's comparison page puts it plainly:

text
Importantly, WebMCP tools are ephemeral. They exist only when your
page is open. Once the user navigates away from your site or closes
the tab, the agent cannot access your site or take actions.

Discovery happens by visiting. The overview lists it as a limitation: clients and browsers must visit a site directly to know if it has callable tools. There is no registry and no well-known file.

Outside the origin trial there is nothing to call. A visitor in stock Chrome gets no document.modelContext unless your site serves a trial token, through an Origin-Trial response header or the equivalent meta tag as described in Chrome's origin trials guide. This site served none when I checked on 11 October, so the fixed script alone would change nothing for that visitor. In the build I tested, the tools appeared only with the testing feature on.

The tool list is an interface, not a boundary. The spec's security section starts from the assumption that the agent already has the user's session:

text
Identity inheritance: Agents are able to inherit user identity and
authentication context from the browser. When an agent visits a
website, it carries the user’s logged-in credentials and session state.

Chrome's security page adds that an extension with host permission can manipulate the page by running custom JavaScript even without WebMCP. So registering a few tidy tools in a logged-in app does not narrow what an agent in that tab can reach. It gives a cooperative agent a better path, and leaves the question of who holds the session exactly where it was. Authorization still belongs on the server behind each tool.

On headless use by agents, which is a different thing from the headless test above, the sources put the weight in different places. The repository added a line on 4 September saying headless use cases are in scope (#296). Chrome's overview says running tools headless may be possible, but the API is primarily designed for local browser workflows with a human in the loop. I read that as allowed and not the target.

WebMCP or a remote MCP server for a public read-only site?

For public, read-only data the MCP endpoint is the one an agent can reach today, and WebMCP is the one that makes sense once there is a logged-in user and page state. On 8 October I put a small read-only MCP server at /mcp on this site, serving the same contact and product data plus an article search. Side by side:

WebMCP toolsRemote MCP server
Reachable whenthe page is open in a supporting browserany time, over HTTP
Discoveryby visiting the pagea URL in a client's config, or a server card (still a draft proposal)
Caller's identitythe user's browser sessionwhatever the endpoint requires (mine: nothing, it is public)
Sees page stateyes: DOM, cart, unsaved formno
Availabilityorigin trial, flag, a few clientsMCP clients that support remote HTTP servers

Chrome frames it as "MCP is for backend" and "WebMCP is for frontend", and recommends using both. For an app, that reading holds. A checkout, a seat map, a half-filled form: that state lives in the tab, and a tool that reads it from the page avoids rebuilding the session on a server.

A site like mine has no such state. On the real site get_contact returns the same seven fields to everyone, and nothing about it needs a tab. My read is that for a public read-only site WebMCP earns its place as an experiment and not as the integration: the same data behind a plain HTTP endpoint serves an agent that has never opened the page. Each registered tool also brings a name, a description and a schema, and when a client hands those to the model they cost context, the same cost any tool catalog has. I have also argued that an agent reaches for the tool whose result it can predict, which is a reason to prefer two clear tools over a long list.

Should a site register WebMCP tools now or wait?

Register now only if you will also own the maintenance, because the table above lists five changes of shape in seven months and the one that caught my script failed without a sound. If the site has real per-user state and you want agent traffic to go through functions you wrote instead of through guessed clicks, the origin trial is a reasonable place to learn. If the site is public content, ship the HTTP endpoint first and treat WebMCP as optional.

Either way, test against a browser and not a detector: getTools() in a flagged Chrome takes a minute, and on a clean profile there is no shim to answer for the browser. Feature-detect the object the current draft names and nothing older, because in the build I tested a fallback to navigator.modelContext or provideContext() is dead code that makes the script look more compatible than it is. And put a date on the integration. The draft said 9 October 2026 when I read it, and the declarative API had left the spec the day before.

The claim I would hold loosely is the one in Chrome's February post, that a direct channel "eliminates ambiguity" and beats raw DOM actuation on reliability. It may well, and the post gives no measurements. What I measured is smaller and less flattering: a registration I wrote in April for a scanner that never did say yes, shipped anyway and left in place, exposed nothing the first time I asked a browser.

Discussion

No comment section here — all discussions happen on X.

Max Nardit

Max Nardit

@mnardit

More articles