Skip to main content

How to Add WebMCP Tools to Your Website

Short answer

Add a tool to your page by calling document.modelContext.registerTool() with a name, a description, a JSON Schema for the inputs and an execute function. Pass an AbortSignal in the options to remove the tool later, because there is no unregisterTool() method. WebMCP is a draft, and in Chrome it is in an origin trial from Chrome 149 or behind the enable-webmcp-testing flag. Check for document.modelContext before you register anything, so the page still works in browsers without it.

Tested withWebMCP draft, W3C Web Machine Learning Community Group, 3 September 2026Chrome 149 or later, origin trial or the enable-webmcp-testing flagNode.js 22.16 (test script)Checked 20 Oct 2026

What you'll have at the end​

A static page for a fictional coffee shop. It has four tools that a Chrome agent can see and call: search_menu, add_to_cart, get_order and place_order. This is the output we got when we ran the snippet below in Chrome, against the page:

const tools = await document.modelContext.getTools();
tools.map((tool) => tool.name);
["add_to_cart", "get_order", "place_order", "search_menu"]

Chrome returns tools sorted by name. The agent never sees your HTML or your JavaScript. It sees the tool list, and it calls a tool, and your execute function runs in the page.

Before you start

  • Chrome 149 or later with WebMCP available. Either sign up for the origin trial, or open chrome://flags/#enable-webmcp-testing, set it to Enabled and relaunch Chrome. The flag is for local development.
  • Node.js 22 for the test script. We ran it on 22.16. The page itself needs no build step.
  • Python 3, or any static file server, to serve the page on localhost.
  • Basic HTML and JavaScript.

1. Learn the current API​

WebMCP is a draft from the W3C Web Machine Learning Community Group. The draft is a Community Group report, not a W3C Standard. Chrome describes it as "a proposed web standard" and runs an origin trial for it from Chrome 149.

The draft puts the API on document:

MemberWhat it does
document.modelContextThe entry point. Only available in a secure context (https:// or localhost).
registerTool(tool, options)Adds a tool. Returns a promise that rejects if the name is already taken, if name or description is empty, or if inputSchema is invalid.
getTools(options)Lists the tools the page has registered. Chrome sorts them by name.
executeTool(tool, input, options)Runs a tool from getTools(). The promise resolves to the result as a string.
toolchange eventFires on document.modelContext when the list of tools changes.

The options argument to registerTool() takes a signal. When that signal is aborted, the tool is removed. Chrome's docs say you remove a tool with AbortSignal, and the draft defines no unregisterTool() method.

You may find older examples that use navigator.modelContext and provideContext(). Those come from the early proposal. Chromium removed navigator.modelContext in favour of document.modelContext in this commit, and the draft no longer defines provideContext(). Use document.modelContext only.

Each tool is an object with these fields:

FieldRequiredNotes
nameYesUnique on the page. Use letters, digits and underscores.
descriptionYesTells the agent when to use the tool. Write it for the agent.
inputSchemaNoA JSON Schema object for the input. Describe each property.
executeYesasync (input, options) => result. options.signal is aborted if the agent cancels.
annotationsNoreadOnlyHint, consequentialHint, untrustedContentHint, and debugging (Chrome 156 or later). Each defaults to false.

Chrome's docs describe consequentialHint as the flag for actions that are significant or can't be undone, such as booking a flight. It lets the browser ask the user to confirm first.

The draft also has an HTML form version, which Chrome documents as the declarative API. Forms use the attributes toolname, tooldescription, toolparamdescription and toolautosubmit. The draft's declarative section is still a placeholder, so this guide covers only the JavaScript API.

2. Write the page​

Make a folder called acme-coffee with two files. The page holds a menu and an order list. The data is fictional, and nothing leaves the page.

index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acme Coffee Shop</title>
</head>
<body>
<h1>Acme Coffee Shop</h1>
<p id="webmcp-status">Checking for WebMCP support...</p>

<h2>Menu</h2>
<ul id="menu"></ul>

<h2>Your order</h2>
<ul id="cart"></ul>
<p>Total: $<span id="total">0.00</span></p>
<button id="place-order" type="button">Place order</button>
<p id="order-result"></p>

<p>
<button id="toggle-tools" type="button">Turn off agent tools</button>
</p>

<script src="tools.js"></script>
</body>
</html>

The script tag loads tools.js after the page is in place, so the script can find the elements it needs.

3. Register the tools​

Put this in tools.js next to index.html:

tools.js
// Acme Coffee Shop: a demo page that exposes four WebMCP tools.
// All data is fictional and stays in the page. Nothing is sent over the network.

const MENU = [
{ id: 'espresso', name: 'Espresso', price: 2.5 },
{ id: 'flat-white', name: 'Flat white', price: 3.2 },
{ id: 'cold-brew', name: 'Cold brew', price: 3.8 },
{ id: 'croissant', name: 'Croissant', price: 2.9 },
];

const cart = new Map();
let registration = null;

function byId(id) {
return document.getElementById(id);
}

function cartSummary() {
const items = [];
let total = 0;
for (const [itemId, quantity] of cart) {
const item = MENU.find((entry) => entry.id === itemId);
const lineTotal = Math.round(item.price * quantity * 100) / 100;
total += lineTotal;
items.push({ itemId, name: item.name, quantity, lineTotal });
}
return { items, total: Math.round(total * 100) / 100 };
}

function renderMenu() {
byId('menu').replaceChildren(
...MENU.map((item) => {
const li = document.createElement('li');
li.textContent = `${item.name} ($${item.price.toFixed(2)})`;
return li;
}),
);
}

function renderCart() {
const summary = cartSummary();
byId('cart').replaceChildren(
...summary.items.map((line) => {
const li = document.createElement('li');
li.textContent = `${line.quantity} x ${line.name} ($${line.lineTotal.toFixed(2)})`;
return li;
}),
);
byId('total').textContent = summary.total.toFixed(2);
}

async function searchMenu({ query = '' } = {}) {
const needle = String(query).trim().toLowerCase();
return MENU.filter((item) => item.name.toLowerCase().includes(needle));
}

async function addToCart({ itemId, quantity = 1 }) {
const item = MENU.find((entry) => entry.id === itemId);
if (!item) {
throw new Error(`Unknown menu item: ${itemId}`);
}
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 10) {
throw new Error('quantity must be a whole number from 1 to 10');
}
cart.set(itemId, (cart.get(itemId) ?? 0) + quantity);
renderCart();
return { added: item.name, quantity, order: cartSummary() };
}

