WebMCP Explained: Make Your Website Callable by AI Agents

What WebMCP is, where it stands in October 2026, and how I added it to this blog: four tools, the exact code, real outputs, and the document.modelContext bug that hid them.

By Ajith Joseph · Fri Oct 02 2026 · Updated Fri Oct 02 2026 · 15 min read · intermediate

#web-development #mcp #webmcp #ai-agents #chrome

An AI agent that wants to use a website today mostly guesses. It reads the page, works out which element is the search box, types into it and hopes the layout has not changed since yesterday. WebMCP is a proposal to stop that: the page tells the agent what it can do, as named tools with schemas, and the agent calls them like functions.

I added WebMCP to this blog, blog.ajithjoseph.com, and this post is the walkthrough: what the standard is, where it stands as of 3 October 2026, the code I shipped, the outputs it produces, and the one bug that left the tools invisible at first. It also lists what I have not done, because the standard is still moving.

What WebMCP Is, and What It Is Not

Chrome's description is short: WebMCP lets a site "tell agents exactly what they can do" by registering tools. The site declares tools with names and JSON schemas, the browser exposes them to agents, and agents execute them with structured arguments.

Two things it is not:

  • It is not a server. A classic MCP server is a separate process that an agent connects to over a transport. WebMCP tools live in your page's JavaScript and run in the user's browser tab, with the user's session.
  • It is not a replacement for your API. It exposes what a person could do on the page, through the page. The agent gets the same permissions the signed-in user has, no more.

That second point is the reason the standard is interesting. A shopping site, a booking form or a dashboard already has authentication, validation and business rules wired to its UI. WebMCP reuses all of it.

Where It Stands in October 2026

The facts below come from the standard's draft and Chrome's own documentation. Dates are the pages' own.

  • Chrome announced it in February 2026. The Chrome blog post of 10 February 2026 introduced WebMCP and opened an early preview programme.
  • It is a draft W3C Community Group report. The specification published by the Web Machine Learning Community Group was dated 30 September 2026 when I read it. It is not a W3C Standard.
  • The API lives on document. The current draft exposes ModelContext through document.modelContext. Earlier previews used navigator.modelContext. Chrome's imperative API reference, updated 21 September 2026, uses document.modelContext.registerTool().
  • Chrome has an origin trial. The reference page links to a trial registration. Project reports I read put the trial at Chrome 149 through 156, with sites serving a trial token. Chrome's own pages that I could read did not spell out the versions or the local testing flags, so check the trial page before you plan around dates.
  • Lighthouse audits it. An informational audit, "Registered WebMCP tools", lists the tools on a page and warns when more than 40 are registered, because tool definitions cost tokens and confuse agents.
  • There are security hints. Chrome's guidance for authors, updated 1 September 2026, defines three annotations and recommends keeping descriptions and outputs short.

The Two APIs

Imperative. You call registerTool from JavaScript. This is for dynamic work: search, filtering, navigation, anything that needs code.

Declarative. You annotate an ordinary HTML form with toolname and tooldescription and the browser turns it into a tool. Chrome's overview describes it, and the Lighthouse audit counts both kinds. The specification draft still marks its declarative section as a to-do and points to an explainer, so I did not build on it. It also adds nothing a blog needs: the actions here are search and read, not form submissions.

The Tool Shape

A tool has these fields, per Chrome's reference and the draft:

Field Required Meaning
name yes Identifier
description yes What it does, in plain language
execute yes Async function; receives the input object and an options object with an AbortSignal
inputSchema usually JSON Schema for the input
annotations no readOnlyHint, untrustedContentHint, consequentialHint (and a debugging hint in newer Chrome)
exposedTo no Origins allowed to use the tool across frames

Passing { signal } as the second argument to registerTool unregisters the tool when the signal aborts. That is the clean way to remove tools when a page is left or a view goes away.

What I Built for This Blog

A blog has four natural actions, so I registered four tools and no more:

Tool What it does Annotations
search_articles Search by query, up to 5 results read-only, untrusted content
list_latest_articles Newest articles, up to 5 read-only, untrusted content
get_article Read one article in chunks read-only, untrusted content
open_article Navigate the browser to an article none

I did not expose anything that writes. The site's API has write endpoints, but they are not part of any tool, and I kept them out of the read-only description I published for agents.

Step 1: Reuse the Functions the Site Already Has

The React app already fetches posts, searches and loads one article through src/lib/api.ts. The tools call those same functions, so there is no second data path to keep in sync:

