WebMCP: Stop making agents click websites
Agents still use websites by pretending to be users. They dump the DOM, read the accessibility tree, or look at screenshots, then click and type until something happens. It works on any site. It is also slow, expensive, and easy to get wrong the moment a dropdown is not a real <select> or two buttons both say "Submit."
Look, click, screenshot, look again.
WebMCP is an attempt to stop that guessing. A page can publish a small list of tools: named actions with descriptions and JSON schemas. An agent calls those instead of reverse-engineering the UI. Same idea as MCP, just attached to the live tab rather than a server. MCP still owns backend systems. WebMCP only exists while the page is open, and it runs in that page's session.
The page publishes actions. The agent calls them.
The proposal is being incubated in the W3C's Web Machine Learning Community Group as a web platform API, document.modelContext.1 Chrome opened an origin trial for it in version 149 and Edge in 150. Brave has experimental support in Leo, ChatGPT Desktop supports it, and Firefox and Safari each have a standards-position request open rather than an implementation.2 You can register tools from JavaScript, annotate an existing form declaratively, or let a proxy inject a tool layer in front of a site that never wrote any of this.3 None of it is widely deployed, and the shape is still moving: the API lived on navigator before it moved onto document. The interesting part is the contract, not who shipped the flag.
What this replaces
Most agent stacks still drive a local or remote browser like a person. That is the right move when the site offers nothing else. It is the wrong move when the site already knows the action: which line item is which, which reason codes the backend accepts, whether this button refunds or exchanges.
WebMCP is the site saying that out loud.
The agent calls. The page updates.
Why it matters when the site actually supports it
Fewer invented IDs. The schema can demand lineItemId, not "the navy tee."
Less page soup in the prompt. A tool list is small. A DOM or screenshot loop is not.
Same session, visible result. The call runs in the tab the user already has open. The UI can update in front of them. A purchase or refund can still ask for confirmation.
No extra public API required for a flow the page already implements. You are exposing the action you already have, not rebuilding checkout for models.
It does not replace browser-use. Silent sites still need clicking. WebMCP only helps where something registered a tool.
What the page registers
The two tools in the figures above are ordinary registerTool calls on document.modelContext. A browser without the API gets the UI and nothing else. The registration lives only while the order page is mounted; leaving the page aborts the signal so the write tools cannot be invoked from some other screen. Each tool starts folded; open one if you want the schema.
/*** Registered while the order page is mounted. Returns its own teardown.*/export async function registerOrderTools() {const modelContext = document.modelContext;// A browser without the API simply gets no tools. The UI is the fallback.if (!modelContext?.registerTool) return () => {};const controller = new AbortController();const { signal } = controller;
await modelContext.registerTool({name: "get_open_order",title: "Get the order on this page",description:"Return the order the user is viewing, including line item IDs. Use lineItemId in later tools. Do not use the product title as an id.",inputSchema: { type: "object", properties: {} },annotations: { readOnlyHint: true },execute() {const order = window.currentOrder;return {content: [{type: "text",text: JSON.stringify({orderId: order.id,currency: order.currency,items: order.items.map((item) => ({lineItemId: item.id,title: item.title,size: item.size,qtyPurchased: item.qty,qtyReturnable:item.qty - item.alreadyReturned,unitPrice: item.unitPrice,})),}),},],};},},{ signal });
await modelContext.registerTool({name: "start_return",title: "Start a return",description:"Start a refund or exchange for a line item on the open order. Reason and resolution must be the enum values, not free text. Call get_open_order first if you do not have a lineItemId.",inputSchema: {type: "object",properties: {lineItemId: {type: "string",description:"Line item id from get_open_order, not the product name.",},quantity: { type: "integer", minimum: 1 },reason: {type: "string",enum: ["too_small","too_large","damaged","not_as_described","changed_mind",],},resolution: {type: "string",enum: ["refund", "exchange"],},},required: ["lineItemId", "quantity", "reason", "resolution"],},annotations: { readOnlyHint: false },async execute({ lineItemId, quantity, reason, resolution },{ signal }) {const result = await orderApi.startReturn({ lineItemId, quantity, reason, resolution },{ signal });renderReturnStatus(result);return {content: [{type: "text",text: JSON.stringify({returnId: result.id,refundAmount: result.refundAmount,labelUrl: result.labelUrl ?? null,nextStep:result.resolution === "exchange"? "Ask the user which size to send, then call start_exchange.": "Return is filed. Give the user the label link.",}),},],};},},{ signal });
await modelContext.registerTool({name: "start_exchange",title: "Start an exchange",description:"Send a replacement size for a line item after start_return with resolution exchange. newSize must be an enum value, not a word like medium. Call get_open_order first if you do not have a lineItemId.",inputSchema: {type: "object",properties: {lineItemId: {type: "string",description:"Line item id from get_open_order, not the product name.",},returnId: {type: "string",description:"Return id from start_return, so the exchange is attached to the return already filed.",},newSize: {type: "string",enum: ["S", "M", "L"],description:"Replacement size. Must differ from the size already purchased.",},quantity: { type: "integer", minimum: 1 },},required: ["lineItemId", "returnId", "newSize", "quantity"],},annotations: { readOnlyHint: false },async execute({ lineItemId, returnId, newSize, quantity },{ signal }) {const result = await orderApi.startExchange({ lineItemId, returnId, newSize, quantity },{ signal });renderExchangeStatus(result);return {content: [{type: "text",text: JSON.stringify({exchangeId: result.id,shipsSize: result.newSize,labelUrl: result.labelUrl ?? null,nextStep:"Exchange is filed. Give the user the outbound and return label links.",}),},],};},},{ signal });
// Called when the user leaves the order page, so the write tools cannot// be invoked from some other screen.return () => controller.abort();}
Two details in there are the API rather than the example. A tool hands its result back as a content array, so structured data crosses as JSON text today; the explainer still lists an outputSchema for structured output as an open question.4 And execute receives an AbortSignal alongside its arguments, which is worth passing straight through to fetch: a cancelled call should cancel the request it started, not orphan it.
readOnlyHint is not decoration either. It is the signal an agent uses to decide when a confirmation is warranted, so marking a write tool read-only is how a refund gets filed without anyone being asked.5
start_return is not the end of the flow. If the resolution is an exchange, nextStep sends the agent to start_exchange: same lineItemId, the returnId from the call that just ran, a newSize from an enum rather than a word like medium. Stable IDs, allowed values, which field is which. That is what the visible UI does not say.
One caution about the schema
It is a contract, not a boundary. An enum tells a cooperating agent which reason codes exist. It does not stop a confused or hostile caller from posting something else, and prompt injection against agentic systems is a demonstrated attack rather than a hypothetical one.5 Validate on the server exactly as you would for the form, and mark anything that returns user-generated content with untrustedContentHint so the agent knows the payload deserves suspicion.
The pitch
Do not make agents parse your UI. Publish the actions you already have, using the IDs and enums your backend already uses.
Do not make agents parse your UI.Publish the actions you already have.