Skip to content

Proposal: Dynamic Tool Definitions in WebMCP #167

Closed

Description

@AlpineJosh

Proposal to let tool definitions respond to state — so agents see tight, accurate schemas and up-to-date availability without pages re-pushing entire tool sets.

registerTool() assumes a tool's inputSchema and callability are fixed at registration. In state-driven applications, they aren't. This proposal lets inputSchema be resolved lazily at enumeration time, and introduces a disabled flag toggleable via updateTool() — preserving tool identity across state changes.

Strawman API

navigator.modelContext.registerTool({
  name: "play_track",
  description: "Play a track from the user's library.",
  inputSchema: () => ({
    type: "object",
    properties: { id: { type: "string", enum: library.trackIds() } },
    required: ["id"],
  }),
  async execute({ id }) { player.play(id); /* ... */ },
});

navigator.modelContext.registerTool({
  name: "remove_from_queue",
  description: "Remove a track from the playback queue.",
  disabled: true, // queue empty at boot
  inputSchema: { /* static */ },
  execute: async ({ position }) => { /* ... */ },
});

queue.on("change", (q) => {
  navigator.modelContext.updateTool("remove_from_queue", { disabled: q.length === 0 });
});

inputSchema as a function is invoked by the UA every time it materializes the tool for an agent (e.g. each model turn). Must be synchronous. Disabled tools stay in the registry — visible to introspection, hidden from the model, rejected on execute. updateTool() mutates disabled, description, or inputSchema; not execute, annotations, or name.

Problem

A music app registers playTrack, addToPlaylist, removeFromQueue. removeFromQueue is meaningless when the queue is empty. playTrack's id should enum over tracks actually in the library; addToPlaylist's playlistId should enum over playlists the user currently has. Hard-coding type: "string" invites hallucinated IDs and burns tokens on retries.

The existing workaround is to re-call provideContext() on every state change. This churns every tool's definition for a local change, races the agent, and — since the cost scales with (tools × state changes) — pushes developers toward over-broad schemas to avoid the overhead.

Design Considerations

  • Push vs. pull. Availability needs prompt reflection in agent UIs, so it's pushed (updateTool). Schema only matters at prompt-assembly time, so it's pulled (UA invokes the callback). This split matches how each piece of information is actually consumed.
  • Sync callbacks. Tool enumeration runs per model turn. An async resolver introduces a failure mode — pending resolution when the LLM is about to be prompted — worse than asking pages to keep derivation fast. Pages needing async (e.g. server-fetched enums) cache into state and push via updateTool, which reduces to the existing model exactly where push makes sense.
  • Identity preservation. updateTool keys by name and leaves execute/annotations/name immutable. Changing those amounts to replacing the tool and should go through unregisterTool + registerTool so identity is explicit — consistent with the direction set in #101 / #130.
  • Staleness. Pull doesn't eliminate the window between UA reads schema and model emits call; execute still validates defensively. It does make the window a function of enumeration cadence rather than of the page's re-registration choreography.

Open Questions

  • Does a grant to call a tool persist across a disabled: true/false toggle? (I think yes — disabled is availability, not identity.)
  • What does the UA do when a lazy inputSchema callback throws — silently treat the tool as unavailable, or surface the error (DevTools, event)?
  • Should toolchange (currently only on navigator.modelContextTesting) be promoted so agent UIs can reflect availability without polling?

Prior Art

  • MCP's notifications/tools/list_changed — the push counterpart; no lazy schema equivalent.
  • modelcontextprotocol/discussions#1384 — converges on partial returns + list_changed. WebMCP is positioned to do better because UA and provider are co-located and a sync callback is cheap.
  • Plotono's published WebMCP integration documents the "re-register on every state change" pattern this proposal is trying to absorb.

