WebMCP: A Practical Guide to Agent-Ready Websites

AI agents can already use websites without any cooperation from the site itself. They inspect the DOM, read the accessibility tree, analyze screenshots, locate controls, type into fields, and click buttons. It works well enough, and it’s also slightly absurd, because the website already knows exactly what all those controls do.

An ecommerce application knows how to search its catalog, add a product to the cart, calculate a subtotal, and prepare an order for checkout. An agent still has to rediscover every one of those capabilities from an interface that was designed for a human with a mouse.

Consider this shopping request:

output
Find me a wireless mechanical keyboard under $100 with hot-swappable
switches and at least a 4.5 rating. Add the highest-rated one to my cart.

Without help from the site, an agent has to reason through something like this:

output
Find the search field
Enter "mechanical keyboard"
Find the price filter
Enter $100
Find the rating filter
Select 4.5
Find the feature filters
Select wireless and hot-swappable
Submit the form
Inspect the results
Compare their ratings
Find the right Add to cart button
Click it

With WebMCP, the application can hand over the underlying capability directly:

output
search_products({
  query: "mechanical keyboard",
  maxPrice: 100,
  minRating: 4.5,
  features: ["wireless", "hot-swappable"]
})

The agent can then take the returned product ID and call:

output
add_to_cart({
  productId: "keyboard-142"
})

None of this removes the human-facing website. WebMCP gives the same application a second, structured interface that agents can discover and invoke.

You’ll add WebMCP to a deliberately small ecommerce store with 290 products, register JavaScript tools, execute them without an AI agent in the loop, expose an existing HTML form declaratively, handle mutations and cancellation, and work through the security, testing, and operational questions that show up as soon as agent calls can touch real application state.

One caveat before you start. WebMCP is still experimental in September 2026. It’s a Draft Community Group Report from the W3C Web Machine Learning Community Group rather than a W3C Recommendation, and Chrome is running an origin trial from Chrome 149 through 156. The API is still moving, so every example here follows the current Chrome implementation unless noted otherwise.

Prerequisites

You’ll need a recent version of Chrome and Node.js installed locally.

The demo application uses Vite, vanilla JavaScript, semantic HTML, plain CSS, and a static JSON product catalog. There’s no backend, no database, no authentication system, no payment provider, and no frontend framework, because none of that helps you understand WebMCP.

From the project directory, install the dependencies:

command
npm install

Then start the Vite development server:

command
npm run dev

Open the local address printed in your terminal.

Before you change anything, use the store the way a person would. Search for products, add one to the cart, remove it, and walk through checkout. The application works perfectly well without WebMCP, and that’s the point: WebMCP should be a progressive enhancement rather than a prerequisite for using your site.

What is WebMCP?

WebMCP is a proposed web platform API that lets a web application hand structured tools to AI agents. For the JavaScript imperative API, the entry point is:

js
document.modelContext;

A tool describes its name, what it does, the JSON Schema for its inputs, a few optional safety annotations, and the JavaScript function that runs when something invokes it. Here’s about the smallest useful example:

js
await document.modelContext.registerTool({
  name: 'get_cart',
  description: 'Return the current shopping cart.',
  inputSchema: {
    type: 'object',
    properties: {},
  },
  execute: async () => {
    return getCart();
  },
});

A browser-integrated agent discovers whatever tools the page exposes through the browser’s WebMCP machinery, then decides when to call them.

What makes this architecturally interesting is where the implementation runs. The tool executes inside the web application’s existing page context, so it reuses the same JavaScript state, the same application functions, and the same authenticated browser session that the visible interface already relies on. In our store, both the UI and WebMCP end up calling functions like these:

js
searchProducts(criteria);
addToCart(productId);
getCart();
removeFromCart(productId);

The agent never gets a second ecommerce implementation built especially for AI. It gets a structured interface to the one you already shipped.

Enabling WebMCP in Chrome

Chrome exposes WebMCP through an origin trial that started in Chrome 149. For local development, a browser flag is enough. Open:

output
chrome://flags/#enable-webmcp-testing

Set the flag to Enabled and relaunch Chrome. Then open the demo and type this into DevTools:

js
document.modelContext;

You should get back a ModelContext object. If you get undefined, check your Chrome version and confirm the flag actually took effect after the relaunch.

Your application should feature-detect WebMCP rather than assume it’s there:

js
export async function initWebMCP() {
  if (!document.modelContext) {
    console.warn("WebMCP isn't available in this browser.");
    return;
  }

  // Register tools here.
}

Resist the urge to ship your own fallback object at document.modelContext. A test shim can be handy in isolation, but in production code it mostly hides the fact that you’re no longer testing the browser’s implementation at all.

Understanding the demo application

The demo carries 290 electronics products across keyboards, mice, headphones, monitors, webcams, and speakers. The catalog lives in a static products.json file, and the cart lives in browser state, persisted to localStorage.

What matters here is the line between the user interface and the underlying application operations:

output
                 Human
                   |
                   v
                Web UI
                   |
                   v
        Application functions
          /               \
         /                 \
Product catalog            Cart
         ^                 ^
          \               /
           \             /
             WebMCP tools
                  ^
                  |
                Agent

A poor implementation would make the agent-facing tool poke at the page:

js
document.querySelector("[data-product-id='keyboard-142'] button").click();

That’s browser automation with extra steps, reimplemented inside your own application. Instead, the UI click handler and the WebMCP tool should both land on the same call:

js
addToCart('keyboard-142');

Expose the capability, not the control that happens to trigger it today.

The first tool will expose the store’s existing search logic, which already accepts structured criteria:

js
export function searchProducts(criteria = {}) {
  const { query, category, maxPrice, minRating, features } = criteria;

  return products.filter((product) => {
    if (
      category &&
      category !== 'all' &&
      product.category.toLowerCase() !== category.toLowerCase()
    ) {
      return false;
    }

    if (maxPrice !== undefined && maxPrice !== null && maxPrice !== '') {
      const maximum = Number(maxPrice);

      if (!Number.isNaN(maximum) && product.price > maximum) {
        return false;
      }
    }

    if (minRating !== undefined && minRating !== null && minRating !== '') {
      const minimum = Number(minRating);

      if (!Number.isNaN(minimum) && product.rating < minimum) {
        return false;
      }
    }

    if (Array.isArray(features) && features.length > 0) {
      const productFeatures = product.features.map((feature) =>
        feature.toLowerCase()
      );

      const matches = features.every((requestedFeature) => {
        const requested = requestedFeature.toLowerCase().trim();

        return productFeatures.some(
          (feature) =>
            feature.includes(requested) || requested.includes(feature)
        );
      });

      if (!matches) {
        return false;
      }
    }

    if (query && typeof query === 'string' && query.trim() !== '') {
      const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean);

      const searchable = [
        product.id,
        product.name,
        product.brand,
        product.category,
        ...product.features,
      ]
        .join(' ')
        .toLowerCase();

      if (!terms.every((term) => searchable.includes(term))) {
        return false;
      }
    }

    return true;
  });
}

There’s nothing WebMCP-specific anywhere in that function, and that’s deliberate. The human-facing filter controls call it today, and the agent-facing tool is about to call exactly the same thing.

Registering search_products

Now you can expose that operation through the imperative API:

js
await document.modelContext.registerTool({
  name: 'search_products',
  title: 'Search products',
  description:
    'Search the electronics catalog by keywords, category, ' +
    'price, rating, or required features.',
  inputSchema: {
    type: 'object',
    properties: {
      query: {
        type: 'string',
        description: 'Keywords to match against product metadata.',
      },
      category: {
        type: 'string',
        enum: [
          'keyboards',
          'mice',
          'headphones',
          'monitors',
          'webcams',
          'speakers',
        ],
      },
      maxPrice: {
        type: 'number',
        minimum: 0,
        description: 'Maximum price in US dollars.',
      },
      minRating: {
        type: 'number',
        minimum: 0,
        maximum: 5,
        description: 'Minimum acceptable star rating.',
      },
      features: {
        type: 'array',
        items: {
          type: 'string',
        },
        description: 'Features every returned product must provide.',
      },
    },
    additionalProperties: false,
  },
  annotations: {
    readOnlyHint: true,
  },
  execute: async (criteria) => {
    const results = searchProducts(criteria);

    return results.map((product) => ({
      id: product.id,
      name: product.name,
      brand: product.brand,
      price: product.price,
      rating: product.rating,
      stock: product.stock,
      features: product.features,
    }));
  },
});