import { fetchPost, fetchPosts, searchPosts } from './api';

Step 2: Write the Tools

This is the whole file as it ships. It is about 100 lines, and the comments explain the choices.

import { fetchPost, fetchPosts, searchPosts } from './api';
import type { Post } from '../types';

// WebMCP (https://webmachinelearning.github.io/webmcp/): lets a browser agent call these
// site actions directly instead of scraping the page. Read-only apart from navigation.

interface ToolResult {
  content: { type: 'text'; text: string }[];
}

interface ModelContextTool {
  name: string;
  description: string;
  inputSchema: object;
  annotations?: { readOnlyHint?: boolean; untrustedContentHint?: boolean; consequentialHint?: boolean };
  execute: (input: Record<string, unknown>) => Promise<ToolResult>;
}

interface ModelContext {
  registerTool: (tool: ModelContextTool, options?: { signal?: AbortSignal }) => unknown;
}

// WebMCP moved from navigator.modelContext to document.modelContext; browsers and the inspector
// extension expose one or the other, so look at both.
declare global {
  interface Navigator { modelContext?: ModelContext }
  interface Document { modelContext?: ModelContext }
}

const postUrl = (p: Post) => `${window.location.origin}/blog/${p.id}/${p.slug}`;
// Chrome's guidance for tool authors is to keep one output under about 1.5K characters, so results
// are short summaries and article text is paged.
const clip = (s: string, max: number) => (s.length > max ? s.slice(0, max - 1).trimEnd() + '…' : s);
const summary = (p: Post) => ({ id: p.id, title: p.title, summary: clip(p.excerpt ?? '', 140), url: postUrl(p) });
const ARTICLE_CHUNK = 1200;
const text = (value: unknown): ToolResult => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });

const tools: ModelContextTool[] = [
  {
    name: 'search_articles',
    description: 'Search the blog for articles matching a query. Returns up to 5 matches with id, title, summary and url.',
    inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Words to search for' } }, required: ['query'] },
    annotations: { readOnlyHint: true, untrustedContentHint: true },
    execute: async ({ query }) => text((await searchPosts(String(query))).slice(0, 5).map(summary)),
  },
  {
    name: 'list_latest_articles',
    description: 'List the newest articles, newest first.',
    inputSchema: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 5, default: 5 } } },
    annotations: { readOnlyHint: true, untrustedContentHint: true },
    execute: async ({ limit }) => {
      const pageSize = Math.min(Math.max(Number(limit) || 5, 1), 5);
      return text((await fetchPosts({ page: 1, pageSize })).items.map(summary));
    },
  },
  {
    name: 'get_article',
    description: 'Read an article as Markdown, one chunk at a time. Pass nextOffset from the previous result to continue.',
    inputSchema: {
      type: 'object',
      properties: {
        id: { type: 'string', description: 'Article id from a search or list result' },
        offset: { type: 'integer', minimum: 0, default: 0, description: 'Character offset to start from' },
      },
      required: ['id'],
    },
    annotations: { readOnlyHint: true, untrustedContentHint: true },
    execute: async ({ id, offset }) => {
      const p = await fetchPost(String(id));
      const start = Math.max(Number(offset) || 0, 0);
      const end = start + ARTICLE_CHUNK;
      return text({
        title: p.title,
        url: postUrl(p),
        published: p.created_at,
        totalChars: p.content.length,
        markdown: p.content.slice(start, end),
        nextOffset: end < p.content.length ? end : null,
      });
    },
  },
  {
    name: 'open_article',
    description: 'Navigate the browser to an article so the user can read it.',
    inputSchema: { type: 'object', properties: { id: { type: 'string' }, slug: { type: 'string' } }, required: ['id', 'slug'] },
    execute: async ({ id, slug }) => {
      const url = `/blog/${encodeURIComponent(String(id))}/${encodeURIComponent(String(slug))}`;
      window.location.assign(url);
      return text({ opened: window.location.origin + url });
    },
  },
];

/** Registers the tools when the browser supports WebMCP; they are removed again when the page is left. */
export function registerWebMcpTools(): void {
  const modelContext = document.modelContext ?? navigator.modelContext;
  if (typeof modelContext?.registerTool !== 'function') return;

  const controller = new AbortController();
  for (const tool of tools) modelContext.registerTool(tool, { signal: controller.signal });
  window.addEventListener('pagehide', () => controller.abort(), { once: true });
}

