Examples

WebMCP examples: copy-paste tools by job

Updated · Checked against the WebMCP draft of 20 October 2026 · By the Convertlyft research team

On this page
  1. The shared setup
  2. Site search
  3. Contact or booking form
  4. Product lookup
  5. Order status (read-only)
  6. Add to cart
  7. Docs search
  8. What makes a good tool definition
  9. Common mistakes

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.

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.

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, like get_order_status.
  • A description that says what happens and what comes back. It is required and cannot be empty.
  • A title for people, as a top-level field on the tool.
  • A description on every input, in inputSchema or with toolparamdescription.
  • Few inputs. Ask only for what the action needs. The spec warns that extra inputs can leak private data.
  • The right annotations: readOnlyHint for reads, consequentialHint for actions that matter, untrustedContentHint for content you did not write.
  • A signal in execute, passed on to fetch, 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 with InvalidStateError.
  • 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; leaving name or description out rejects with a TypeError. For a form, both toolname and tooldescription are 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 not await and catch it, a failed registration becomes an unhandled rejection.
  • Using removed APIs. unregisterTool(), provideContext() and clearContext() are not in the current spec. Use an AbortController signal to remove tools.
  • Using navigator instead of document. The entry point is document.modelContext.
  • Putting title or exposedTo in the wrong place. title goes on the tool, not in annotations. exposedTo goes in the options argument, not in the tool.
  • Undocumented attributes. tooltitle and toolparamtitle appear in some tutorials, but no primary source documents them. Chrome documents only toolname, tooldescription, toolparamdescription and toolautosubmit.

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

  1. WebMCP, Draft Community Group Report, 20 October 2026 (read 2026-10-23)
  2. WebMCP declarative API explainer (read 2026-10-23)
  3. WebMCP commit history (read 2026-10-23)
  4. WebMCP pull requests: navigator to Document (#177) (read 2026-10-23)
  5. WebMCP pull requests: unregisterTool removal (#156) (read 2026-10-23)
  6. WebMCP pull request #132: remove provideContext and clearContext (read 2026-10-23)
  7. Chrome for Developers: WebMCP imperative API (read 2026-10-23)
  8. Chrome for Developers: WebMCP declarative API (read 2026-10-23)
  9. Chrome for Developers: Secure your WebMCP tools (read 2026-10-23)
  10. Chrome for Developers: WebMCP best practices (read 2026-10-23)
  11. Chrome DevTools: WebMCP pane (read 2026-10-23)
  12. Lighthouse: Registered WebMCP tools audit (read 2026-10-23)