A good tool definition does more than make a JavaScript function reachable. The name describes a capability rather than an implementation detail, so search_products will still make sense after you redesign the entire catalog page next year. The schema pins down values that have a known shape, which is why the rating can’t exceed five and the category has to come from the list the store actually supports. And the returned object carries enough for an agent to compare candidates without dumping every internal product field into its context window.

Using tool annotations

The current API supports a few optional annotations that describe how a tool behaves:

js
annotations: {
  readOnlyHint: true,
  untrustedContentHint: false,
  consequentialHint: false
}

readOnlyHint says the tool only reads data and won’t modify state. untrustedContentHint flags output that may contain content the tool author doesn’t trust, such as customer reviews, third-party seller text, or anything else that could smuggle in prompt-injection instructions. consequentialHint says the operation can cause a significant, real-world, or hard-to-reverse effect.

Treat all three as hints for agents and browsers. They aren’t authorization controls, and nothing enforces them on your behalf. Our product search only reads, so readOnlyHint: true is the right call and the other two can stay off.

Inspecting registered tools

Before you put a model anywhere near this, confirm the browser can see the tool. For in-page JavaScript agents and development-time poking around, WebMCP gives you:

js
const tools = await document.modelContext.getTools();

console.log(tools);

getTools() returns the tools the calling document is authorized to access. It’s primarily a discovery API for in-page agents, since a browser-integrated agent has its own internal mechanism for retrieving exposed tools and never needs to call this. For debugging, though, it’s the most useful thing in the API.

You can check for your search tool directly:

js
const tools = await document.modelContext.getTools();

const searchTool = tools.find((tool) => tool.name === 'search_products');

console.log(searchTool);

Working outward in that order beats starting from a prompt and guessing. First confirm registration succeeded, then confirm JavaScript can discover the tool, then confirm the tool executes, and only then worry about whether an agent picks it correctly. If registration or execution is broken, no amount of prompt rewriting will save you.

Executing the tool without an agent

You can manually execute any tool that getTools() hands back. Chrome’s current origin-trial implementation wants the input arguments as a valid JSON string:

js
const tools = await document.modelContext.getTools();

const searchTool = tools.find((tool) => tool.name === 'search_products');

const result = await document.modelContext.executeTool(
  searchTool,
  JSON.stringify({
    query: 'mechanical keyboard',
    category: 'keyboards',
    maxPrice: 100,
    minRating: 4.5,
    features: ['wireless', 'hot-swappable'],
  })
);

console.log(result);

There’s a versioning wrinkle worth knowing about. Chrome’s September 1 documentation requires that second argument to be a JSON string, while the September 4 WebMCP draft already describes executeTool() as taking a JavaScript object and serializing it internally. That gap is a decent illustration of why you should read both the browser documentation and the specification while this API is still in flux, and why code you expect readers to run today should follow whatever the target browser actually implements.

Running the query against the demo catalog returns five qualifying keyboards, two of which tie at the top rating of 4.8, including the Nova K87 Wireless Mechanical Keyboard at $89.99. The specific product doesn’t matter much. What matters is that you’ve now walked the entire WebMCP execution path with no model reasoning involved anywhere.

Adding a state-changing tool

Search only reads. The more interesting case is a tool that changes what the human sees on screen. The store already exposes:

js
addToCart(productId, quantity);

Register it the same way:

js
await document.modelContext.registerTool({
  name: 'add_to_cart',
  title: 'Add product to cart',
  description:
    'Add one or more units of a catalog product to the ' +
    'current shopping cart.',
  inputSchema: {
    type: 'object',
    properties: {
      productId: {
        type: 'string',
        description: 'Product identifier returned by product search.',
      },
      quantity: {
        type: 'integer',
        minimum: 1,
        default: 1,
      },
    },
    required: ['productId'],
    additionalProperties: false,
  },
  execute: async ({ productId, quantity = 1 }) => {
    if (
      typeof productId !== 'string' ||
      !Number.isInteger(quantity) ||
      quantity < 1
    ) {
      throw new Error('Invalid cart request.');
    }

    return addToCart(productId, quantity);
  },
});

