Enabling WebMCP for a website to Register a Tool an AI Agent can Discover and Call

Sitecore Architect
  • Twitter
  • LinkedIn

When an AI agent needs to act on a page on a user’s behalf today, it usually must fall back on scraping the visible DOM, guessing at field names, fighting with dynamically rendered widgets, and breaking the moment a page’s markup changes. WebMCP is an emerging browser capability that offers a different path. A website can register structured, named “tools” directly with the browser, each with a declared input schema and a real function behind it. An agent operating in that browser can then discover those tools and call them directly, the same way a program calls an API, instead of trying to reverse-engineer a visual interface.

This article walks through, at a generic level, how we added WebMCP support to an existing website feature, from enabling the capability on every page, to registering a tool for a specific piece of UI, to calling that tool directly from a browser console to verify it works.

 

Enabling WebMCP on Every Page

WebMCP is currently shipped behind a Chrome origin trial. Before any page can register a tool, the browser needs to be told that this specific origin has opted in. That opt-in must be present in the HTML the browser receives, not added later by a script.

 

The Origin Trial Token

Signing up for the trial for a given origin produces a token, a signed string that encodes the origin it is valid for, the feature it unlocks, and an expiry date. The browser checks this token against the page’s actual origin when the page loads. If it matches and has not expired, the experimental API becomes available on document for the lifetime of that page.

A few practical details mattered in getting this right:

  • The token is origin-specific. A token issued for a local development URL will not work on the production domain, and vice versa. Each environment needs its own token.
  • When registering through the Chrome Origin Trials registration page, the token should be first-party when the page is using the capability on itself.
  • The token is not a secret. It is safe to ship inside the HTML source because its only job is proving the origin is enrolled.

     

Rendering the Token on Every Page

Since the trial must be active for any page that might register a tool, the opt-in tag is rendered once in a shared layout component that wraps every page, rather than being duplicated per feature. This addition can be made in the layout file. The token itself is kept in an environment variable and read at render time, so each deployment, including local, staging, and production environments, can carry its own origin-specific value without code changes.

// Shared layout, rendered for every page
<Head>
  ...
  <meta
    key="origin-trial"
    httpEquiv="origin-trial"
    content={process.env.WEBMCP_TOKEN}
  />
</Head>

With the token in place and valid, loading the page and checking the browser console confirms the capability is live:

'modelContext' in document
// true

 

Registering a Tool

With the capability active, a piece of UI can register a tool: a name, a human- and machine-readable description, a JSON Schema describing its inputs, and an execute function that performs the work when the tool is called. The schema lets an agent understand what the tool needs without reading any source code.

await document.modelContext.registerTool({
  name: 'tool_name',
  description: 'What this tool does, in plain language.',
  inputSchema: {
    type: 'object',
    properties: {
      fieldOne: { type: 'string', description: '...' },
      fieldTwo: { type: 'string', description: '...' },
    },
    required: ['fieldOne'],
  },
  annotations: {
    readOnlyHint: false,
    consequentialHint: true,
  },
  execute: async (input) => {
    // ... do the work, call an internal API, etc.
    return JSON.stringify({ status: 'success' });
  },
});

Two easily overlooked details shaped how this is wired up in practice:

  • The execute function must return a string. If the result is structured data, it is returned as a JSON-encoded string, which the caller then parses.
  • The optional annotations signal to the browser and the agent how carefully the tool should be treated. Marking a tool consequentialHint: true because calling it has a real side effect, such as creating a record, sending something, or changing state, allows the browser to prompt a human for confirmation before it runs rather than letting an agent fire it silently.

 

Registering Only When It Is Safe and Cleaning Up

A tool should not be registered unconditionally the moment a component mounts. It should only be registered once whatever it depends on is actually ready, and it needs to be unregistered again if the component unmounts or that underlying condition stops being true. The registration call accepts a cancellation signal for exactly this purpose:

useEffect(() => {
  if (!readyToRegister || !('modelContext' in document)) {
    return;
  }

  const controller = new AbortController();

  document.modelContext
    .registerTool(toolDefinition, { signal: controller.signal })
    .catch((err) => {
      if (err?.name !== 'AbortError') {
        console.error('Tool registration failed:', err);
      }
    });

  return () => controller.abort();
}, [readyToRegister]);

Aborting the signal during cleanup unregisters the tool.

 

Calling the Tool from the Browser Console

Before wiring up a real agent, the fastest way to verify that a tool works end to end is to call it directly from the browser’s DevTools console. This uses the same mechanism an agent would use, just driven manually.

 

Listing Registered Tools
await document.modelContext.getTools()

// [{ name: "tool_name", description: "...", inputSchema: "...", ... }]

 

Finding and Calling a Specific Tool

One detail worth knowing up front is that the call to invoke a tool expects the actual tool object returned by getTools(), not just its name as a string. The pattern is to look up the tool first, then pass that object in:

const tools = await document.modelContext.getTools();
const tool = tools.find((t) => t.name === 'tool_name');

await document.modelContext.executeTool(
  tool,
  JSON.stringify({
    fieldOne: 'some value',
    fieldTwo: 'another value',
  })
);

The result comes back as the JSON string returned by the tool’s execute function. It can be parsed and inspected like any API response, making it straightforward to confirm a successful call. It is equally straightforward to deliberately send invalid input and confirm that the tool reports a clear, structured error instead of failing silently or throwing something the caller cannot interpret.

 

Summary

The overall shape of the work was consistent across all three stages: opt the origin in once, globally, so the capability is available wherever it is needed; register a tool only when it would genuinely work and tear the registration down the moment that stops being true; and give the tool a return contract clear enough that both a human testing it from a console and an agent calling it autonomously can tell, from the response alone, exactly what happened.

None of the individual pieces are complicated on their own: the origin trial, a registration call, and a lookup followed by an invocation. Getting the details right in each one, including the key prop on the meta tag, the first-party token, the AbortController-driven lifecycle, and passing the tool object rather than its name, is what makes the difference between a tool that looks right and one that works reliably.