Engineering

We gave our contact form to the browser's agent. It thought nothing was required.

WebMCP turns a plain HTML form into a tool an in-browser agent can call. We shipped it on a production Next.js site and found four things the spec does not tell you.

WTM Studio6 min read

WebMCP is a proposed web standard, currently in a Chrome origin trial, that lets a page publish tools an agent can call. It is experimental and it may change or go away. We put it on wethemakers.dev anyway, on real forms with real validation, because we wanted to see what the browser does with a production form rather than a demo. This is what it did.

The problem WebMCP is trying to fix

When an agent uses a website today it takes a screenshot, guesses which box is the email field, clicks, types, and hopes. Google calls this actuation. Each step is a guess, and guesses compound. A step that is right nine times in ten, run six times in a row, is a coin flip.

The failure is rarely a crash. It is a form submitted with the wrong service selected and a success message, and nobody on either end knows a machine got it wrong.

WebMCP replaces the guessing with a contract. The page says: here are the things you can do here, and here is exactly what each one needs. The agent calls a function instead of reading a screen.

There are two ways to declare a tool. Add two attributes to an existing form and the browser does the rest. Or register a function in JavaScript for anything that is not a form. We used both.

WebMCP is a browser API. It is not the same transport as the MCP servers that Claude and ChatGPT talk to, despite the name. Two agents can call it today: Gemini in Chrome, and since late August, ChatGPT and Codex inside the ChatGPT desktop app's built-in browser. Claude in Chrome does not, and the request to add it was closed as not planned.

What two attributes bought us

Our contact form is ordinary HTML. Name, email, phone, company, a service dropdown, a budget dropdown, a timeline dropdown, a message. We added this to the form tag:

<form toolname="submitProjectEnquiry"
      tooldescription="Send a project enquiry to WeTheMakers ...">

No script. No schema written by hand. Here is what Chrome derived from the form's own fields and handed to the agent:

{
  "name": "submitProjectEnquiry",
  "description": "Send a project enquiry to WeTheMakers ...",
  "inputSchema": {
    "type": "object",
    "properties": {
      "name":     { "type": "string" },
      "email":    { "type": "string" },
      "phone":    { "type": "string" },
      "company":  { "type": "string" },
      "service":  { "enum": ["Build a product", "AI Enablement & Readiness",
                             "Fractional CTO", "Staff Augmentation",
                             "DevOps Consulting", "Fractional Product Manager",
                             "Campaign Technology", "SEO Management",
                             "Partnership", "Something else"] },
      "budget":   { "enum": ["Under $10k", "$10k-$25k", "$25k-$50k",
                             "$50k-$100k", "$100k-$150k", "$150k+"] },
      "timeline": { "enum": ["ASAP (< 1 month)", "1-3 months", "3-6 months",
                             "6+ months", "Ongoing / flexible"] },
      "message":  { "type": "string" }
    },
    "required": ["name", "email", "service", "message"]
  }
}

The dropdowns became enums, so an agent cannot submit a service we do not offer. The required attributes became the required list. Each name attribute became a property.

That is the version after the fix. The first version the browser produced had an empty required list.

Finding one: a form that validates in JavaScript looks optional to a machine

Our forms use react-hook-form. Every required field is declared like this:

<input {...register('email', { required: true })} />

That is how the library validates. It is not how the browser knows anything. register never sets the required attribute on the element, and every one of our forms has noValidate on it so the browser stays out of validation entirely. Which is the correct setup for a React form, and it meant the DOM carried no information about what was required.

So the first schema Chrome derived said an agent could call submitProjectEnquiry with a name and nothing else. The form would have refused the submission when the human pressed the button, but the agent would already have decided the call was valid.

The fix is one attribute per field:

<input required {...register('email', { required: true })} />

Nothing visible changes. noValidate still suppresses the browser's validation bubbles and react-hook-form still owns the error messages. The attribute is there for the machine reading the DOM, and for screen readers, which should have had it all along.