When the tool calls addToCart(), ordinary application state changes, and the same subscription mechanism the human interface uses notices that change and rerenders the cart. An agent invocation turns Cart (0) into Cart (1) on screen without anybody maintaining a separate agent-only cart. The human and the agent are operating the same state, which is the whole reason this is more interesting than an external API.

Define mutation semantics explicitly

add_to_cart is additive on purpose, so this call:

output
add_to_cart({
  productId: "keyboard-142",
  quantity: 1
})

increments the current quantity by one, and repeating it increments again. The operation isn’t idempotent. For a demo that’s fine, since “add another one to the cart” is an intuitive ecommerce action, but production tool contracts should state their retry semantics out loud. Where retries are likely, a desired-state operation such as:

output
set_cart_item_quantity({
  productId: "keyboard-142",
  quantity: 1
})

is usually easier to make idempotent. Putting an agent on the other end of the call doesn’t make ordinary distributed-systems problems go away.

Reading and modifying the cart

Register a read-only tool so the agent can check what its mutation actually did:

js
await document.modelContext.registerTool({
  name: 'get_cart',
  title: 'Get shopping cart',
  description:
    'Return the current cart items, subtotal, shipping, ' + 'and total.',
  inputSchema: {
    type: 'object',
    properties: {},
    additionalProperties: false,
  },
  annotations: {
    readOnlyHint: true,
  },
  execute: async () => {
    return getCart();
  },
});

The result comes back looking like this:

json
{
  "items": [
    {
      "id": "keyboard-142",
      "name": "Nova K87 Wireless Mechanical Keyboard",
      "price": 89.99,
      "quantity": 1,
      "subtotal": 89.99
    }
  ],
  "itemCount": 1,
  "subtotal": 89.99,
  "shipping": 9.99,
  "total": 99.98
}

Removal follows the same pattern:

js
await document.modelContext.registerTool({
  name: 'remove_from_cart',
  description: 'Remove a product from the current shopping cart.',
  inputSchema: {
    type: 'object',
    properties: {
      productId: {
        type: 'string',
        description: 'Product identifier to remove.',
      },
    },
    required: ['productId'],
    additionalProperties: false,
  },
  execute: async ({ productId }) => {
    return removeFromCart(productId);
  },
});

The application now exposes a compact set of genuinely useful ecommerce capabilities:

output
search_products
add_to_cart
get_cart
remove_from_cart

You’ll notice what isn’t in that list. There’s no renderCartBadge, no openCartDrawer, and no saveCartToLocalStorage, because nobody has ever wanted any of those things. They’re mechanics, not goals.

Preparing checkout without placing the order

The demo also lets the agent get the cart ready for checkout:

js
await document.modelContext.registerTool({
  name: 'prepare_checkout',
  description:
    'Open the checkout review for the current cart. ' +
    'This does not place the order.',
  inputSchema: {
    type: 'object',
    properties: {},
    additionalProperties: false,
  },
  execute: async () => {
    return prepareCheckout();
  },
});

The underlying operation refuses to proceed on an empty cart:

js
export function prepareCheckout() {
  if (cart.length === 0) {
    throw new Error('Cannot checkout with an empty cart.');
  }

  currentView = 'checkout';
  notifyListeners();

  return {
    success: true,
    view: currentView,
    cart: getCart(),
  };
}

There’s one operation the WebMCP surface deliberately doesn’t include:

output
place_order

The normal interface has a Place order button, and the agent simply can’t reach it. Nothing in the protocol forces that boundary. We picked it for the demo because it makes a product-design point that’s easy to lose in the excitement: making an application agent-ready doesn’t oblige you to make every action agent-autonomous. The agent can search, build the cart, and set up checkout, and the human still makes the call that costs money.

Running the complete workflow

Hand a compatible agent the original request:

output
Find me a wireless mechanical keyboard under $100 with hot-swappable
switches and at least a 4.5 rating. Add the highest-rated one to my cart.

A reasonable run calls search_products(...), compares the ratings across the matching candidates, calls add_to_cart(...) for the winner, and then calls get_cart() to confirm what happened. The visible cart updates the moment add_to_cart runs, because the tool goes through the same state-management function the interface uses.

A follow-up request like this:

output
Get my cart ready for checkout.

triggers prepare_checkout({}), the application switches to its checkout view, and the human reviews the cart and places the order. That one workflow covers most of what makes WebMCP worth paying attention to: structured discovery, tool selection, typed inputs, shared state, mutations, and a boundary somebody chose on purpose.

Exposing the search form declaratively

The imperative API suits you when you want to expose JavaScript application functions directly. WebMCP also has a declarative API that turns ordinary HTML forms into tools, which is a much better fit when the capability already exists as a form.

Say the catalog page already contains this:

html
<form id="search-form">
  <label for="query">Search keywords</label>
  <input id="query" name="query" type="search" />

  <label for="category">Category</label>
  <select id="category" name="category">
    <option value="keyboards">Keyboards</option>
    <option value="mice">Mice</option>
    <option value="headphones">Headphones</option>
  </select>

  <label for="max-price"> Maximum price </label>
  <input id="max-price" name="maxPrice" type="number" />

  <button type="submit">Search</button>
</form>

You expose it by adding attributes to the form you already have:

html
<form
  id="search-form"
  toolname="search_products_form"
  tooldescription="Filter the electronics catalog."
  toolautosubmit
></form>

The individual controls become tool parameters, and HTML semantics you were already writing feed the generated schema: input types, required, labels, and <select> options all contribute. When a label isn’t descriptive enough for an agent, add toolparamdescription:

html
<input
  id="max-price"
  name="maxPrice"
  type="number"
  min="0"
  toolparamdescription="Highest acceptable product price in US dollars."
/>

In a browser without WebMCP support, that markup is still just a form. Progressive enhancement doesn’t get much more literal than this.

Handling declarative tool execution

The declarative API gets considerably more interesting once you look at what happens when an agent submits the form. SubmitEvent picks up an agentInvoked property that tells you where the submission came from, and a respondWith() method that returns the result of the operation to the agent. You have to call preventDefault() before using respondWith().

For our store, one handler serves both audiences:

js
searchForm.addEventListener('submit', (event) => {
  event.preventDefault();

  const formData = new FormData(searchForm);

  const criteria = {
    query: formData.get('query'),
    category: formData.get('category'),
    maxPrice: formData.get('maxPrice'),
    minRating: formData.get('minRating'),
  };

  const results = searchProducts(criteria);

  renderProducts(results);

  if (event.agentInvoked) {
    const output = results.map((product) => ({
      id: product.id,
      name: product.name,
      price: product.price,
      rating: product.rating,
      features: product.features,
    }));

    event.respondWith(Promise.resolve(output));
  }
});

A human submission just updates the visible results. An agent invocation runs the same search and also hands the structured result back to the caller.

toolautosubmit controls whether an agent invocation submits the populated form automatically. Leave it off and the browser can fill in the visible form while the person decides whether to press the button, which makes the declarative API surprisingly appealing for any workflow where you want human review baked into the interaction rather than bolted on afterward.

When to use the imperative or declarative API

These two APIs overlap, but they aren’t interchangeable. Reach for the declarative API when the capability already maps cleanly onto a semantic HTML form and keeping that visible form in the loop is worth something. Reach for the imperative API when the operation is better described as an application capability, when you need a more precise schema, when the tool combines several internal operations, or when nothing about it resembles a form submission.

Our demo uses both. The existing search form goes declarative, the existing cart functions go imperative, and nobody had to convert the whole application to one style to make that work.

Handling tool lifetime and cancellation

Capabilities come and go while an application runs, so you can tie registration to an AbortSignal:

js
const controller = new AbortController();

await document.modelContext.registerTool(tool, {
  signal: controller.signal,
});

When the tool should stop being available:

js
controller.abort();

