DEV Community

Cover image for Context Over MCP: Part V The Form Is the Tool.
wolfejam.dev
wolfejam.dev Subscriber

Posted on

Context Over MCP: Part V The Form Is the Tool.

Part IV showed a WebMCP page with three tools. The companion walked through
building one yourself, the JavaScript way: registerTool(), a schema, a
handler. It mentioned the other way in one sentence and moved on.

This is the other way. No registerTool(), and no schema written by hand.
It's an HTML form, with a few attributes that tell the browser it's also a tool.

The playground from Part IV is now listed in the W3C Web Machine Learning
group's awesome-webmcp,
under Demos. That page is the production version of what you're about to build.

Save a blank index.html, serve it with python3 -m http.server, and open
http://localhost:8000 in Chrome with chrome://flags/#enable-webmcp-testing
turned on. Opened straight from disk, the page has no origin and the tool won't register.

Step 1 — start with a form that already works

Before it's a tool, it's a form a person can use. This one estimates a
shipping price. It's read-only: it calculates a number and places no order.

<form id="ship">
  <label>Weight (kg)
    <input name="weight_kg" type="number" min="0.1" max="30" step="0.1" required>
  </label>
  <label>Destination
    <select name="zone" required>
      <option value="domestic">Domestic</option>
      <option value="eu">EU</option>
      <option value="world">Rest of world</option>
    </select>
  </label>
  <label>Speed
    <select name="speed">
      <option value="standard">Standard</option>
      <option value="express">Express</option>
    </select>
  </label>
  <button type="submit">Estimate</button>
  <p>Result: <output id="result"></output></p>
</form>

<script type="module">
const RATES = { domestic: 4, eu: 9, world: 18 };

function estimate({ weight_kg, zone, speed }) {
  const w = Number(weight_kg);
  if (!Number.isFinite(w) || w < 0.1 || w > 30) {
    return { error: 'invalid_weight', message: 'weight_kg must be between 0.1 and 30' };
  }
  if (!Object.hasOwn(RATES, zone)) {
    return { error: 'invalid_zone', message: 'zone must be domestic, eu or world' };
  }
  const base = RATES[zone] + w * 1.5;
  const price = speed === 'express' ? base * 1.8 : base;
  return { price_eur: Math.round(price * 100) / 100, zone, speed: speed || 'standard' };
}

const form = document.getElementById('ship');
form.addEventListener('submit', (event) => {
  event.preventDefault();
  const result = estimate(Object.fromEntries(new FormData(form)));
  document.getElementById('result').value =
    result.error ? result.message : `€${result.price_eur}`;
});
</script>
Enter fullscreen mode Exit fullscreen mode

Submit it by hand. You get a price, and the URL doesn't change. This is the
same order as the companion's Step 2: the logic is a plain function before
anything calls it.

Step 2 — add three attributes, and it's a tool

<form id="ship"
      toolname="estimate_shipping"
      tooldescription="Estimate the shipping price for one parcel. Returns price_eur. Does not place an order."
      toolautosubmit>
  <label>Weight (kg)
    <input name="weight_kg" type="number" min="0.1" max="30" step="0.1" required
           toolparamdescription="Parcel weight in kilograms, 0.1 to 30">
  </label>
  <label>Destination
    <select name="zone" required toolparamdescription="Destination zone: domestic, eu or world">
      <option value="domestic">Domestic</option>
      <option value="eu">EU</option>
      <option value="world">Rest of world</option>
    </select>
  </label>
  <label>Speed
    <select name="speed" toolparamdescription="standard or express">
      <option value="standard">Standard</option>
      <option value="express">Express</option>
    </select>
  </label>
  <button type="submit">Estimate</button>
  <p>Result: <output id="result"></output></p>
</form>
Enter fullscreen mode Exit fullscreen mode
  • toolname names the tool.
  • tooldescription tells the agent what it does. Say what it doesn't do too ("does not place an order"), so the agent doesn't have to guess.
  • toolparamdescription on each control becomes that parameter's description.

You don't write the schema. The browser builds the tool's input schema from
the form's named controls, and it reads more of the form than you might
expect. Here's what Chrome 153 generated from the form above:

{
  "type": "object",
  "properties": {
    "weight_kg": { "type": "number", "minimum": 0.1, "maximum": 30, "multipleOf": 0.1,
                   "description": "Parcel weight in kilograms, 0.1 to 30" },
    "zone":      { "type": "string", "enum": ["domestic", "eu", "world"],
                   "description": "Destination zone: domestic, eu or world" },
    "speed":     { "type": "string", "enum": ["standard", "express"],
                   "description": "standard or express" }
  },
  "required": ["weight_kg", "zone"]
}
Enter fullscreen mode Exit fullscreen mode