async function getOrder() {
return cartSummary();
}

async function placeOrder() {
if (cart.size === 0) {
throw new Error('The order is empty. Add an item with add_to_cart first.');
}
const summary = cartSummary();
cart.clear();
renderCart();
byId('order-result').textContent =
`Order placed for $${summary.total.toFixed(2)}. (Demo only: nothing was charged.)`;
return { placed: true, total: summary.total };
}

function toolDefinitions() {
return [
{
name: 'search_menu',
description:
'Search the Acme Coffee Shop menu by item name. Returns matching items with their ids and prices in dollars. Leave the query empty to list the whole menu.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Text to look for in item names. Not case-sensitive.',
},
},
},
annotations: { readOnlyHint: true },
execute: searchMenu,
},
{
name: 'add_to_cart',
description:
'Add one menu item to the order. Use an itemId returned by search_menu. Quantity must be a whole number from 1 to 10.',
inputSchema: {
type: 'object',
properties: {
itemId: {
type: 'string',
enum: MENU.map((item) => item.id),
description: 'Menu item id from search_menu.',
},
quantity: {
type: 'integer',
minimum: 1,
maximum: 10,
description: 'How many to add. Defaults to 1.',
},
},
required: ['itemId'],
},
execute: addToCart,
},
{
name: 'get_order',
description:
'Return the items currently in the order, with line totals and the order total in dollars.',
inputSchema: { type: 'object', properties: {} },
annotations: { readOnlyHint: true },
execute: getOrder,
},
{
name: 'place_order',
description:
'Place the order that is currently in the cart. This demo page only shows a confirmation and charges nothing.',
inputSchema: { type: 'object', properties: {} },
annotations: { consequentialHint: true },
execute: placeOrder,
},
];
}

async function registerTools() {
registration = new AbortController();
const { signal } = registration;
await Promise.all(
toolDefinitions().map((tool) => document.modelContext.registerTool(tool, { signal })),
);
byId('webmcp-status').textContent =
'Agent tools are on: search_menu, add_to_cart, get_order, place_order.';
byId('toggle-tools').textContent = 'Turn off agent tools';
}

function unregisterTools() {
registration.abort();
registration = null;
byId('webmcp-status').textContent = 'Agent tools are off. The page still works normally.';
byId('toggle-tools').textContent = 'Turn on agent tools';
}

async function setUpTools() {
const supported = window.isSecureContext && 'modelContext' in document;
if (!supported) {
byId('webmcp-status').textContent =
'This browser does not support WebMCP yet, so no agent tools are available. The page still works normally.';
byId('toggle-tools').hidden = true;
return;
}
try {
await registerTools();
} catch (error) {
registration?.abort();
registration = null;
byId('webmcp-status').textContent = `Could not register agent tools: ${error.message}`;
}
}

byId('toggle-tools').addEventListener('click', () => {
if (registration) {
unregisterTools();
return;
}
registerTools().catch((error) => {
registration?.abort();
registration = null;
byId('webmcp-status').textContent = `Could not register agent tools: ${error.message}`;
});
});

byId('place-order').addEventListener('click', () => {
placeOrder().catch((error) => {
byId('order-result').textContent = error.message;
});
});

renderMenu();
renderCart();
setUpTools();

