Build declarative WebMCP tools with HTML form attributes

Kashish Hora

Kashish Hora

Co-founder of MCPcat

Try out MCPcat

The Quick Answer

Add toolname and tooldescription to any <form> element to make it discoverable by AI agents. The browser synthesizes a JSON Schema from the form's inputs and registers it as an MCP tool automatically:

<form toolname="book_table"
      tooldescription="Book a restaurant table for a given date, time, and party size"
      toolautosubmit>

  <input type="text" name="name" required
         toolparamdescription="Guest's full name" />

  <input type="date" name="date" required
         toolparamdescription="Reservation date (YYYY-MM-DD)" />

  <select name="guests" required
          toolparamdescription="Number of guests (1-6)">
    <option value="1">1</option>
    <option value="2" selected>2</option>
    <option value="3">3</option>
    <option value="4">4</option>
    <option value="5">5</option>
    <option value="6">6</option>
  </select>

  <button type="submit">Reserve</button>
</form>

The browser reads those attributes, builds a JSON Schema with name as a required string, date as a date-formatted string, and guests as an enum — then registers the result as a tool agents can call. No JavaScript, no build step, no registerTool() call required.

Prerequisites

  • A website served over HTTPS (or localhost for development)
  • A Chromium-based browser with chrome://flags/#enable-webmcp-testing enabled
  • Basic HTML forms knowledge
  • Familiarity with WebMCP concepts (see registering your first WebMCP tool)

How the Browser Synthesizes Tools

When the browser encounters a <form> element with a toolname attribute, it does three things: reads the form-level attributes to build the tool's name and description, scans each form-associated element (<input>, <select>, <textarea>) to build a JSON Schema from their types and constraints, and registers the result as an MCP tool via navigator.modelContext internally.

The form from the Quick Answer produces this JSON Schema:

{
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "description": "Guest's full name"
    },
    "date": {
      "type": "string",
      "format": "date",
      "description": "Reservation date (YYYY-MM-DD)"
    },
    "guests": {
      "type": "string",
      "enum": ["1", "2", "3", "4", "5", "6"],
      "description": "Number of guests (1-6)"
    }
  },
  "required": ["name", "date", "guests"]
}

This is the same tool you'd get by calling navigator.modelContext.registerTool() yourself with the same schema. The declarative approach produces an identical registration — the browser just handles the wiring. Here's what the browser generates internally from those form attributes:

// The browser does this internally — you don't write this code
navigator.modelContext.registerTool({
  name: "book_table",
  description: "Book a restaurant table for a given date, time, and party size",
  inputSchema: { /* the JSON Schema above */ },
  execute: async (input) => { /* submits the form with input values */ }
});

The key insight is that <form> elements already have rich semantics for describing inputs — types, constraints, enums via <select>, and required fields. The browser leverages all of this to build the JSON Schema that agents need. It also watches the DOM, so if you add, remove, or modify form inputs dynamically, the tool's schema updates automatically without any re-registration logic.

With the synthesis pipeline clear, the next step is understanding the specific attributes that control it. Form-level attributes define the tool itself, while input-level attributes shape its parameters.

Form-Level Attributes

Three attributes control the tool definition on the <form> element:

<form toolname="search_products"
      tooldescription="Search the product catalog by keyword, category, and price range"
      toolautosubmit>
  <!-- inputs here -->
</form>

toolname (required) — The identifier agents use to reference this tool. This is also the opt-in signal: a form without toolname is never exposed as a tool. Use snake_case names that describe the action (search_products, book_table, submit_feedback). Names must be unique on the page.

tooldescription (required) — A natural language description of what the tool does. Agents rely on this to decide whether and when to invoke the tool. Be specific about the action and its inputs: "Search the product catalog by keyword, category, and price range" is better than "Search products". Both toolname and tooldescription are required — omitting either prevents tool registration.

toolautosubmit (optional) — A boolean attribute. When present, the agent can submit the form automatically without the user clicking the submit button. When absent (the default), the browser populates the form fields visually and waits for the user to review and submit manually. This is the core human-in-the-loop safety mechanism in WebMCP's declarative API.

Use toolautosubmit for read-only or low-risk tools like search, lookup, or navigation. Omit it for anything that modifies data, initiates transactions, or has side effects:

<!-- Safe: search is read-only, auto-submit is fine -->
<form toolname="search_products"
      tooldescription="Search the product catalog"
      toolautosubmit>
  <input type="text" name="query" required />
  <button type="submit">Search</button>
</form>

<!-- Unsafe: purchase changes state, require human confirmation -->
<form toolname="place_order"
      tooldescription="Place an order for items in the cart">
  <input type="hidden" name="cart_id" />
  <button type="submit">Place Order</button>
</form>

Input-Level Attributes

Each form input maps to a property in the tool's JSON Schema. Two WebMCP-specific attributes let you control how inputs appear in the schema:

<form toolname="search_flights"
      tooldescription="Search for available flights between two airports"
      toolautosubmit>

  <input type="text" name="from" required
         toolparamtitle="origin"
         toolparamdescription="Departure airport IATA code (e.g. SFO, LAX)" />

  <input type="text" name="to" required
         toolparamtitle="destination"
         toolparamdescription="Arrival airport IATA code (e.g. JFK, LHR)" />

  <input type="date" name="date" required
         toolparamdescription="Travel date" />

  <input type="number" name="passengers" min="1" max="9"
         toolparamdescription="Number of passengers (1-9)" />

  <button type="submit">Search</button>
</form>

toolparamdescription — The description for this property in the JSON Schema. Agents read this to understand what value to provide. If omitted, the browser falls back to the associated <label> element's text content, then aria-description. Always provide explicit descriptions for inputs without visible labels.

toolparamtitle — Overrides the name attribute as the property key in the JSON Schema. In the example above, name="from" would normally produce a schema property called from, but toolparamtitle="origin" changes it to origin. Use this when your HTML form uses short or legacy name values that aren't descriptive enough for agents.

Standard HTML attributes also affect the schema. The required attribute adds the input to the schema's required array. Constraints like min, max, minlength, maxlength, and pattern map to their JSON Schema equivalents. Inputs without a name attribute are excluded from the schema entirely.

HTML Input Types to JSON Schema Mapping

Beyond the WebMCP-specific attributes, the input's type attribute drives the JSON Schema type the browser generates. Choosing the right input type gives agents richer context about what values are expected:

HTML Input TypeJSON Schema TypeNotes
text, search, telstringBasic string
emailstring with format: "email"Email validation hint
urlstring with format: "uri"URL validation hint
number, rangenumberNumeric, min/max become minimum/maximum
datestring with format: "date"YYYY-MM-DD
datetime-localstring with format: "date-time"ISO 8601
timestring with format: "time"HH:MM
checkboxbooleanChecked = true
<select>string with enumEnum values from <option> elements
<select multiple>array of string with enumMultiple selections
<textarea>stringLong-form text
hiddenstringIncluded in schema but not visible in the UI

Choosing the right input type matters. An <input type="email"> tells the agent the field expects an email address — the format: "email" hint in the schema provides validation context that a plain type="text" wouldn't. Similarly, <input type="number" min="1" max="9"> gives the agent both the type and the valid range.

Radio buttons sharing the same name attribute produce a string property with enum values drawn from each radio's value attribute — the same behavior as a <select>.

Handling Agent Submissions

When an agent invokes a declarative form tool, the browser dispatches a standard submit event on the form. Two new properties on SubmitEvent let you distinguish agent submissions and return structured data:

const form = document.getElementById("reservationForm");

form.addEventListener("submit", (e) => {
  e.preventDefault();
  const data = new FormData(form);

  if (e.agentInvoked) {
    // Agent submitted — process and return structured result
    const reservation = processReservation(data);
    e.respondWith({
      confirmation: reservation.id,
      date: reservation.date,
      guests: reservation.guests
    });
    return;
  }

  // Human submitted — show confirmation UI
  showConfirmationModal(data);
});

SubmitEvent.agentInvoked — A boolean that is true when the submission was triggered by an AI agent, false for human clicks. This lets you branch your form handler to return structured data to agents while showing normal UI for humans.

SubmitEvent.respondWith(value) — Returns a value to the agent as the tool's output. The value is serialized and sent back as the tool result. You must call preventDefault() before respondWith() to suppress the default form action — otherwise the browser throws an exception. You can use async/await in your handler before calling respondWith(), which is useful when the response depends on an API call or computation:

