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>
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>
-
toolnamenames the tool. -
tooldescriptiontells 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. -
toolparamdescriptionon 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"]
}
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));
}
});
Three details:
-
preventDefault()first. Without it, the form navigates, and the page that registered the tool goes away mid-call. -
The form's rules are the tool's rules. Because
maxand the<option>list are in the schema, Chrome checks them before your handler runs. An agent that asks for 99 kg getsForm validation failed: weight_kg: Value must be less than or equal to 30., and one that asks for zone"mars"getsInvalid value "mars" for parameter zone. The checks inestimate()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. -
The
typeofguard. In a browser without WebMCP,respondWithdoesn'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; }
window.addEventListener('toolactivated', (event) => {
if (event.toolName === 'estimate_shipping') {
document.getElementById('result').value =
'An agent filled this form. Check it, then press Estimate.';
}
});
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
nameisn't submitted with the form. Do that on purpose, and say it intooldescription("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>
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:
- The tool is there, with the schema from Step 2.
const [tool] = await document.modelContext.getTools();
JSON.parse(tool.inputSchema);
tool.name is estimate_shipping, and the schema has minimum, maximum, the two enums and required: ["weight_kg", "zone"].
- 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' }));
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.
Invalid calls are rejected by the form. Try
{ weight_kg: 99, zone: 'eu' }and then{ weight_kg: 1, zone: 'mars' }. Both reject, withForm validation failed: weight_kg: Value must be less than or equal to 30.andInvalid value "mars" for parameter zone. Your handler never runs.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' }));
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.
- 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)