A few decisions in there are worth explaining.

Short outputs. Chrome's author guidance suggests keeping individual outputs near 1.5K characters, with tool descriptions under about 500. A full article is around 14,000 characters, so get_article returns a 1,200-character chunk plus a nextOffset, and the agent asks for the next chunk if it wants more. Lists are capped at five items and summaries are clipped to 140 characters.

Honest annotations. Everything that only reads is marked readOnlyHint. Titles and summaries come from content, so those tools are also marked untrustedContentHint, which tells the agent the payload is data and not instructions. That matters for a blog whose text comes partly from automation. open_article navigates but changes nothing, so it carries no hint. If I ever add a tool that posts or deletes, that is exactly what consequentialHint is for.

The result shape. The draft says execute resolves to a serializable value. I return the MCP-style { content: [{ type: 'text', text }] } object, with the JSON as text. It works with the WebMCP inspector extension I tested the site with and keeps the tools portable if agents settle on that shape. If you build your own, check what your target agents expect.

Step 3: Register Once, Feature-Detect, Clean Up

The registration function is the last twelve lines of the file:

export function registerWebMcpTools(): void {
  const modelContext = document.modelContext ?? navigator.modelContext;
  if (typeof modelContext?.registerTool !== 'function') return;

  const controller = new AbortController();
  for (const tool of tools) modelContext.registerTool(tool, { signal: controller.signal });
  window.addEventListener('pagehide', () => controller.abort(), { once: true });
}

Three rules sit in those lines. Look for the API first and do nothing if it is missing, so the site still works in every browser. Register through a single AbortController. Abort on pagehide so tools do not outlive the page.

It runs once, at startup, in main.tsx:

import { registerWebMcpTools } from './lib/webmcp';

registerWebMcpTools();

This blog is a single-page app, and its four tools make sense on every route, so registering them once at load is enough. If your tools depend on the view (a checkout page with a "confirm order" tool), register them when the view mounts and abort when it unmounts, so the agent never sees a tool that does not apply.

The Bug That Hid My Tools

My first version only looked at navigator.modelContext. The inspector extension showed this:

WebMCP Tools
No tools registered yet in https://blog.ajithjoseph.com/

The code was fine. The extension, and current Chrome, expose the API on document.modelContext, so my feature check found nothing and quietly registered no tools. The fix is the line you saw above: document.modelContext ?? navigator.modelContext, with a typeof ...registerTool check so a half-implemented object does not throw.

The lesson is broader than one property. The standard moved during 2026, and a feature check that says "nothing here" looks identical to "not supported". If a tool list is empty, test both locations before you debug anything else.

Testing It Without Waiting for Agents

You do not need an agent to test tools. You need an object that records what gets registered. Paste this into the console of a dev build, which is how I checked each tool before shipping:

const reg = [];
Object.defineProperty(document, 'modelContext', {
  configurable: true,
  value: { registerTool: (tool, options) => reg.push({ tool, options }) },
});

const m = await import('/src/lib/webmcp.ts');
m.registerWebMcpTools();

const run = async (name, args) =>
  (await reg.find(r => r.tool.name === name).tool.execute(args)).content[0].text;

console.log(reg.map(r => r.tool.name));
console.log(await run('search_articles', { query: 'copilot studio credits' }));

That is a dev-server import path, so it only works under npm run dev. The point is the technique: stub the registry, call execute directly and read what comes back.

Real Outputs

These are from my dev server, so the host in the URLs is localhost. On the live site it is blog.ajithjoseph.com.

The tools after registration:

["search_articles", "list_latest_articles", "get_article", "open_article"]

search_articles with { "query": "copilot studio credits" } returned one match, 396 characters:

[{"id":"bce6eb75-3b04-4c9a-835a-9618a50a9ef9",
  "title":"Copilot Studio Credits: Estimate, Cap and Avoid Surprises",
  "summary":"How Copilot Studio credits are billed in October 2026: the rate table, a tested calculator, the enforcement rules, and the controls that st…",
  "url":"http://localhost:5173/blog/bce6eb75-3b04-4c9a-835a-9618a50a9ef9/copilot-studio-credits-estimate-cap-and-avoid-surprises"}]

list_latest_articles with { "limit": 2 } returned two items, 782 characters in total, newest first (abbreviated here):

