How to Add WebMCP to Your Website: A Practical Guide
Three weeks ago we explained what WebMCP is: a proposed web standard that lets a page give an AI agent a list of named, typed tools instead of leaving it to guess its way through buttons and screenshots. This follow-up is for the people who have to ship it. It walks through adding WebMCP to a working website one step at a time, using a restaurant booking site as the running example, and it follows the W3C draft as it stands on September 24, 2026. The draft is still moving, so treat the code as a snapshot and check the sources at the end before you rely on it.
What has changed since our explainer
The specification repository has taken more than a dozen changes since early September. Four of them matter if you are building now.
- New annotations. Alongside
readOnlyHintanduntrustedContentHint, a tool can now setconsequentialHintfor actions that are significant, real-world, or irreversible, such as a booking or a payment, anddebuggingfor tools meant for developer tooling rather than end users. - Lifecycle events.
toolactivatedandtoolcancelevents, specified on September 17, tell the page when an agent starts running a tool and when it abandons one. - Headless is now in scope. The explainer lists headless browsing as a goal rather than a non-goal. Fully autonomous use with no browser UI at all is still out of scope. Our earlier piece described the older wording.
- More places to test. Edge runs its own origin trial from Edge 150, and the spec's implementation status page lists ChatGPT Desktop as supporting WebMCP and Brave's Leo assistant as experimental.
Step 1: Decide what an agent should be able to do
Resist starting with code. Start with a list of the jobs a signed-in visitor actually comes to your site to do, and write each one as a verb and an object. For our booking site that list is short: find an open table, book it, cancel a booking, and send the restaurant a question. That becomes four tools: search-availability, book-table, cancel-booking, and a contact form.
A few rules from the spec's best-practice guidance are worth applying at this stage, before they are baked into code:
- Mind the tool budget. Every tool's name, description, and schema is text the model has to read on every turn. A handful of well-chosen tools beats dozens of overlapping ones, which slow the agent down and make it pick the wrong one.
- One job per tool. If two tools could plausibly answer the same request, merge them or sharpen the difference.
- Choose verbs that tell the truth.
book-tableshould book the table. If a tool only opens the booking flow for a person to finish, call itstart-booking. The spec's guidance contrastscreate-eventwithstart-event-creation. - Accept input the way people give it. Take "7:30pm tomorrow" or a plain date and normalize it in your own code. Do not make the model convert time zones or look up internal IDs, and prefer readable enum values such as
"patio"over codes such as3.
Step 2: Switch it on
For local development, use Chrome 150 or later and enable chrome://flags/#enable-webmcp-testing. Then install the Model Context Tool Inspector extension, which lists the tools a page has registered and lets you run them by hand. You will use it constantly.
For real visitors, register your origin for the WebMCP origin trial, which runs from Chrome 149 through Chrome 156, and serve the token on every page that registers tools, either as a response header or as a meta tag:
<meta http-equiv="origin-trial" content="YOUR_ORIGIN_TRIAL_TOKEN">
Edge has a separate trial with its own token. Without a token, document.modelContext simply does not exist for ordinary visitors, which is exactly what should happen in Safari and Firefox too. Your site has to work normally in that case, so every piece of WebMCP code sits behind a feature check:
if ('modelContext' in document) {
// register tools here
}
Three platform rules catch people out:
- HTTPS only. The API is limited to secure contexts.
localhostcounts as secure for development. - No
document.domain.registerTool()rejects with aSecurityErrorif the page has relaxed its origin isolation by settingdocument.domain, a legacy trick some older sites still use. - Iframes need permission. WebMCP is controlled by a
toolspermissions policy that defaults to'self', so the top-level page and same-origin frames can register tools and cross-origin frames cannot unless you addallow="tools"to the iframe. A page that should never expose tools, such as an admin console, can sendPermissions-Policy: tools=().
If you experimented in the spring, check your code for navigator.modelContext. The API moved to document.modelContext in May, and the old location is deprecated in Chrome.
Step 3: Start with your forms
The cheapest win is the declarative API, because most of the actions on a typical site are already HTML forms. Two attributes turn a form into a tool. The browser builds the input schema from the form's controls: each control's name becomes a parameter, required marks it required, and the optional toolparamdescription attribute gives the model a description of that parameter.
<form id="availability" action="/availability"
toolname="search-availability"
tooldescription="Find open tables for a date, time, and party size.
Returns available time slots. Does not make a booking."
toolautosubmit>
<label>Date <input type="date" name="date" required></label>
<label>Time
<input type="time" name="time"
toolparamdescription="Preferred time, local to the restaurant">
</label>
<label>Guests
<input type="number" name="partySize" min="1" max="12" required>
</label>
<label>Seating
<select name="seating">
<option value="any">No preference</option>
<option value="indoor">Indoor</option>
<option value="patio">Patio</option>
</select>
</label>
<button type="submit">Check availability</button>
</form>
toolautosubmit lets the agent submit the form itself once it has filled it in. That is fine for a search. Leave it off for anything consequential. Without it, the browser fills in the form, moves focus to the submit button, and the agent tells the user to check the details and submit. That gives you a human confirmation step at no cost.
By default, submitting the form navigates the page as it always has. To hand the agent a structured answer instead, check the new agentInvoked flag on the submit event, cancel the navigation, and pass a promise to respondWith(). Call preventDefault() first, because the spec requires it:
const form = document.getElementById('availability');
form.addEventListener('submit', (event) => {
if (!event.agentInvoked) return; // people get the normal page
event.preventDefault(); // required before respondWith()
event.respondWith(
searchAvailability(new FormData(form)).then((slots) => {
renderSlots(slots); // keep the screen in sync
return { slots }; // what the agent reads
})
);
});
To show people that an agent is filling in a form, style the new :tool-form-active and :tool-submit-active pseudo-classes. Give them a rule of their own, not a selector list shared with other selectors, so that browsers which do not recognize them only drop that one rule:
form:tool-form-active { outline: 2px solid var(--accent); outline-offset: 4px; }
button:tool-submit-active { box-shadow: 0 0 0 4px var(--accent-soft); }
Two caveats. The declarative section of the spec is still a work in progress: Chromium implements a loose version of the form-to-schema conversion while the exact rules are settled. And how an agent should read the result of a form that navigates to a new page is still an open question. For any form whose result the agent needs, use respondWith() rather than depending on the page after the navigation.
Step 4: Register JavaScript tools for everything else
Anything that is not a simple form, including flows that span several screens, actions inside a single-page app, and anything that needs the user's current state, belongs in the imperative API. Here are the other two booking tools, registered from a module script:
if ('modelContext' in document) {
const tools = new AbortController();
await document.modelContext.registerTool({
name: 'book-table',
title: 'Book a table',
description:
'Books a table for the signed-in guest using a slotId returned ' +
'by search-availability. Creates a real reservation and emails ' +
'a confirmation.',
inputSchema: {
type: 'object',
properties: {
slotId: {
type: 'string',
description: 'A slot id returned by search-availability',
},
partySize: { type: 'integer', description: 'Guests, 1 to 12' },
notes: {
type: 'string',
description: 'Optional requests, such as a high chair',
},
},
required: ['slotId', 'partySize'],
},
annotations: { consequentialHint: true },
async execute({ slotId, partySize, notes }, { signal }) {
const slot = findSlot(slotId);
if (!slot) {
return {
error: 'Unknown slotId. Use an id from search-availability.',
};
}
const booking = await createBooking(
{ slotId, partySize, notes },
{ signal },
);
showConfirmation(booking); // same screen a person would see
return { confirmed: true, reference: booking.reference };
},
}, { signal: tools.signal });
await document.modelContext.registerTool({
name: 'list-my-bookings',
title: 'Your bookings',
description: "Lists the signed-in guest's upcoming bookings.",
annotations: { readOnlyHint: true },
async execute(_input, { signal }) {
return { bookings: await fetchUpcomingBookings({ signal }) };
},
}, { signal: tools.signal });
}
A few things in that code are deliberate.
titleis for people,descriptionis for the model. The browser may show the title in its own interface, so localize it. Write the description as a plain statement of what the tool does and what it returns. Say what the tool does rather than listing what the model must not do.- Keep the schema loose and the validation strict. Schema constraints are hints to the model. An overly strict schema can leave an agent stuck, so do the real validation inside
executeand return an error message the agent can act on, as the unknown-slot case above does. - Call the same functions the UI calls.
createBookingis the function behind the Book button. If the tool takes a separate code path, it can end up with different, and weaker, validation than your UI. The spec's security section singles this out as a real risk. - Pass the signal on.
executereceives anAbortSignalthat fires when the agent cancels. Forward it tofetchso work stops when the user presses stop. - Keep results small. Whatever you return is serialized to a string for the agent. Short, self-describing JSON is easier for the model to use than a dump of your internal objects.
- Await registration.
registerTool()returns a promise that rejects if the name is already taken, if the name or description is empty, or if the schema is invalid. Catch the error and log it, or a tool can quietly fail to register.
Older tutorials show a requestUserInteraction() method for asking the user to confirm. It was removed from the draft in June, along with the ModelContextClient interface it belonged to, while the group works out how confirmation should happen. For now, consequentialHint tells the agent and browser to treat the tool with care, and the rest is up to your product design. A simple rule: if you would not let a single click complete an action without a confirmation screen, do not let a tool do it either. Name the tool start-booking, have it fill in your normal confirmation screen, and let the person press the button.
Step 5: Keep the tool list in step with the page
For a small site, registering everything on page load is fine, and the spec recommends it. In a single-page app, register the tools that make sense for the current view and unregister them when the user leaves by aborting the signal. Calls that are already running when you unregister are allowed to finish.
let viewTools;
function enterView(registerToolsForView) {
viewTools?.abort(); // drop the previous view's tools
viewTools = new AbortController();
registerToolsForView({ signal: viewTools.signal });
}
Each change fires a toolchange event, which agents use to refresh their list. The newer toolactivated and toolcancel events are useful for your own interface, for example a small indicator that an agent is working. toolactivated fires when a tool starts and toolcancel fires when the agent abandons it, but no event fires when a tool finishes normally, so clear the indicator yourself when execute returns:
const mc = document.modelContext;
mc.addEventListener('toolactivated', (e) => showAgentBadge(e.toolName));
mc.addEventListener('toolcancel', (e) => hideAgentBadge(e.toolName));
// No event marks a successful finish, so wrap each execute callback.
function withBadge(name, run) {
return async (input, options) => {
try {
return await run(input, options);
} finally {
hideAgentBadge(name);
}
};
}
// In registerTool(): execute: withBadge('book-table', bookTable),
Whatever a tool changes, the page should show it straight away. The person and the agent are using the same tab, and neither should be looking at an out-of-date screen.
Step 6: Treat every tool as an attack surface
WebMCP runs with the user's session, so a tool can do anything the signed-in user can do. The draft's security and privacy considerations are unusually candid, and they reduce to a short checklist for site owners:
- Descriptions are prompts. Anything in a name, description, or parameter description goes straight into the model's context. Keep them factual, and review changes to them as carefully as code.
- Mark untrusted output. If a tool returns reviews, messages, forum posts, or anything else your users wrote, set
untrustedContentHint: true. Text from your users can contain instructions aimed at the agent, and this hint tells the agent that the output is not something you vouch for. - Ask for the minimum. Every parameter is data the agent may fill in from what it knows about the user. Do not ask for a phone number your tool does not need.
- Server-side checks do not change. Authorization, rate limits, and fraud checks apply to tool calls exactly as they apply to clicks. When a tool is rate-limited or blocked, return a message telling the agent to ask the user to finish in the page.
- Mark consequential actions, and do not stop there.
consequentialHintis a hint to a well-behaved agent. It is not access control. - Be deliberate about frames. Do not add
allow="tools"to third-party iframes, and only list origins you trust in theexposedTooption that shares tools with embedded agents. - Keep debug tools out of production. Mark internal tools with
debugging: trueand ship them only in development builds.
Step 7: Test with real agents
Unit tests with a mocked document.modelContext tell you your callbacks work. They do not tell you whether an agent will pick the right tool, and they will pass even if your production pages never serve an origin trial token. Test in four layers:
- The inspector. With the flag on, open each page in the Model Context Tool Inspector, confirm every tool you expect is listed, and run each one by hand with good and bad input.
- Real agents. Try the site with the agents that can use WebMCP today: Chrome through the inspector's Gemini mode, ChatGPT Desktop, and Brave's Leo if you want an early look.
- Task evals. Write ten or twenty requests a real customer might make, such as "book us a patio table for four on Friday around eight", run them, and record which tools the agent called and with what arguments. When it picks the wrong tool, the fix is almost always in the description. Treat descriptions as copy you revise, not as documentation you write once.
- Without WebMCP. Load the site in Safari, in Firefox, and in Chrome with the flag off and no token. Nothing should change and nothing should appear in the console.
If you use TypeScript, the webmcp-types package, referenced from the spec repository, provides type definitions for the API.
Where this leaves MCP
If you already run an MCP server, you have done most of the hard thinking. The tool names, descriptions, and schemas you designed for backend agents are a strong first draft of your WebMCP tools, and keeping the two consistent means an agent gets the same meaning whether it reaches you through your API or through a signed-in customer's browser tab. The difference is where the tool runs and whose session it uses. MCP is for your systems. WebMCP is for your pages, used alongside the person using them.
The whole job fits in a sprint for most sites: annotate the forms that matter, register a few JavaScript tools with honest names and the right hints, lock down the security basics, and spend the rest of the time testing with real agents. The standard is still a draft and only Chromium browsers implement it, but none of this work is wasted: every step also makes the site clearer for people. If you are working out what an agent should be allowed to do in your own product, tell us about it.
Sources
- W3C Web Machine Learning Community Group — WebMCP explainer and specification repository
- W3C Web Machine Learning Community Group — WebMCP Draft Community Group Report
- W3C Web Machine Learning Community Group — WebMCP declarative API explainer
- W3C Web Machine Learning Community Group — Browser and agent implementation status
- Chrome for Developers — WebMCP documentation
- Chrome for Developers — WebMCP best practices
- Chrome for Developers — Creating security-minded tools
- Chrome for Developers — Join the WebMCP origin trial
- Chrome Web Store — WebMCP Model Context Tool Inspector
- npm — webmcp-types