This is the finding worth taking from the post. The derived schema is only as honest as the HTML. If your validation lives in JavaScript, the browser cannot see it, and neither can the agent.

Finding two: the API moved while we were building

We started on Chrome 151. The API lived at navigator.modelContext, and there was a separate navigator.modelContextTesting object with a listTools() call that showed the agent's view, including the declarative form tools.

Chrome updated itself to 152 partway through. On 152, navigator.modelContext does not exist. The object is now document.modelContext. The testing view is gone. Declarative tools now show up in getTools() directly. And executeTool no longer takes a tool name; it takes the tool object that getTools() returned.

Our code reads whichever shape is present:

const mc = document.modelContext ?? navigator.modelContext

This is what an origin trial means in practice. The surface changes between weekly Chrome releases, and it changed under us within a single afternoon. Test against the real browser on the machine, not against the spec text.

Finding three: ChatGPT ignores the part that worked best

Two days after we shipped the forms, we read OpenAI's documentation for site tools properly. One line: "Tools defined through HTML form attributes aren't available as site tools." ChatGPT's browser only sees tools registered from JavaScript.

So the declarative path, the one where Chrome derived that whole schema from two attributes, is invisible to ChatGPT. On our site it saw one tool, the case-study search, and none of the five forms.

The fix is a small hook that registers each form a second time, imperatively, with a schema built from the same DOM the same way Chrome builds it. It waits about a second for the browser's own registration and defers to it if one appears, because Chrome rejects a second tool with the same name. On Chrome nothing changes. On ChatGPT the forms exist.

The imperative execute fills the visible form and stops. It never submits. That was a choice: the person sees their details land in the real form and presses the button. It is also how Chrome's own declarative tools behave, which we learned the slow way, because executeTool on a declarative tool returns a promise that settles only when a human submits. In a headless test that promise never settles at all.

Finding four: registration is asynchronous

Immediately after page load, the tool list is empty. Within about a second, the form tools appear. A test that reads the list once, synchronously, reports zero tools and looks like a bug in your code. Poll.

The tool that is not a form

Forms cover submitting. They do not cover asking. The question an agent is most likely to be asked about a studio is "have they built anything for X", and we have 30 case studies that no agent could query without scraping the grid.

So there is one JavaScript tool, registered on every page:

document.modelContext.registerTool({
  name: 'findRelevantWork',
  description: 'Search WeTheMakers case studies by industry, product type, or technology ...',
  inputSchema: {
    type: 'object',
    properties: { query: { type: 'string' }, limit: { type: 'integer' } },
    required: ['query'],
  },
  async execute({ query, limit }) { /* fetch /api/work, score, return matches with links */ },
})

The case-study index comes from a small static route, fetched on the first call. Pages ship no extra bytes. Nothing is paid for until an agent asks.

How we test it

A dependency-free Node script launches the Chrome already installed on the machine with --enable-experimental-web-platform-features, drives it over the DevTools protocol, opens each page, waits for registration, and asserts that every tool is listed with the required fields we expect. On the case-study page it also calls findRelevantWork("fintech") and checks the answer names a fintech project.

To cover the ChatGPT path, two more runs rewrite the HTML on the wire so Chrome never sees the toolname attribute, then assert the imperative tool registers with the same schema, call it, and read the filled values back off the page.

Eight runs, six tools, all passing on Chrome 152. About four hours over two days including the investigation. Most of it went on findings one and three.

What this is and is not

It is not an SEO lever. Lighthouse has an Agentic Browsing category that reports a pass ratio, not a score, and Google has said it is gathering data rather than ranking on it.

It reaches people running an agent inside Chrome or inside the ChatGPT desktop app, and nobody else today. Firefox and Safari have not signaled a position. If the trial ends without the feature shipping, the origin trial token expires and the forms are forms again. Nothing breaks.

We did it because the cost was small and the attributes are inert everywhere else, and because we wanted to know what the browser actually does with a real form. Now we know: Chrome does exactly what the HTML tells it, and ChatGPT does not read the HTML at all.