Repository navigation
Proposal: Dynamic Tool Definitions in WebMCP #167
Description
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.
updateToolkeys by name and leavesexecute/annotations/nameimmutable. Changing those amounts to replacing the tool and should go throughunregisterTool+registerToolso 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;
executestill 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/falsetoggle? (I think yes —disabledis availability, not identity.) - What does the UA do when a lazy
inputSchemacallback throws — silently treat the tool as unavailable, or surface the error (DevTools, event)? - Should
toolchange(currently only onnavigator.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
domfarolino commentedon Apr 23, 2026
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 anupdateTool()method.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 requesttools/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(), includinginputSchema. Second, the ability forinputSchemaitself 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
inputSchemaresolves 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?anssiko commentedon Jun 25, 2026
@AlpineJosh, I invite you to join the Community Group. This allows you to make substantive contributions to be considered for inclusion in the specification.
anssiko commentedon Jun 25, 2026
arnabwithab commentedon Aug 31, 2026
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?
domfarolino commentedon Aug 31, 2026
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:Is there more to this issue that I'm missing, that compels us to implement something in the spec?
arnabwithab commentedon Sep 1, 2026
Agreed on the callback slice — indirection covers it, and I'd drop
updateToolCallback()from the ask. A framework can close over a mutable refand 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:
inputSchemaanddescriptionare theagent'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:
The only way to refresh metadata today is unregister → register, which is
exactly the churn §1 describes: two
toolchangeevents, the tool blinks out ofthe 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:
changes, written up as the framework recipe. Zero spec.
(schema/description only — execute stays userland's job per the pattern
above), with
toolchangefiring only on an actual delta. Small change to theduplicate-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.
domfarolino commentedon Sep 3, 2026
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_changedmessage 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
toolchangeevent at thedocument.modelContextobject. 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 atoolchangeevent) and re-register it with the same name + updated properties (fires anothertoolchangeevent). 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
toolchangeevent 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.beaufortfrancois commentedon Sep 4, 2026
registerToolsucceeds if tool name already exists, and overrides internal tool map?overrideoption?domfarolino commentedon Sep 4, 2026
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 eachregisterTool().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
overridemitigates against the problem above which is nice, and doesn't require new methods etc.