form.addEventListener("submit", async (e) => {
  e.preventDefault();

  if (e.agentInvoked) {
    const results = await searchFlights(new FormData(form));
    e.respondWith({ flights: results });
    return;
  }

  // Human flow...
});

The agent's execution pauses until respondWith() is called, so you have time to do async work. Without respondWith(), the agent receives a generic success or failure signal but no structured data. Always use it when the agent needs to act on the result.

Styling Active Tool Invocations

Two CSS pseudo-classes provide visual feedback when an agent is interacting with a form:

form:tool-form-active {
  outline: 2px solid #6366f1;
  outline-offset: 4px;
  transition: outline 0.2s ease;
}

button[type="submit"]:tool-submit-active {
  background: linear-gradient(110deg, #6366f1 30%, #a5b4fc 50%, #6366f1 70%);
  background-size: 200% 100%;
  animation: shimmer 2s infinite linear;
  color: white;
}

button[type="submit"]:tool-submit-active::after {
  content: "Agent submitting...";
  display: block;
  font-size: 0.75rem;
  opacity: 0.8;
}

@keyframes shimmer {
  from { background-position: 200% 0; }
  to { background-position: -200% 0; }
}

:tool-form-active — Applied to the <form> element while the agent is actively filling in fields. Use it to add a visible border, highlight, or glow so the user can see which form the agent is operating on.

:tool-submit-active — Applied to the submit button while the agent is about to submit. This is useful for showing a loading state, disabling the button visually, or displaying a "reviewing" badge.

These pseudo-classes are new to browsers and may not be recognized by CSS build tools that validate selectors at compile time. If your CSS pipeline strips unknown pseudo-classes, inject these rules via runtime JavaScript or add them in a <style> block in the HTML.

Testing Declarative Tools

Use the navigator.modelContextTesting API (enabled by the WebMCP testing flag) to verify your forms register correctly and respond as expected.

Open DevTools on a page with your declarative form and list all registered tools to confirm it appears:

const tools = navigator.modelContextTesting.listTools();
console.log(tools);
// [{name: "book_table", description: "Book a restaurant...", inputSchema: "{...}"}, ...]

Each entry in the returned array contains name, description, and inputSchema (as a JSON string). If your form's tool doesn't appear, check that both toolname and tooldescription are set and that the form is currently in the DOM. Tools from forms that haven't been rendered yet (e.g., behind a conditional) won't show up.

Execute the tool with test input to verify the submit handler and respondWith() work:

const result = await navigator.modelContextTesting.executeTool(
  "book_table",
  JSON.stringify({ name: "Jane Doe", date: "2026-04-15", guests: "4" })
);
console.log(result);

Note that executeTool takes a JSON string for the input, not an object. The result is also returned as a string. For a dedicated testing workflow, see testing WebMCP tools with the Model Context Tool Inspector.

You can also listen for tool changes to confirm re-registration happens when you modify the DOM. This is useful during development when you're dynamically adding or removing form inputs and want to verify the schema updates correctly:

navigator.modelContextTesting.addEventListener("toolchange", () => {
  console.log("Tools updated:", navigator.modelContextTesting.listTools());
});

Add this listener in DevTools before making DOM changes. Each time the browser detects a form mutation that affects a tool's schema, the callback fires with the updated tool list.

Declarative vs Imperative: When to Use Each

The declarative approach (form attributes) and the imperative approach (navigator.modelContext.registerTool()) produce identical tool registrations. Choose based on your use case:

Use declarative when:

  • You have existing HTML forms you want to expose as tools with minimal changes
  • The tool maps naturally to a form (search, booking, feedback, settings)
  • You want progressive enhancement — the form works for humans and agents
  • You want zero JavaScript for the tool registration itself
  • You're working with a CMS or static site where adding script is inconvenient

Use imperative when:

  • The tool's schema is dynamic (changes based on user state or API responses)
  • You need complex validation beyond what HTML attributes express
  • The tool doesn't map to a form (e.g., "get current cart total", "screenshot viewport")
  • You want full control over the execute callback's behavior
  • You're using a framework like React (where webmcp-react's useMcpTool hook is more natural)