Here is what matters in the script:

  • Feature detection. window.isSecureContext && 'modelContext' in document decides whether to register anything. If it's false, the page shows a message and carries on as a normal page. Without the check, document.modelContext.registerTool would throw in every other browser.
  • One signal for all tools. registration is an AbortController. Every tool gets its signal, so registration.abort() removes all four at once. The "Turn off agent tools" button does exactly that.
  • Validation in execute. The schema describes the input, but it can't express every rule your page needs. add_to_cart still checks that itemId is in the menu and that quantity is a whole number from 1 to 10.
  • Errors. When execute throws, the call fails. Throw an Error with a message the agent can act on, such as "The order is empty. Add an item with add_to_cart first."
  • Annotations. search_menu and get_order are readOnlyHint: true. place_order is consequentialHint: true, so a browser agent should ask the user before it runs.

4. Test it without a browser​

Chrome's WebMCP support isn't available on every machine, so the test script checks the page logic on its own. It parses tools.js with node --check, then runs the script in Node against a fake DOM and a mock document.modelContext. The mock follows the draft's rules: duplicate names are rejected, and aborting the signal removes the tool.

The test script is test.sh in the example folder. From the root of the repository, run:

bash docs/guides-examples/add-webmcp-to-your-site/test.sh

The output we got on Node 22.16:

ok: page/tools.js parses
ok: unsupported browser shows a plain page
ok: four tools registered with the expected schemas and annotations
ok: tool execution, validation and cart state
ok: aborting the signal unregisters tools and turning them back on re-registers
ok: a refused registration reports the error and leaves no tools behind
All page checks passed

The mock is a stand-in. It can't tell you whether Chrome accepts your schema or shows the tools to an agent. For that, run the page in Chrome (step 5).

5. Run it in Chrome​

Serve the folder on localhost, because document.modelContext is only available in a secure context. A file opened from disk won't have it.

cd acme-coffee
python3 -m http.server 8080

Open http://localhost:8080 in Chrome 149 or later, with the origin trial or the flag enabled. The status line under the heading should say "Agent tools are on: search_menu, add_to_cart, get_order, place_order."

To check the tools from the page, open the developer console and run:

const tools = await document.modelContext.getTools();
const addToCart = tools.find((tool) => tool.name === 'add_to_cart');
const result = await document.modelContext.executeTool(addToCart, { itemId: 'espresso', quantity: 2 });
console.log(result);

executeTool() takes the tool object that getTools() returns, not the name. Its result is a string, so the console shows the JSON that add_to_cart returned. The cart on the page updates too.

Chrome DevTools has a WebMCP panel for inspecting, debugging and testing the tools a page registers, including their schemas. Chrome's debugging guide describes it.

Common problems​

document.modelContext is undefined. The flag is off or Chrome wasn't relaunched after you turned it on. Set chrome://flags/#enable-webmcp-testing to Enabled and restart Chrome. If you're using the origin trial, check that your Chrome version is 149 or later.

The page isn't in a secure context. document.modelContext only exists on https:// pages and on localhost. Serve the page with a local server, as in step 5, or deploy it over HTTPS.

registerTool() rejects. The most common causes are a tool name that is already registered, an empty name or description, or an inputSchema that isn't valid JSON Schema. Check the names in your page for duplicates. If you re-run the registration code after a hot reload, abort the old signal first.

An iframe on your page can't register tools. Tool registration is disabled in cross-origin iframes by default. Add allow="tools" to the <iframe> element to delegate the tools permission. Chrome's docs also say a tool is only visible to another origin when the tool's exposedTo option lists that origin, and the reading side lists the origin in fromOrigins.

The agent only sees tools while the page is open. That's by design for now. Chrome's docs say WebMCP is built for local browser workflows with a person present, and clients must visit a site to learn what tools it has.

FAQ​

Is WebMCP the same as MCP?

No. MCP is a protocol that AI apps use to talk to servers, and the servers can run anywhere. WebMCP lets a web page offer tools to an agent running in the browser, through a JavaScript API on the page. Both can exist on the same site. See WebMCP vs MCP.

Do I still need navigator.modelContext?

No. Chromium has removed navigator.modelContext. Use document.modelContext.registerTool() and document.modelContext.getTools() instead.

Does WebMCP work in Firefox and Safari?

Not yet, as far as the project's implementation status page shows. It lists Chrome and Edge origin trials, and it links only to standards-position discussions for Firefox and Safari. See WebMCP browser support.

Can an agent use my tools when the page is closed?

No. The tools are registered by the page, so the page has to be open in a browser the agent can reach. Chrome's docs say WebMCP is designed for local browser sessions with a person present.

Are WebMCP tools safe to expose?

Only if you design them carefully. Tool descriptions and tool results both reach the model, so a malicious page or a user-written comment can try to steer the agent. Mark consequential tools, and read WebMCP security before you ship.

  • What is WebMCP?The W3C proposal, the navigator and document API history, and where it stands today.
  • WebMCP vs MCPWhere each runs, who calls it, and when to use one or both.
  • WebMCP browser supportWhich browsers and agents support WebMCP today, and how to turn it on.
  • WebMCP securityInjection, over-privileged tools and cross-origin risks, with mitigations.
  • Build an MCP serverThe server-side version: tools that any MCP client can call.