As of Chrome 153, unregistering a tool this way won’t cancel an execution that’s already in flight, which is the kind of detail that bites you in a demo in front of other people.

WebMCP also fires a toolchange event:

js
document.modelContext.addEventListener('toolchange', () => {
  console.log('Available tools changed.');
});

That’s mainly useful for an in-page agent that needs to react when the toolset shifts under it.

Cancellation applies to execution too. A long-running tool receives an execution signal as the second argument to execute():

js
await document.modelContext.registerTool({
  name: 'search_remote_catalog',
  description: 'Search a remote catalog.',
  inputSchema: {
    type: 'object',
    properties: {
      query: {
        type: 'string',
      },
    },
    required: ['query'],
  },
  execute: async ({ query }, { signal }) => {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal,
    });

    return response.json();
  },
});

Passing that signal through to fetch() stops work from grinding on after the agent or the user has already cancelled.

Designing secure WebMCP tools

WebMCP runs inside the user’s authenticated browser session, which is one of its real advantages over an external system trying to reconstruct the same application state from the outside. It also means your tool surface is a production application interface, and you should treat it like one.

Validate tool arguments

A JSON Schema helps an agent build valid input. It doesn’t replace your own validation, and it never will, because the schema is advisory to whatever is calling you. A cart mutation should still reject nonsense:

js
execute: async ({ productId, quantity = 1 }) => {
  if (
    typeof productId !== 'string' ||
    !Number.isInteger(quantity) ||
    quantity < 1 ||
    quantity > 10
  ) {
    throw new Error('Invalid cart request.');
  }

  return addToCart(productId, quantity);
};

If the tool eventually reaches a backend, that backend still owns authorization, ownership checks, tenant boundaries, inventory rules, and every other constraint it would enforce for a human. Never let authorization depend on a language model reading your tool description correctly.

Treat tool output as untrusted when appropriate

Suppose product search starts returning customer reviews or third-party seller descriptions. That text can carry instructions aimed squarely at your agent. For output you don’t fully control, say so:

js
annotations: {
  readOnlyHint: true,
  untrustedContentHint: true
}

The annotation gives the caller context about what it’s reading. It doesn’t make prompt injection impossible, which is why the more effective mitigation is still returning only the data the agent needs and nothing else.

Mark consequential actions accurately

A tool that books travel, submits a payment, deletes something valuable, or otherwise reaches into the world can declare:

js
annotations: {
  consequentialHint: true;
}

That helps agents and browsers make better confirmation decisions. It’s a hint, not a permission system, so if your application requires explicit human approval, enforce that in the application workflow where you can actually guarantee it.

Keep abuse controls in place

An authenticated agent can hammer an operation far faster than any person clicking through your interface. Rate limits, quotas, fraud checks, and abuse detection all still apply. Don’t assume that a request from an authenticated browser session will arrive at human browsing speed, because that assumption is quietly baked into more systems than anyone realizes.

Cross-origin WebMCP tools

WebMCP won’t hand your tools to arbitrary cross-origin documents. Getting cross-origin participation working takes several explicit steps, all of which have to line up.

Say shop.example embeds an in-page agent from https://assistant.example. The embedding document has to delegate the tools Permissions Policy:

html
<iframe src="https://assistant.example" allow="tools"></iframe>

The shop then exposes an individual tool to that origin:

js
await document.modelContext.registerTool(tool, {
  exposedTo: ['https://assistant.example'],
});

And the in-page agent has to ask for tools from the shop origin:

js
const tools = await document.modelContext.getTools({
  fromOrigins: ['https://shop.example'],
});

Every piece is load-bearing. Allowing the iframe to participate doesn’t expose your tools, and adding an origin to exposedTo doesn’t make another document retrieve them. Only hand authenticated capabilities to origins you’d trust with the session behind them.

Testing WebMCP correctly

A WebMCP test strategy has two layers that people love to collapse into one. Test the deterministic application contract first, then evaluate the probabilistic behavior of an agent separately. Mixing them produces test failures nobody can interpret.

Test the application operation

Our shopping scenario can be verified with no WebMCP and no model anywhere in sight:

js
const results = searchProducts({
  query: 'mechanical keyboard',
  category: 'keyboards',
  maxPrice: 100,
  minRating: 4.5,
  features: ['wireless', 'hot-swappable'],
});

const highestRating = Math.max(...results.map((product) => product.rating));

const bestProduct = results.find((product) => product.rating === highestRating);

addToCart(bestProduct.id);

const cart = getCart();

if (cart.itemCount !== 1) {
  throw new Error('Expected one item in the cart.');
}

The supplied catalog returns five qualifying keyboards for that query, and the full search, cart, and checkout scenario passes the demo’s automated test.

Test discovery and direct execution

Next, verify the WebMCP layer itself:

js
const tools = await document.modelContext.getTools();

console.table(
  tools.map((tool) => ({
    name: tool.name,
    description: tool.description,
  }))
);

Then call each tool directly with executeTool() before an LLM gets involved. Cover valid input, malformed input, empty search results, an unknown product ID, repeated cart mutations, removing something that isn’t there, checkout with an empty cart, and cancelled long-running work. Everything you catch here is a failure you won’t later mistake for bad model behavior.

Evaluate agent behavior separately

Once the deterministic path holds up, start asking whether agents make sensible decisions, and don’t limit yourself to the happy path. A small eval suite might look like this:

Scenario Expected behavior
Find a keyboard under $100 Calls search_products
Ask about the return policy Doesn’t call a catalog tool
“Buy this now” Doesn’t invent place_order
Vague product request Searches before mutating
Checkout with an empty cart Doesn’t proceed successfully
Product text contains instructions Doesn’t follow injected text
Remove an absent product Handles the failure correctly

Test paraphrases of the same goal too, since model behavior is probabilistic and one passing phrasing proves very little. Knowing when to call a tool is only half of it. The model also has to know when to leave your tools alone.

Observing WebMCP in production

The moment WebMCP leaves your laptop, you need enough telemetry to tell where a failure actually happened. Invocation counts per tool, execution latency, success and failure rates, cancellation rates, validation failures, and downstream application errors will get you most of the way there.

If your application already does distributed tracing, wrap the execution in an application-defined span and let normal backend spans nest underneath:

js
execute: async (criteria, { signal }) => {
  return tracer.startActiveSpan(
    'webmcp.tool.execute search_products',
    async (span) => {
      try {
        return await searchProducts(criteria, { signal });
      } catch (error) {
        span.recordException(error);
        throw error;
      } finally {
        span.end();
      }
    }
  );
};

That span name is an application convention and nothing more. No standardized WebMCP semantic conventions exist today, so please don’t dress custom attribute names up as though OpenTelemetry had blessed them. When conventions do land, an OTel-native backend like Dash0 will pick them up without you rewriting your instrumentation, which is a decent argument for keeping your attribute names boring and standards-shaped in the meantime.

One more thing, and it’s the one most teams get wrong first: don’t record raw tool arguments and results by default. WebMCP tools will end up handling addresses, account data, support messages, and search queries that reveal more than anyone intends. Observability should help you diagnose tool execution without quietly turning your telemetry backend into a second copy of your users' personal data.

WebMCP versus MCP and browser automation

WebMCP sits somewhere different from both remote MCP servers and traditional browser automation, and the comparison is easier to hold in your head as a table:

Approach Capability lives in Requires active page Typical use
WebMCP Web document Yes Page-native interactions
MCP server External server No Backend and service capabilities
Browser automation Human UI Yes Sites without explicit tools
MCP Apps MCP server Host-dependent Interactive UI inside MCP hosts

A remote Model Context Protocol server lives independently of any browser tab, which makes it the better fit for capabilities that should keep working when nobody’s visiting your website. WebMCP belongs to the active web application, so it earns its place when current page state, the browser session, or the visible user workflow actually matters. Browser automation stays the fallback for every site that hasn’t exposed structured capabilities, which is nearly all of them.

MCP Apps solves a different problem again, letting an MCP server provide an interactive interface for an MCP host to render. With WebMCP the existing website is the interface, and the agent works against capabilities that page chose to expose. A real agentic workflow will happily use several of these at once.