min, max and step became minimum, maximum and multipleOf. Each
<select> became an enum (Chrome also adds each option's label as a
title, trimmed here). required became required. That's convenient, and
it's also the main thing to remember in Step 5.

Notice the <output> has an id and no name. That's deliberate: it shows
the result and isn't something the agent should fill in.

Step 3 — answer the agent, not just the screen

With toolautosubmit, the agent's call fills the fields and submits the form.
Your submit handler runs as usual. To hand the result back as the tool's
output, call respondWith() on the submit event:

form.addEventListener('submit', (event) => {
  event.preventDefault();
  const result = estimate(Object.fromEntries(new FormData(form)));
  document.getElementById('result').value =
    result.error ? result.message : `€${result.price_eur}`;
  if (typeof event.respondWith === 'function') {
    event.respondWith(Promise.resolve(result));
  }
});
Enter fullscreen mode Exit fullscreen mode

Three details:

  1. preventDefault() first. Without it, the form navigates, and the page that registered the tool goes away mid-call.
  2. The form's rules are the tool's rules. Because max and the <option> list are in the schema, Chrome checks them before your handler runs. An agent that asks for 99 kg gets Form validation failed: weight_kg: Value must be less than or equal to 30., and one that asks for zone "mars" gets Invalid value "mars" for parameter zone. The checks in estimate() are a second line of defense, for the day someone edits the form and forgets the function. Anything that gets past the form comes back as { error, message }, as in the companion's Step 3.
  3. The typeof guard. In a browser without WebMCP, respondWith doesn't exist, the guard skips it, and the form is still a working form.

Chrome also sets event.agentInvoked to true when an agent submitted the form,
if you want to behave differently for agents. Here, the answer is the same for
anyone who asks.

Step 4 — decide who presses Submit

toolautosubmit is a decision, not a default.

With it: the agent fills the fields and the form submits. That's right for
this tool: it's read-only and nothing happens that someone would want to review.

Without it: the agent fills the fields, and a person has to click Submit.
That's right for anything with consequences, and it's the reason the attribute
is opt-in.

One thing to know before you remove it: from the caller's side, that call
stays pending until someone submits or cancels. A conventional MCP client
talking to the page can't tell "waiting for a human" from "stuck" (see
webmcp#307). If a
person needs to confirm, make the waiting visible on the page.

Chrome gives you hooks for that:

form:tool-form-active { outline: 2px solid #00D4D4; }
button:tool-submit-active { box-shadow: 0 0 0 3px #00D4D4; }
Enter fullscreen mode Exit fullscreen mode
window.addEventListener('toolactivated', (event) => {
  if (event.toolName === 'estimate_shipping') {
    document.getElementById('result').value =
      'An agent filled this form. Check it, then press Estimate.';
  }
});
Enter fullscreen mode Exit fullscreen mode

toolactivated fires when the agent has filled the fields. It fires on
window, not on the form (a listener on the form never hears it), so check
event.toolName if the page has more than one tool.

If the page resets the form while the agent waits, the call ends with an
error: Tool execution cancelled by a form reset. Chrome's docs also describe
a toolcancel event; in Chrome 153 I didn't see it fire on a reset, so don't
depend on it yet.

Step 5 — the fields that should never become parameters

Step 2's convenience has a cost. Every named control becomes something an
agent can fill in. For a shipping form, that's fine. For a checkout, login or
identity form, it means card numbers, CVVs, one-time codes and national ID
numbers become agent-fillable parameters, and they end up in a transcript.
A checkout form with name="cvv" becomes a tool with a cvv parameter.

The spec doesn't have guidance for this yet
(webmcp#316 is open).
Until it does, a simple rule:

  • Don't make a sensitive form a tool. Make a smaller form that is one: "estimate", "check availability", "find a slot". Let the person handle the part with their card or code themselves.
  • If a form must be a tool, leave sensitive controls out of it. A control without a name isn't submitted with the form. Do that on purpose, and say it in tooldescription ("payment details are entered by the user").
  • Read-only tools can auto-submit. Anything else shouldn't.

The whole page

Everything from Steps 1–4 in one file, for copying:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Shipping estimate</title>
  <style>
    form:tool-form-active { outline: 2px solid #00D4D4; }
    button:tool-submit-active { box-shadow: 0 0 0 3px #00D4D4; }
  </style>
</head>
<body>
<form id="ship"
      toolname="estimate_shipping"
      tooldescription="Estimate the shipping price for one parcel. Returns price_eur. Does not place an order."
      toolautosubmit>
  <label>Weight (kg)
    <input name="weight_kg" type="number" min="0.1" max="30" step="0.1" required
           toolparamdescription="Parcel weight in kilograms, 0.1 to 30">
  </label>
  <label>Destination
    <select name="zone" required toolparamdescription="Destination zone: domestic, eu or world">
      <option value="domestic">Domestic</option>
      <option value="eu">EU</option>
      <option value="world">Rest of world</option>
    </select>
  </label>
  <label>Speed
    <select name="speed" toolparamdescription="standard or express">
      <option value="standard">Standard</option>
      <option value="express">Express</option>
    </select>
  </label>
  <button type="submit">Estimate</button>
  <p>Result: <output id="result"></output></p>
</form>

<script type="module">
const RATES = { domestic: 4, eu: 9, world: 18 };

function estimate({ weight_kg, zone, speed }) {
  const w = Number(weight_kg);
  if (!Number.isFinite(w) || w < 0.1 || w > 30) {
    return { error: 'invalid_weight', message: 'weight_kg must be between 0.1 and 30' };
  }
  if (!Object.hasOwn(RATES, zone)) {
    return { error: 'invalid_zone', message: 'zone must be domestic, eu or world' };
  }
  const base = RATES[zone] + w * 1.5;
  const price = speed === 'express' ? base * 1.8 : base;
  return { price_eur: Math.round(price * 100) / 100, zone, speed: speed || 'standard' };
}

const form = document.getElementById('ship');
const out = document.getElementById('result');

form.addEventListener('submit', (event) => {
  event.preventDefault();
  const result = estimate(Object.fromEntries(new FormData(form)));
  out.value = result.error ? result.message : `€${result.price_eur}`;
  if (typeof event.respondWith === 'function') {
    event.respondWith(Promise.resolve(result));
  }
});

window.addEventListener('toolactivated', (event) => {
  if (event.toolName === 'estimate_shipping') {
    out.value = 'An agent filled this form. Check it, then press Estimate.';
  }
});
</script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Step 6 — verify it before you add anything else

In Chrome with the WebMCP flag on, document.modelContext also has
getTools() and executeTool(), so you can play the agent from the console.
Run these in order:

  1. The tool is there, with the schema from Step 2.
   const [tool] = await document.modelContext.getTools();
   JSON.parse(tool.inputSchema);
Enter fullscreen mode Exit fullscreen mode

tool.name is estimate_shipping, and the schema has minimum, maximum, the two enums and required: ["weight_kg", "zone"].

  1. A valid call returns a value, and the page stays put.
   await document.modelContext.executeTool(tool,
     JSON.stringify({ weight_kg: 2, zone: 'eu', speed: 'standard' }));
Enter fullscreen mode Exit fullscreen mode

It returns '{"price_eur":12,"zone":"eu","speed":"standard"}', the page shows €12, and the URL doesn't change. Two things to note: executeTool takes the tool object from getTools(), not its name, and the result comes back as a JSON string.

  1. Invalid calls are rejected by the form. Try { weight_kg: 99, zone: 'eu' } and then { weight_kg: 1, zone: 'mars' }. Both reject, with Form validation failed: weight_kg: Value must be less than or equal to 30. and Invalid value "mars" for parameter zone. Your handler never runs.

  2. Without autosubmit, the call waits for a person.

   document.getElementById('ship').removeAttribute('toolautosubmit');
   const pending = document.modelContext.executeTool(tool,
     JSON.stringify({ weight_kg: 5, zone: 'world', speed: 'express' }));
Enter fullscreen mode Exit fullscreen mode

The fields fill, the outline appears, the result line says an agent filled the form, and pending doesn't settle. Press Estimate: it resolves with '{"price_eur":45.9,"zone":"world","speed":"express"}' and the outline goes away.

  1. Nothing leaves the tab. Open DevTools → Network and repeat step 2. No requests.

(Checked in Chrome 153 with #enable-webmcp-testing on, 25 September 2026.)

The same shape, in production

fill_6ws on faf.one/webmcp is this pattern: a plain
form with toolname, tooldescription, toolautosubmit and a
toolparamdescription on each of its six fields. It calls
preventDefault(), answers with respondWith({ yaml }), and doesn't navigate.
It auto-submits for the same reason Step 4 gives: it builds text and changes nothing.

Pick by what the page already has. If it already has a form that does the
job, the form is the tool. If it doesn't, registerTool() is there.


Series: Part I — Invisible AGENTS.md? · Part II — Publishing to the Registry · Part III — Horses for Courses · Part IV — No Working Directory At All · Build a WebMCP Tool From Scratch.

Top comments (0)