Activity

  1. domfarolino commented on Apr 23, 2026

    @domfarolino
    Collaborator

    The existing workaround is to re-call provideContext() on every state change. This churns every tool's definition for a local change, races the agent, and — since the cost scales with (tools × state changes) — pushes developers toward over-broad schemas to avoid the overhead.

    There is no provideContext() method. Instead, the author would just unregister and re-register the tool. That's not too bad, but I agree it might be more ergonomic to have an updateTool() method.

    • Sync callbacks. Tool enumeration runs per model turn.

    The timing of tool enumeration is not specified by WebMCP, and besides list_changed, I don't think MCP specifies when a model has to request tools/list. So I disagree with this.


    There seems to be two distinct parts of this proposal. First, the ability to update some things explicitly via updateTool(), including inputSchema. Second, the ability for inputSchema itself to change implicitly, via returning different values across invocations.

    I don't see how the latter is useful if we have the former. And I don't think it's important that inputSchema resolves synchronously if it's a function. No built-in browser agent is going to have synchronous access to it, so for changing input schemas.

    You're either making it a function so that the value can change across invocations (not needed if you have updateTool()), or you're making in a function so that its "real" value is "lazy", as to prevent flooding the context window which is a known issue with MCP tools—this last one is also not important as the code sitting in between the tool registry and the model's context can just do this manually, by holding back the inputSchema from the agent until it asks for it. Does that make sense?

  2. anssiko commented on Jun 25, 2026

    @anssiko
    Member

    @AlpineJosh, I invite you to join the Community Group. This allows you to make substantive contributions to be considered for inclusion in the specification.

  3. anssiko commented on Jun 25, 2026

    @anssiko
    Member

    RESOLUTION: Sketch out an API that allows updating non-model-exposed tool parts; table the rest until WebMCP consumers demonstrate a problem that demands updating model-exposed tool parts. (issue #167)

  4. arnabwithab commented on Aug 31, 2026

    @arnabwithab

    Happy to draft the spec delta for (1) (it's a small change to the duplicate-name check in registerTool steps) plus WPTs, if editors are good with the name-keyed direction vs. a separate key. @domfarolino does this match what you had in mind for updateTool() in #167, or did you want a distinct method?

  5. domfarolino commented on Aug 31, 2026

    @domfarolino
    Collaborator

    The more I think about this issue, the more I wonder if we should actually handle it at the spec/protocol level, or leave it to userland. The direction we mentioned above is basically an updateToolCallback() method (I think whether a tool is persistent-and-enabled, persistent-but-disabled, etc. should be handled by progressive disclosure via #255), and I'm not sure it's that useful? You could easily do this in userland via:

    document.modelContext.registerTool({
      name: "tool",
      description: "foo",
      execute: () => {
        window.actualImplementation();
      }
    });
    
    // Later when some state happens where you want to change what the tool does:
    
    window.actualImplementation = function(bar) {
      // This is the "new" implementation of the `tool` above.
    }

    Is there more to this issue that I'm missing, that compels us to implement something in the spec?

  6. arnabwithab commented on Sep 1, 2026

    @arnabwithab

    Agreed on the callback slice — indirection covers it, and I'd drop
    updateToolCallback() from the ask. A framework can close over a mutable ref
    and swap implementations without touching the registry (that's the standard
    React ref pattern). You're not missing anything there.

    The piece that's still spec-shaped: inputSchema and description are the
    agent's decision surface, and they're frozen at registration.
    The indirection
    pattern makes behavior fresh at execution time, but the agent reads
    schema/description at discovery time. When those are state-dependent, there's
    no userland fix:

    // addToCart's inputSchema enum is the currently in-stock variant IDs.
    // Stock changes -> the enum the agent already saw is stale -> it calls
    // with an out-of-stock ID -> wasted call, and (once #92 lands) a
    // validation-error retry loop against a schema that can never be right.

    The only way to refresh metadata today is unregister → register, which is
    exactly the churn §1 describes: two toolchange events, the tool blinks out of
    the agent's view, and the spec's own note flags the
    unregistration/re-registration race for in-flight calls.

    So the honest question for the group is which cost to bless:

    1. Document and move on: indirection for callbacks + re-register for schema
      changes, written up as the framework recipe. Zero spec.
    2. Minimal delta: allow same-name registration to patch metadata in place
      (schema/description only — execute stays userland's job per the pattern
      above), with toolchange firing only on an actual delta. Small change to the
      duplicate-name check in registerTool().

    (1) is a README section, (2) is a small spec diff + WPTs.
    And agreed that enabled/disabled belongs to #255, which also covers §3's
    pending/unavailable case.

  7. domfarolino commented on Sep 3, 2026

    @domfarolino
    Collaborator

    Great, I'm glad we're aligned on the problem statement @arnabwithab, thanks for summarizing. I slightly lean towards option (1) mostly because: while I can imagine use-cases where a tool's inputSchema enums might update dynamically, I don't imagine it's common or load-bearing. So far in our Origin Trial we haven't gotten feedback from real-world sites pining for this use case, so I'm hesitant to solve a problem until it's common in practice.

    I'll also note that MCP has the same limitation, though it's a little different. MCP lets tool authors send a list_changed message to agent clients whenever they want to advertise new tools. This puts the tool provider in charge of when the client is notified about new tools compared to the web, where this is kind of handled for you implicitly at tool unregistration and registration time.

    I'm most interested in getting feedback from model providers about this, especially those that ship browser extensions that may use WebMCP tools, to understand what tool update signals work best for them. Let me loop in @justinrushing + @nmccormack-oai, and @ekolve-ant + @jtang-ant.

    To briefly summarize the problem statement: Whenever tools are registered and unregistered, WebMCP fires the toolchange event at the document.modelContext object. Conceivably, agents can use this as a signal to grab and refresh its list of the site's tools. Unfortunately, there's no way to update the schema or description of a tool in-place. Your only option is to unregister the old tool (fires a toolchange event) and re-register it with the same name + updated properties (fires another toolchange event). Is this spurious event firing going to trip up agents or cause tool trashing?

    To add some anecdata: neither Gemini-in-Chrome nor Copilot-in-Edge use the toolchange event as a signal to refresh its view of the tools. Instead, their loops grab the full list of a page's tools on every model turn and diffs them. The website doesn't push tools to the agent. Therefore, unregistering and re-registering a tool is completely transparent to those agents, and there's no thrashing. But you can imagine extension agents or in-page agents that might operate differently, and that's what we'd love more insight on.

  8. beaufortfrancois commented on Sep 4, 2026

    @beaufortfrancois
    Collaborator
    • What would be the cons to make registerTool succeeds if tool name already exists, and overrides internal tool map?
    • Should we introduce a ModelContextRegisterToolOptions override option?
  9. domfarolino commented on Sep 4, 2026

    @domfarolino
    Collaborator

    What would be the cons to make registerTool succeeds if tool name already exists, and overrides internal tool map?

    Just that it becomes possible to silently squash other tools elsewhere in your site by accident. On large sites with dijoint development teams, it's not inconceivable that this could happen, and if we made this change, the squashing is a silent bug. To really protect against it you'd have to call getTools() before each registerTool().

    Should we introduce a ModelContextRegisterToolOptions override option?

    I'm less concerned with what shape the update mechanism takes than figuring out if we need it in the first place. But yeah an explicit override mitigates against the problem above which is nice, and doesn't require new methods etc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions