{"id":"535deae6-0708-499d-bc79-f3edf64b8b5e","title":"WebMCP Explained: Make Your Website Callable by AI Agents","content":"# WebMCP Explained: Make Your Website Callable by AI Agents\n\nMeta Description: 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.\n\nAn 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.\n\nI added WebMCP to this blog, [blog.ajithjoseph.com](https://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.\n\n## What WebMCP Is, and What It Is Not\n\nChrome'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.\n\nTwo things it is not:\n\n- **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.\n- **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.\n\nThat 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.\n\n## Where It Stands in October 2026\n\nThe facts below come from the standard's draft and Chrome's own documentation. Dates are the pages' own.\n\n- **Chrome announced it in February 2026.** The Chrome blog post of 10 February 2026 introduced WebMCP and opened an early preview programme.\n- **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.\n- **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()`.\n- **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.\n- **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.\n- **There are security hints.** Chrome's guidance for authors, updated 1 September 2026, defines three annotations and recommends keeping descriptions and outputs short.\n\n## The Two APIs\n\n**Imperative.** You call `registerTool` from JavaScript. This is for dynamic work: search, filtering, navigation, anything that needs code.\n\n**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.\n\n### The Tool Shape\n\nA tool has these fields, per Chrome's reference and the draft:\n\n| Field | Required | Meaning |\n| --- | --- | --- |\n| `name` | yes | Identifier |\n| `description` | yes | What it does, in plain language |\n| `execute` | yes | Async function; receives the input object and an options object with an `AbortSignal` |\n| `inputSchema` | usually | JSON Schema for the input |\n| `annotations` | no | `readOnlyHint`, `untrustedContentHint`, `consequentialHint` (and a `debugging` hint in newer Chrome) |\n| `exposedTo` | no | Origins allowed to use the tool across frames |\n\nPassing `{ 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.\n\n## What I Built for This Blog\n\nA blog has four natural actions, so I registered four tools and no more:\n\n| Tool | What it does | Annotations |\n| --- | --- | --- |\n| `search_articles` | Search by query, up to 5 results | read-only, untrusted content |\n| `list_latest_articles` | Newest articles, up to 5 | read-only, untrusted content |\n| `get_article` | Read one article in chunks | read-only, untrusted content |\n| `open_article` | Navigate the browser to an article | none |\n\nI 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.\n\n### Step 1: Reuse the Functions the Site Already Has\n\nThe 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:\n\n```typescript\nimport { fetchPost, fetchPosts, searchPosts } from './api';\n```\n\n### Step 2: Write the Tools\n\nThis is the whole file as it ships. It is about 100 lines, and the comments explain the choices.\n\n```typescript\nimport { fetchPost, fetchPosts, searchPosts } from './api';\nimport type { Post } from '../types';\n\n// WebMCP (https://webmachinelearning.github.io/webmcp/): lets a browser agent call these\n// site actions directly instead of scraping the page. Read-only apart from navigation.\n\ninterface ToolResult {\n  content: { type: 'text'; text: string }[];\n}\n\ninterface ModelContextTool {\n  name: string;\n  description: string;\n  inputSchema: object;\n  annotations?: { readOnlyHint?: boolean; untrustedContentHint?: boolean; consequentialHint?: boolean };\n  execute: (input: Record<string, unknown>) => Promise<ToolResult>;\n}\n\ninterface ModelContext {\n  registerTool: (tool: ModelContextTool, options?: { signal?: AbortSignal }) => unknown;\n}\n\n// WebMCP moved from navigator.modelContext to document.modelContext; browsers and the inspector\n// extension expose one or the other, so look at both.\ndeclare global {\n  interface Navigator { modelContext?: ModelContext }\n  interface Document { modelContext?: ModelContext }\n}\n\nconst postUrl = (p: Post) => `${window.location.origin}/blog/${p.id}/${p.slug}`;\n// Chrome's guidance for tool authors is to keep one output under about 1.5K characters, so results\n// are short summaries and article text is paged.\nconst clip = (s: string, max: number) => (s.length > max ? s.slice(0, max - 1).trimEnd() + '…' : s);\nconst summary = (p: Post) => ({ id: p.id, title: p.title, summary: clip(p.excerpt ?? '', 140), url: postUrl(p) });\nconst ARTICLE_CHUNK = 1200;\nconst text = (value: unknown): ToolResult => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });\n\nconst tools: ModelContextTool[] = [\n  {\n    name: 'search_articles',\n    description: 'Search the blog for articles matching a query. Returns up to 5 matches with id, title, summary and url.',\n    inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Words to search for' } }, required: ['query'] },\n    annotations: { readOnlyHint: true, untrustedContentHint: true },\n    execute: async ({ query }) => text((await searchPosts(String(query))).slice(0, 5).map(summary)),\n  },\n  {\n    name: 'list_latest_articles',\n    description: 'List the newest articles, newest first.',\n    inputSchema: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 5, default: 5 } } },\n    annotations: { readOnlyHint: true, untrustedContentHint: true },\n    execute: async ({ limit }) => {\n      const pageSize = Math.min(Math.max(Number(limit) || 5, 1), 5);\n      return text((await fetchPosts({ page: 1, pageSize })).items.map(summary));\n    },\n  },\n  {\n    name: 'get_article',\n    description: 'Read an article as Markdown, one chunk at a time. Pass nextOffset from the previous result to continue.',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        id: { type: 'string', description: 'Article id from a search or list result' },\n        offset: { type: 'integer', minimum: 0, default: 0, description: 'Character offset to start from' },\n      },\n      required: ['id'],\n    },\n    annotations: { readOnlyHint: true, untrustedContentHint: true },\n    execute: async ({ id, offset }) => {\n      const p = await fetchPost(String(id));\n      const start = Math.max(Number(offset) || 0, 0);\n      const end = start + ARTICLE_CHUNK;\n      return text({\n        title: p.title,\n        url: postUrl(p),\n        published: p.created_at,\n        totalChars: p.content.length,\n        markdown: p.content.slice(start, end),\n        nextOffset: end < p.content.length ? end : null,\n      });\n    },\n  },\n  {\n    name: 'open_article',\n    description: 'Navigate the browser to an article so the user can read it.',\n    inputSchema: { type: 'object', properties: { id: { type: 'string' }, slug: { type: 'string' } }, required: ['id', 'slug'] },\n    execute: async ({ id, slug }) => {\n      const url = `/blog/${encodeURIComponent(String(id))}/${encodeURIComponent(String(slug))}`;\n      window.location.assign(url);\n      return text({ opened: window.location.origin + url });\n    },\n  },\n];\n\n/** Registers the tools when the browser supports WebMCP; they are removed again when the page is left. */\nexport function registerWebMcpTools(): void {\n  const modelContext = document.modelContext ?? navigator.modelContext;\n  if (typeof modelContext?.registerTool !== 'function') return;\n\n  const controller = new AbortController();\n  for (const tool of tools) modelContext.registerTool(tool, { signal: controller.signal });\n  window.addEventListener('pagehide', () => controller.abort(), { once: true });\n}\n```\n\nA few decisions in there are worth explaining.\n\n**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.\n\n**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.\n\n**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.\n\n### Step 3: Register Once, Feature-Detect, Clean Up\n\nThe registration function is the last twelve lines of the file:\n\n```typescript\nexport function registerWebMcpTools(): void {\n  const modelContext = document.modelContext ?? navigator.modelContext;\n  if (typeof modelContext?.registerTool !== 'function') return;\n\n  const controller = new AbortController();\n  for (const tool of tools) modelContext.registerTool(tool, { signal: controller.signal });\n  window.addEventListener('pagehide', () => controller.abort(), { once: true });\n}\n```\n\nThree 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.\n\nIt runs once, at startup, in `main.tsx`:\n\n```typescript\nimport { registerWebMcpTools } from './lib/webmcp';\n\nregisterWebMcpTools();\n```\n\nThis 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.\n\n## The Bug That Hid My Tools\n\nMy first version only looked at `navigator.modelContext`. The inspector extension showed this:\n\n```text\nWebMCP Tools\nNo tools registered yet in https://blog.ajithjoseph.com/\n```\n\nThe 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.\n\nThe 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.\n\n## Testing It Without Waiting for Agents\n\nYou 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:\n\n```javascript\nconst reg = [];\nObject.defineProperty(document, 'modelContext', {\n  configurable: true,\n  value: { registerTool: (tool, options) => reg.push({ tool, options }) },\n});\n\nconst m = await import('/src/lib/webmcp.ts');\nm.registerWebMcpTools();\n\nconst run = async (name, args) =>\n  (await reg.find(r => r.tool.name === name).tool.execute(args)).content[0].text;\n\nconsole.log(reg.map(r => r.tool.name));\nconsole.log(await run('search_articles', { query: 'copilot studio credits' }));\n```\n\nThat 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.\n\n## Real Outputs\n\nThese are from my dev server, so the host in the URLs is `localhost`. On the live site it is `blog.ajithjoseph.com`.\n\nThe tools after registration:\n\n```text\n[\"search_articles\", \"list_latest_articles\", \"get_article\", \"open_article\"]\n```\n\n`search_articles` with `{ \"query\": \"copilot studio credits\" }` returned one match, 396 characters:\n\n```json\n[{\"id\":\"bce6eb75-3b04-4c9a-835a-9618a50a9ef9\",\n  \"title\":\"Copilot Studio Credits: Estimate, Cap and Avoid Surprises\",\n  \"summary\":\"How Copilot Studio credits are billed in October 2026: the rate table, a tested calculator, the enforcement rules, and the controls that st…\",\n  \"url\":\"http://localhost:5173/blog/bce6eb75-3b04-4c9a-835a-9618a50a9ef9/copilot-studio-credits-estimate-cap-and-avoid-surprises\"}]\n```\n\n`list_latest_articles` with `{ \"limit\": 2 }` returned two items, 782 characters in total, newest first (abbreviated here):\n\n```json\n[{\"id\":\"79c8ccfd-e436-460c-877f-7da2a2338441\",\n  \"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-…\"},\n {\"id\":\"bce6eb75-3b04-4c9a-835a-9618a50a9ef9\",\n  \"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-…\"}]\n```\n\n`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:\n\n```json\n{\"title\":\"Standard or GitHub Copilot Harness? A Decision Guide\",\n \"url\":\"http://localhost:5173/blog/79c8ccfd-…\",\n \"published\":\"2026-10-01T17:54:00.89\",\n \"totalChars\":14279,\n \"markdown\":\"# Standard or GitHub Copilot Harness? A Decision Guide\\n\\nMeta Description: Copilot Studio now runs agents on three harnesses. …\",\n \"nextOffset\":1200}\n```\n\nCalling 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.\n\n## How an Agent Uses It\n\nWith the tools registered, \"find me this blog's post on Copilot Studio billing and summarise the enforcement rules\" becomes three calls and no scraping:\n\n1. `search_articles({ query: \"copilot studio billing\" })` returns candidates with ids\n2. `get_article({ id })` returns the first 1,200 characters and `nextOffset`\n3. `get_article({ id, offset: 1200 })`, repeated until `nextOffset` is `null`\n\nCompare 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.\n\n## What I Have Not Done\n\nBeing straight about the gaps is more useful than a clean demo.\n\n- **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.\n- **No declarative forms.** I skipped the form attributes because the spec section is unfinished.\n- **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.\n- **No write tools.** A blog has little worth doing on a reader's behalf, so there was nothing consequential to guard.\n\n## A Checklist for Your Own Site\n\n1. Pick the three to five actions a user does most. Each one is a tool. Stay well under the 40-tool Lighthouse warning\n2. Reuse the functions your UI already calls, so the tool and the page cannot disagree\n3. Write descriptions an agent can act on: what it does, what comes back, what the arguments mean\n4. Keep outputs short and page anything long\n5. Set `readOnlyHint` on anything that only reads, `untrustedContentHint` where the output is content, and `consequentialHint` on anything with real-world effects\n6. Look for `document.modelContext` first, fall back to `navigator.modelContext`, and do nothing when neither exists\n7. Register with an `AbortSignal` and abort it when the tools stop being relevant\n8. Test by stubbing the registry and calling `execute` directly, then check the page with Lighthouse's \"Registered WebMCP tools\" audit\n9. Check the origin trial status before launch\n\n## The Short Version\n\n- WebMCP lets a page register named, schema-described tools that browser agents call directly\n- It is a draft Community Group specification and a Chrome origin trial, so expect change. It already moved from `navigator` to `document`\n- The imperative API is a few lines: `registerTool` with a name, description, schema and an `execute` function\n- Mark tools honestly with annotations, keep outputs short, and register only what is relevant\n- My blog exposes four read-only tools in about 100 lines, reusing code the site already had\n\n## Sources\n\n- [WebMCP early preview announcement](https://developer.chrome.com/blog/webmcp-epp), Chrome for Developers, 10 February 2026\n- [WebMCP and AI agents](https://developer.chrome.com/docs/ai/agents), Chrome for Developers\n- [Imperative API](https://developer.chrome.com/docs/ai/webmcp/imperative-api), Chrome for Developers, updated 21 September 2026\n- [WebMCP tool security](https://developer.chrome.com/docs/ai/webmcp/secure-tools), Chrome for Developers, updated 1 September 2026\n- [Registered WebMCP tools (Lighthouse)](https://developer.chrome.com/docs/lighthouse/agentic-browsing/registered-webmcp-tools), updated 21 September 2026\n- [WebMCP specification](https://webmachinelearning.github.io/webmcp/), W3C Web Machine Learning Community Group, draft dated 30 September 2026\n","excerpt":"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.","slug":"webmcp-explained-make-your-website-callable-by-ai-agents","authorId":"1","author":{"id":"1","username":"ajith","email":"contact@ajithjoseph.com","name":"Ajith Joseph","bio":"AI Lead Engineer in Bengaluru with 13+ years in software. Builds production AI agent systems and writes about Copilot Studio, MCP, .NET and React.","avatarUrl":"images/users/ajith.jpg","createdAt":"2025-03-02T00:00:00"},"createdAt":"2026-10-02T18:51:57.377","updatedAt":"2026-10-02T18:51:57.377","likesCount":0,"commentsCount":0,"featured":true,"tags":[{"id":13,"name":"web-development"},{"id":158,"name":"mcp"},{"id":168,"name":"webmcp"},{"id":169,"name":"ai-agents"},{"id":170,"name":"chrome"}],"readingTimeMinutes":15,"difficultyLevel":"intermediate"}