Debugging common WebMCP problems

Experimental APIs fail in ways that look like model failures at first glance, which wastes an impressive amount of time. Work through the stack in order instead.

document.modelContext is undefined

Check your Chrome version, confirm the development flag is enabled, and relaunch. WebMCP also requires an origin-isolated document, so a page that opts out through something like document.domain won’t get the API at all.

registerTool() rejects

Registration is asynchronous, so handle the promise:

js
await document.modelContext.registerTool(tool);

It can reject for invalid definitions, duplicate tool names, or permission problems, and swallowing that rejection is how you end up debugging a tool that was never registered in the first place.

getTools() doesn’t return the tool

Confirm registration succeeded, then confirm you’re retrieving from the document and origin you think you are. For cross-origin tools, check all three pieces: the Permissions Policy, exposedTo, and fromOrigins.

Direct execution fails

Use the argument format your current browser supports. For Chrome’s origin-trial implementation that means:

js
await document.modelContext.executeTool(
  tool,
  JSON.stringify({
    productId: 'keyboard-142',
  })
);

The latest specification draft already differs here, so recheck the browser documentation as the implementation moves.

The tool works manually but an agent ignores it

Congratulations, you now have a model-selection problem instead of a registration problem, which is a much better problem to have. Look at the tool name, the description, the input descriptions, the schema constraints, and whether the tool overlaps with another one you registered. Then ask whether the user’s request really implies the operation at all. Whatever you find, add the failing prompt to your eval suite.

The agent mutates state but the UI doesn’t update

Your WebMCP tool is bypassing the application’s normal state-management path. Human interaction and agent invocation both have to arrive at the same application operation, and when they don’t, this is exactly how it shows up.

Browser support and standardization status

WebMCP is an emerging proposal, not an interoperable web platform feature. As of September 2026, the specification is a W3C Web Machine Learning Community Group draft, Chrome’s origin trial runs from Chrome 149 through 156, and Chrome lists 157 as an estimated shipping milestone rather than a commitment. Local development works behind a flag. Mozilla and WebKit have standards-position discussions open, which is worth watching and is nowhere near committed cross-browser support.

You’ll also run into older examples using the previous global:

js
navigator.modelContext;

The current API is document-scoped:

js
document.modelContext;

That rename carries real meaning. Tools belong to a specific document and its lifecycle rather than to the browsing session as a whole.

The specification is still moving around tool outputs, dynamic definitions, long-running execution, cross-document discovery, and other browser-agent interactions. So keep your integration thin. A WebMCP tool should be a short adapter that calls an existing application operation, which means an API change costs you an afternoon rather than a rewrite.

Should you adopt WebMCP today?

It’s worth experimenting with if your application has workflows that agents currently have to reverse-engineer from your interface controls. Product discovery and cart management, travel searches, appointment booking, customer-support workflows, complicated forms, productivity applications, and authenticated page-specific operations all qualify.

It’s a poor choice as the only interface for anything that has to work without an active browser tab. A backend API or a remote MCP server fits that job better, and pretending otherwise will hurt.

For now the honest adoption model is progressive enhancement:

js
if (document.modelContext) {
  await initWebMCP();
}

Your human-facing application has to keep working without it, because for the vast majority of your users it will be running without it.

Final thoughts

WebMCP doesn’t ask you to rebuild your application for agents. The useful pattern turns out to be much smaller than the hype suggests: leave your application logic where it is and expose a handful of meaningful capabilities through a thin WebMCP layer.

In the demo, the same functions that drive the human storefront handle product searches and cart changes coming from an agent. The imperative API carries the application-level operations, and the declarative API lets an existing form become a tool without displacing the human workflow.

Treat those tools like any other production interface, which means validating inputs, enforcing authorization in the application and the backend, thinking hard about consequential actions, testing when agents should and shouldn’t invoke them, and observing failures without hoovering up sensitive inputs and outputs along the way. The API is still experimental, so progressive enhancement isn’t optional and your website has to work normally without it.

The underlying idea still holds up, though. Rather than making agents reverse-engineer interfaces built for humans, websites can just say what they know how to do.