Skip to content

Declarative API Fit for JS-Driven Forms #138

@dgp1130

Description

@dgp1130

👋 Hi, Angular maintainer here. I've been exploring a framework-level integration with the WebMCP spec in the Chrome early access program and we had some thoughts on the applicability of the declarative API and shared them with Chrome who wanted us to share more broadly.

TL;DR: Our current view is that the declarative forms API is probably not a great fit for Angular (and potentially JS frameworks / JS-driven forms more broadly). Such systems should consider using registerTool directly based on their raw JS form data model instead.

Driving forms systems from models in JS means the rendered <form> / <input> elements are potentially just one consumer of a more expressive form model known to the framework. The most straightforward example I can think of is adding a list of users. In Angular using our newest signal forms implementation, this looks like (demo):

import { Component, signal } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { applyEach, form, FormField, required } from '@angular/forms/signals';

interface User {
  name: string;
}

@Component({
  selector: 'app-root',
  template: `
    <form (submit)="submit($event)">
      <ul>
        @for (user of userForm.users; track user.name()) {
          <li><input type="text" [formField]="user.name" /></li>
        }
      </ul>

      @if (userForm().invalid() && userForm().touched()) {
        <ul>
          @for (error of userForm().errorSummary(); track error) {
            <li>{{ error.message }}</li>
          }
        </ul>
      }

      <button type="button" (click)="addUser()">Add user</button>
      <button type="submit">Submit</button>
    </form>
  `,
  imports: [FormField],
})
export class App {
  protected readonly model = signal({ users: [{ name: '' }] as User[] });
  protected readonly userForm = form(this.model, (f) => {
    applyEach(f.users, (user) => {
      required(user.name, { message: 'Name is required.' });
    });
  });

  protected addUser(): void {
    this.model.update((m) => ({ ...m, users: [...m.users, { name: '' }] }));
  }

  protected submit(evt: SubmitEvent): void {
    evt.preventDefault();

    console.log(
      `Submitted ${this.model()
        .users.map((u) => u.name)
        .join(', ')}!`
    );
  }
}

bootstrapApplication(App);

In this example, the user is allowed to submit any number of user objects, each of which has a required name. At any given time, the <form> element has a fixed number of <input> elements, but there are other controls on the page to add more users and generate more <input> fields dynamically, which is not visible to the WebMCP agent. When it looks at this form, it just sees a single input with a single user name, and has no visibility into the fact that multiple users are allowed. If we update the <input name="..."> attribute for each field, the agent is effectively working with:

{
  "name": "add-users",
  "description": "Adds users to the database",
  "inputSchema": {
    "type": "object",
    "properties": {
      "name0": {"type": "string"},
      "name1": {"type": "string"}
    },
    "required": ["name0", "name1"]
  }
}

When the user asks it to submit 5 users, the agent's only path here is to scrape the page to find the "Add user" button, click it up to 5 times, notice that the generated form tool has updated to support name0 - name4, and then invoke that tool with all 5 inputs. This is less efficient for the agent (more tokens consumed, more interactions), the page (need to render intermediary steps), and the user (slower response, might interrupt other actions on the page).

Going one step further, if the user then changes their mind and asks to submit 3 users, the agent is seeing a form with 5 required fields, yet they're only required because multiple users have been added. The agent needs to figure out it can click a "Remove user" button to delete that field, and reduce down to only 3 required users. The schema is actively misleading the agent and the agent needs to understand that it can effectively "mutate" that schema by interacting with the rest of the page. That's not typically how "schemas" work.

While the <form> and <input> tags rendered to the page don't know there's more to the form, the core data model they are driven by potentially does. In the above example, we have {users: User[]} as the foundational data model. And through evaluating the applyEach and required schemas, we actually know this relationship at runtime.

The actual MCP schema this form should generate is something like:

{
  "name": "add-users",
  "description": "Adds users to the database",
  "inputSchema": {
    "type": "object",
    "properties": {
      "users": {
        "type": "object",
        "array": true,
        "properties": {
          "name": {"type": "string"}
        },
        "required": ["name"]
      }
    }
  }
}

This is roughly a reflection of the actual {users: User[]} form model, not the rendered <form> and <input> elements.

This mental model can also support use cases which don't strictly use <input> elements to capture user data. Developers might create custom controls for inputting complex data, and since they rely on the framework form system for managing and rendering that content, they don't strictly need <input> elements to achieve that goal. Any such field would be invisible to the AI using an automatically generated WebMCP tool.

While I'm discussing Angular as the thing I'm most concerned with, I think all of these points are potentially relevant to 1) other JS frameworks and 2) any JS-driven form system. Any codebase which uses a JS object as the source of truth for its form system potentially has much more insight into the foundational data model at play than scraping <form> and <input> tags would ever be able to give you. Certainly it depends on the DX of any particular system (ex. if that information is stored in TypeScript and erased at runtime, it might be hard to action on), but I'd be curious to know how maintainers of other frameworks / libraries feel about this.

We're ultimately still exploring this space ourselves, but our current thinking is that it might be better for Angular to intentionally disable / block the WebMCP declarative forms API, and then instead create our own invocation of navigator.modelContext.registerTool based on the raw form data model. We believe this could result in a more comprehensive and usable tool definition for the same amount of effort from developers.

Note that I'm not trying to argue the declarative form API shouldn't exist or that no one should use it. If a form is primarily driven by its rendered HTML and not a JS data model, or if we want to provide some out-of-the-box or minimal effort support for the millions of existing <form> tags out there, this very well could be an excellent fit. I just wanted to share our current thinking that this might not be a great fit for JS-driven forms specifically.

/cc @arick @leonsenft @kirjs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions