Examples
WebMCP examples: copy-paste tools by job
On this page
These WebMCP examples cover six common jobs: site search, a contact or booking form, product lookup, order status, add to cart and docs search. Each one shows a form tool (HTML attributes), a script tool (document.modelContext.registerTool()), or both. All code follows the draft spec of 20 October 2026 and Chrome’s current docs.
If you are new to WebMCP, start with how to use WebMCP. It explains each field step by step.
The shared setup
Every script tool below uses the same helper. It checks for WebMCP, registers the tool, and catches errors. One AbortController lets you remove all the tools at once.
const webmcp = new AbortController();
async function addTool(tool) {
if (!document.modelContext?.registerTool) return; // No WebMCP in this browser.
try {
await document.modelContext.registerTool(tool, { signal: webmcp.signal });
} catch (err) {
console.warn(`WebMCP tool "${tool.name}" not registered:`, err.name, err.message);
}
}
// To remove every tool later: webmcp.abort();
The /api/... paths in the examples are placeholders. Point them at your own endpoints.
Site search
A search form is the easiest tool to add. As a form tool:
<form action="/search" method="get"
toolname="search_site"
tooldescription="Search this site by keyword and open the results page."
toolautosubmit>
<label for="site-q">Search</label>
<input id="site-q" name="q" type="search" required
toolparamdescription="Words to search for, for example 'shipping times'.">
<button type="submit">Search</button>
</form>
toolautosubmit is safe here because searching changes nothing. Because the form submits itself, you can also return a result to the agent. Handle the submit event, call preventDefault() first, then respondWith():
const search = document.querySelector('form[toolname="search_site"]');
search.addEventListener('submit', (event) => {
if (!event.agentInvoked) return; // Submitted by a person: normal behaviour.
event.preventDefault();
const q = new FormData(search).get('q');
event.respondWith(
fetch(`/api/search?q=${encodeURIComponent(q)}`)
.then((res) => (res.ok ? res.json() : `Search failed with status ${res.status}.`)),
);
});
As a script tool that returns results to the agent:
addTool({
name: 'search_site',
title: 'Search the site',
description: 'Search all pages on this site by keyword. Returns up to 10 matches, each with a title and URL.',
inputSchema: {
type: 'object',
properties: { query: { type: 'string', description: 'Words to search for.' } },
required: ['query'],
},
annotations: { readOnlyHint: true },
async execute({ query }, { signal }) {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal });
if (!res.ok) throw new Error(`Search failed with status ${res.status}`);
return (await res.json()).slice(0, 10);
},
});
Use one or the other on a page, not both. Never give two tools the same name: registerTool() rejects a duplicate name in the same document.
Contact or booking form
Leave out toolautosubmit on forms that send something. The agent fills the fields, and the person checks them and presses submit.
<form action="/contact" method="post"
toolname="send_contact_request"
tooldescription="Fill in a contact request to our team. The visitor reviews it and presses Send.">
<label for="c-name">Your name</label>
<input id="c-name" name="name" required>
<label for="c-email">Email</label>
<input id="c-email" name="email" type="email" required
toolparamdescription="Email address where the team should reply.">
<label for="c-date">Preferred date</label>
<input id="c-date" name="date" type="date"
toolparamdescription="Preferred date for a call, if any.">
<label for="c-msg">Message</label>
<textarea id="c-msg" name="message" required
toolparamdescription="What the visitor needs help with, in their own words."></textarea>
<button type="submit">Send</button>
</form>
The name field has no toolparamdescription, so Chrome uses its <label> instead. Every field has a name, which Lighthouse checks.
Product lookup
A read-only tool that returns one product:
addTool({
name: 'get_product_details',
title: 'Get product details',
description: 'Get the name, price, stock status and URL of one product by its SKU.',
inputSchema: {
type: 'object',
properties: { sku: { type: 'string', description: 'Product SKU, for example "TS-001-BLK".' } },
required: ['sku'],
},
annotations: { readOnlyHint: true },
async execute({ sku }, { signal }) {
const res = await fetch(`/api/products/${encodeURIComponent(sku)}`, { signal });
if (res.status === 404) return { found: false, sku };
if (!res.ok) throw new Error(`Lookup failed with status ${res.status}`);
const p = await res.json();
return { found: true, name: p.name, price: p.price, inStock: p.inStock, url: p.url };
},
});
If your store runs on Shopify Liquid, check first. Shopify already registers get_product and nine other tools on every Liquid storefront.
Order status (read-only)
This tool reads the signed-in shopper’s own order. WebMCP tools run in the page, so the request carries the user’s session cookie. The tool never asks for a password or login.
addTool({
name: 'get_order_status',
title: 'Get order status',
description: "Get the status and tracking link of one of the signed-in customer's orders. Returns an error if nobody is signed in.",
inputSchema: {
type: 'object',
properties: { orderNumber: { type: 'string', description: 'Order number from the confirmation email, for example "10234".' } },
required: ['orderNumber'],
},
annotations: { readOnlyHint: true },
async execute({ orderNumber }, { signal }) {
const res = await fetch(`/api/account/orders/${encodeURIComponent(orderNumber)}`, {
signal,
credentials: 'same-origin',
});
if (res.status === 401) return { error: 'Not signed in. Ask the user to sign in first.' };
if (!res.ok) throw new Error(`Lookup failed with status ${res.status}`);
const o = await res.json();
return { status: o.status, trackingUrl: o.trackingUrl };
},
});
Add to cart
Adding to a cart changes state, so set consequentialHint: true. Chrome says this lets the agent or browser ask the user to confirm first.
addTool({
name: 'add_to_cart',
title: 'Add to cart',
description: 'Add a product to the shopping cart. Does not check out or charge anything.',
inputSchema: {
type: 'object',
properties: {
sku: { type: 'string', description: 'Product SKU to add.' },
quantity: { type: 'integer', minimum: 1, maximum: 10, description: 'How many to add. Defaults to 1.' },
},
required: ['sku'],
},
annotations: { consequentialHint: true },
async execute({ sku, quantity = 1 }, { signal }) {
const res = await fetch('/api/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku, quantity }),
signal,
});
if (!res.ok) throw new Error(`Add to cart failed with status ${res.status}`);
const cart = await res.json();
return { added: { sku, quantity }, itemCount: cart.itemCount };
},
});
The description says what the tool does not do. The spec warns that agents cannot check a tool’s behaviour before calling it, so be exact.
Docs search
A docs search that also returns community forum answers. Those answers are written by other people, so mark them with untrustedContentHint: true.
addTool({
name: 'docs.search',
title: 'Search the docs',
description: 'Search the product documentation and community forum. Returns up to 5 results with a title, URL, short excerpt and source.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'What to look up, for example "reset API key".' },
includeForum: { type: 'boolean', description: 'Also search community forum answers. Defaults to true.' },
},
required: ['query'],
},
annotations: { readOnlyHint: true, untrustedContentHint: true },
async execute({ query, includeForum = true }, { signal }) {
const url = `/api/docs-search?q=${encodeURIComponent(query)}&forum=${includeForum}`;
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`Docs search failed with status ${res.status}`);
return (await res.json()).slice(0, 5);
},
});
Dots are allowed in names, so docs.search is valid. You can use them to group related tools.
What makes a good tool definition
- A valid, clear name. 1 to 128 characters, using only ASCII letters, digits,
_,-and.. Use a verb and a noun, likeget_order_status. - A description that says what happens and what comes back. It is required and cannot be empty.
- A
titlefor people, as a top-level field on the tool. - A description on every input, in
inputSchemaor withtoolparamdescription. - Few inputs. Ask only for what the action needs. The spec warns that extra inputs can leak private data.
- The right annotations:
readOnlyHintfor reads,consequentialHintfor actions that matter,untrustedContentHintfor content you did not write. - A signal in
execute, passed on tofetch, so cancelled calls stop. - A sensible number of tools. Chrome sets no maximum, but Lighthouse warns when a page registers more than 40.
Common mistakes
- Invalid names. Spaces or other characters outside
[A-Za-z0-9_.-], or more than 128 characters.registerTool()rejects withInvalidStateError. - Duplicate names. Registering the same name twice in one document rejects with
InvalidStateError. This often happens when a script runs twice, or when you copy a tool your platform already registers. - A missing or empty description. An empty description rejects with
InvalidStateError; leavingnameordescriptionout rejects with aTypeError. For a form, bothtoolnameandtooldescriptionare needed. - Asking for passwords in tool inputs. The tool already runs in the user’s session. Never put credentials in a schema.
- Not handling the Promise.
registerTool()returns a Promise. If you do notawaitand catch it, a failed registration becomes an unhandled rejection. - Using removed APIs.
unregisterTool(),provideContext()andclearContext()are not in the current spec. Use anAbortControllersignal to remove tools. - Using
navigatorinstead ofdocument. The entry point isdocument.modelContext. - Putting
titleorexposedToin the wrong place.titlegoes on the tool, not inannotations.exposedTogoes in the options argument, not in the tool. - Undocumented attributes.
tooltitleandtoolparamtitleappear in some tutorials, but no primary source documents them. Chrome documents onlytoolname,tooldescription,toolparamdescriptionandtoolautosubmit.
To check which form tools your own pages declare today, try the free checker. For the full history of API changes, see the WebMCP spec tracker.
Questions people ask
Can I paste these WebMCP examples straight into my site?
Yes, after you change the endpoints, names and descriptions to match your site. The /api paths in the examples are placeholders. Test each tool in the DevTools WebMCP pane with chrome://flags/#enable-webmcp-testing turned on.
Why use document.modelContext and not navigator.modelContext?
The spec moved the API from navigator to Document on 19 May 2026 (#177). Chrome's imperative-API docs use only document.modelContext.
Are the HTML form examples part of the spec?
Not right now. The declarative API was removed from the spec for now on 8 October 2026 (#338). It still lives in the declarative explainer and in Chrome's implementation.
How do I see which WebMCP tools a page has?
Open DevTools, go to the Application panel and open the WebMCP pane. From code, document.modelContext.getTools() returns the registered tools, in alphabetical order in Chrome.
Sources
- WebMCP, Draft Community Group Report, 20 October 2026 (read 2026-10-23)
- WebMCP declarative API explainer (read 2026-10-23)
- WebMCP commit history (read 2026-10-23)
- WebMCP pull requests: navigator to Document (#177) (read 2026-10-23)
- WebMCP pull requests: unregisterTool removal (#156) (read 2026-10-23)
- WebMCP pull request #132: remove provideContext and clearContext (read 2026-10-23)
- Chrome for Developers: WebMCP imperative API (read 2026-10-23)
- Chrome for Developers: WebMCP declarative API (read 2026-10-23)
- Chrome for Developers: Secure your WebMCP tools (read 2026-10-23)
- Chrome for Developers: WebMCP best practices (read 2026-10-23)
- Chrome DevTools: WebMCP pane (read 2026-10-23)
- Lighthouse: Registered WebMCP tools audit (read 2026-10-23)