[{"id":"79c8ccfd-e436-460c-877f-7da2a2338441",
  "title":"Standard or GitHub Copilot Harness? A Decision Guide", "summary":"Copilot Studio now runs agents on three harnesses. What each is for, the real trade-offs in cost and control, and how to decide, and migrat…", "url":"http://localhost:5173/blog/79c8ccfd-…"},
 {"id":"bce6eb75-3b04-4c9a-835a-9618a50a9ef9",
  "title":"Copilot Studio Credits: Estimate, Cap and Avoid Surprises", "summary":"How Copilot Studio credits are billed in October 2026: …", "url":"http://localhost:5173/blog/bce6eb75-…"}]

get_article with that first id returned the opening of the article and a cursor. The article is 14,279 characters in total; I have elided the text with an ellipsis:

{"title":"Standard or GitHub Copilot Harness? A Decision Guide",
 "url":"http://localhost:5173/blog/79c8ccfd-…",
 "published":"2026-10-01T17:54:00.89",
 "totalChars":14279,
 "markdown":"# Standard or GitHub Copilot Harness? A Decision Guide\n\nMeta Description: Copilot Studio now runs agents on three harnesses. …",
 "nextOffset":1200}

Calling it again with { "id": "...", "offset": 1200 } returns the next chunk, and nextOffset becomes null on the last one. My first version returned the whole article in one call, 14,000-odd characters. With the chunk set to 1,400 characters one call came to 1,690 characters once JSON escaping was counted, so I cut the chunk to 1,200 to sit nearer the guidance.

How an Agent Uses It

With the tools registered, "find me this blog's post on Copilot Studio billing and summarise the enforcement rules" becomes three calls and no scraping:

  1. search_articles({ query: "copilot studio billing" }) returns candidates with ids
  2. get_article({ id }) returns the first 1,200 characters and nextOffset
  3. get_article({ id, offset: 1200 }), repeated until nextOffset is null

Compare that with an agent clicking into a search box, waiting for a client-rendered list and parsing HTML. The tool call is faster, cheaper in tokens and does not break when I change the layout.

What I Have Not Done

Being straight about the gaps is more useful than a clean demo.

  • No origin trial token yet. In Chrome versions where document.modelContext is only available to origins in the trial, a visitor on stock Chrome will not see my tools until the site serves a token (a response header or a meta tag, per the trial page). Today the tools register where the API exists: with the inspector extension I used, and in a browser with WebMCP switched on. Registering the origin and serving a token is the next step.
  • No declarative forms. I skipped the form attributes because the spec section is unfinished.
  • No exposedTo. The tools are for the page's own origin. Cross-origin exposure is a decision to make deliberately; Chrome's guidance is to expose tools only to origins you trust.
  • No write tools. A blog has little worth doing on a reader's behalf, so there was nothing consequential to guard.

A Checklist for Your Own Site

  1. Pick the three to five actions a user does most. Each one is a tool. Stay well under the 40-tool Lighthouse warning
  2. Reuse the functions your UI already calls, so the tool and the page cannot disagree
  3. Write descriptions an agent can act on: what it does, what comes back, what the arguments mean
  4. Keep outputs short and page anything long
  5. Set readOnlyHint on anything that only reads, untrustedContentHint where the output is content, and consequentialHint on anything with real-world effects
  6. Look for document.modelContext first, fall back to navigator.modelContext, and do nothing when neither exists
  7. Register with an AbortSignal and abort it when the tools stop being relevant
  8. Test by stubbing the registry and calling execute directly, then check the page with Lighthouse's "Registered WebMCP tools" audit
  9. Check the origin trial status before launch

The Short Version

  • WebMCP lets a page register named, schema-described tools that browser agents call directly
  • It is a draft Community Group specification and a Chrome origin trial, so expect change. It already moved from navigator to document
  • The imperative API is a few lines: registerTool with a name, description, schema and an execute function
  • Mark tools honestly with annotations, keep outputs short, and register only what is relevant
  • My blog exposes four read-only tools in about 100 lines, reusing code the site already had

Sources

  • WebMCP early preview announcement, Chrome for Developers, 10 February 2026
  • WebMCP and AI agents, Chrome for Developers
  • Imperative API, Chrome for Developers, updated 21 September 2026
  • WebMCP tool security, Chrome for Developers, updated 1 September 2026
  • Registered WebMCP tools (Lighthouse), updated 21 September 2026
  • WebMCP specification, W3C Web Machine Learning Community Group, draft dated 30 September 2026
  1. AJ's Tech Notes
  2. WebMCP Explained: Make Your Website Callable by AI Agents