Both approaches can coexist on the same page. A common pattern is to use declarative tools for standard forms and imperative tools for actions that don't have a visual form representation.

Common Issues

Form not appearing as a tool

Both toolname and tooldescription are required. If either is missing, the browser won't register the form as a tool. Check that the attributes are on the <form> element itself, not on a wrapper <div> or the submit button. Verify with navigator.modelContextTesting.listTools() in the console.

Agent fills the form but doesn't submit

This is the default behavior when toolautosubmit is absent. The browser populates the fields and waits for the user to click submit. If you want automatic submission, add the toolautosubmit attribute to the form. Only do this for read-only or low-risk operations.

Input not appearing in the schema

Every input needs a name attribute to be included in the JSON Schema. Inputs without name are excluded silently. Check your HTML — a common mistake is using id without name, or having a typo in the name attribute.

navigator.modelContext is undefined

The most common cause is the WebMCP flag not being enabled. Navigate to chrome://flags/#enable-webmcp-testing and set it to "Enabled", then restart the browser. The API also requires a secure context (HTTPS or localhost) — plain HTTP pages won't have access. If you're running inside an iframe, test in a top-level page first as some builds restrict iframe access. Use if ("modelContext" in navigator) to avoid errors on unsupported browsers.

Schema property name doesn't match expectations

The property key in the JSON Schema defaults to the input's name attribute. If your form uses short or legacy names (like q for a search query), use toolparamtitle to set a more descriptive key. For example, toolparamtitle="search_query" overrides name="q" in the schema.

Examples

Product Search with Auto-Submit

A product search form that agents can invoke directly and receive structured results. Since search is read-only, toolautosubmit is appropriate here.

<form id="productSearch"
      toolname="search_products"
      tooldescription="Search the product catalog by keyword and category"
      toolautosubmit>

  <input type="text" name="query" required
         toolparamdescription="Search keywords" />

  <select name="category"
          toolparamdescription="Product category to filter by">
    <option value="all">All Categories</option>
    <option value="electronics">Electronics</option>
    <option value="clothing">Clothing</option>
    <option value="books">Books</option>
  </select>

  <input type="number" name="max_price" min="0"
         toolparamdescription="Maximum price in USD" />

  <button type="submit">Search</button>
</form>
document.getElementById("productSearch").addEventListener("submit", async (e) => {
  e.preventDefault();
  const data = Object.fromEntries(new FormData(e.target));
  const results = await searchProducts(data);

  if (e.agentInvoked) {
    e.respondWith({ products: results, count: results.length });
    return;
  }

  renderSearchResults(results);
});

The same submit handler serves both agents and humans. Agents get structured JSON via respondWith(); humans see rendered results in the UI. The form's <select> becomes an enum in the schema, constraining the agent to valid categories.

Contact Form with Human Review

A contact form where the agent fills the fields but the user must review and submit manually. This pattern works well for forms with side effects (sending an email) where you want the user to verify the content before it goes out.

<form id="contactForm"
      toolname="fill_contact_form"
      tooldescription="Fill the contact form with a name, email, subject, and message">

  <input type="text" name="name" required
         toolparamdescription="Sender's full name" />

  <input type="email" name="email" required
         toolparamdescription="Sender's email address" />

  <input type="text" name="subject" required
         toolparamdescription="Message subject line" />

  <textarea name="message" required minlength="10"
            toolparamdescription="Message body (minimum 10 characters)">
  </textarea>

  <button type="submit">Send Message</button>
</form>

No toolautosubmit means the agent fills in the fields and the form is brought into focus with the populated values, but the submit button remains for the user to click. The minlength="10" constraint on the textarea maps to minLength: 10 in the JSON Schema, guiding the agent to provide a sufficiently detailed message. The type="email" on the email input adds format: "email" to the schema, so the agent knows the expected format.

Next Steps

This guide covered the declarative path to WebMCP tools. For the imperative API with full control over schemas and execution, see registering your first WebMCP tool. If you're building with React, webmcp-react wraps the imperative API in hooks with Zod schemas and automatic lifecycle management. For tools that need explicit user confirmation before executing sensitive actions, see building a confirmation flow with requestUserInteraction.