Skip to content

Introduce the Tool interface - #348

Open
domfarolino wants to merge 11 commits into
mainfrom
tool-interface
Open

domfarolino wants to merge 11 commits into
mainfrom
tool-interface

Conversation

@domfarolino

@domfarolino domfarolino commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

This PR introduces the Tool interface which encapsulates a tool's new UUID. Tools are now keyed and identified by this ID. This replaces the RegisteredTool dictionary with the new Tool interface, fixes a number of possible race conditions (such as https://webmachinelearning.github.io/webmcp/#unregistration-execution-race), and also unblocks us from being able to execute registered by opaque origin documents (see #349).

Note that there are some compatibility changes with this:

  • RegisteredTool#inputSchema and RegisteredTool#annotations could be undefined before this PR, where as Tool#inputSchema and Tool#annotations are not object? which makes more sense for an interface object. This means these two members can be null when their respective values are not provided. They are never purely undefined now.
  • JSON.stringify(tool) returns {}, and the spread operator copies nothing.
  • structuredClone(tool) throws, since this type is not yet [Serializable].

This makes some progress towards the discussion in #236 (/cc @jakearchibald), and helps us out with future worker integration.

/cc @nmccormack-oai from who was interested in this fix from an agent's perspective. Note that the spec still doesn't define a native-agent-initiated tool execution path (equivalent to CDP-initiated tool execution in Chromium, or Gemini-in-Chrome/"actor stack"-initiated tool execution in branded Chrome), but that's coming soon, and the changes here are required to support the correctness / race fixes in those areas too.


Preview | Diff

Comment thread index.bs
@domfarolino
domfarolino marked this pull request as ready for review October 16, 2026 13:35

@markafoltz markafoltz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks pretty good!

Comment thread index.bs
Comment thread index.bs
Comment thread index.bs Outdated
Comment thread index.bs
Comment thread index.bs Outdated
Comment thread index.bs
Comment thread index.bs
Comment thread index.bs
Comment thread index.bs
@kenchris

Copy link
Copy Markdown

Binding a Tool to one registration is useful for agents. If an agent approves a tool and calls it later, a Tool for an unregistered tool fails to execute even when the same name is registered again. Before, a page could swap the tool under the same name and the old approval would still apply.

A question from the extension side. We discover tools from a content script and have to pass them on to a service worker and an extension page before the model calls one, so a tool has to cross a few contexts. Since structuredClone(tool) throws and the id isn't exposed, I think an extension can only copy the fields across and then has no way to point back at that specific registration, and would be back to matching on name and origin. I expect the WebExtensions API work that @dotproto proposed in the TPAC breakout (w3c/tpac2026-breakouts#84) will take care of some of this, because an extension API could hand out its own handles. But some of it will probably still be needed here, so is [Serializable] meant to keep the binding when it's cloned (so the clone still refers to the same registration)? If so that would solve it for us. Otherwise some opaque id that can be passed around and given back to executeTool() would work too. I'm only asking whether it's meant to be possible, I don't need a specific shape.

A smaller one: as far as I can tell there is no way to change a registered tool, so a changed description is a new Tool with a new id. That seems fine, but a host that wants to ask the user again when an approved tool changes (#262) would need to match the new one to the old one by name, so it may be worth mentioning how the two relate.

Comment thread index.bs
@domfarolino

Copy link
Copy Markdown
Collaborator Author

Thanks for the reviews! I'll hold off on landing this for a little bit to give @jakearchibald more time to take a look.


A question from the extension side. We discover tools from a content script and have to pass them on to a service worker and an extension page before the model calls one, so a tool has to cross a few contexts. Since structuredClone(tool) throws and the id isn't exposed, I think an extension can only copy the fields across and then has no way to point back at that specific registration, and would be back to matching on name and origin.

This was broken before this PR too, right @kenchris? (Since window is not serializable).

I expect the WebExtensions API work that @dotproto proposed in the TPAC breakout (w3c/tpac2026-breakouts#84) will take care of some of this, because an extension API could hand out its own handles. But some of it will probably still be needed here

Extensions-specific work or something of the sort will take care of this, right. I don't think it'll propagate to any spec changes here, although we may independently make Tool a [Serializable] object anyways (see the discussion in #236).

@kenchris

Copy link
Copy Markdown

Yes, same before, window wasn't cloneable either. It's not a regression, I was only asking whether it's meant to be possible. Good to hear Tool may become [Serializable] with #236. If it does, I'd just want the clone to still point at the same registration, so a host can tell which one it is. Nothing needed in this PR from my side.

Comment thread index.bs
:: |tool definition|'s [=tool definition/description=]

: {{RegisteredTool/inputSchema}}
: [=Tool/input schema=]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inputSchema and annotations objects are created inside the getTools() task with parse a JSON string to a JavaScript value and dictionary → JS conversion. Both use the current realm, which is undefined in a task and may not be the Tool's realm.

How about we store the input schema string and the annotations struct on the Tool. The getters would then create the objects on first access in [=this=]'s relevant realm (via realm.[[Intrinsics]]) and cache them.

Here's how AI thinks we could fix this issue:

  • Tool members: replace input schema (object) with serialized input schema (the string from the tool definition) plus an input schema object cache (initially null). Make annotations an [=annotations=] struct plus an annotations object cache.
  • getTools() task: just copy the tool definition's input schema string and annotations struct. The task then creates no JS objects other than the Tools themselves.
  • inputSchema getter: return null if the string is empty. Otherwise, on first access, set the cache to ! Call(realm.[[Intrinsics]].[[%JSON.parse%]], undefined, « serialized input schema »), where realm is [=this=]'s relevant realm. Return the cache.
  • annotations getter: return null if the struct is null. Otherwise, on first access, build OrdinaryObjectCreate(realm.[[Intrinsics]].[[%Object.prototype%]]) with the four boolean properties in alphabetical order (matching WebIDL dictionary conversion), freeze it, and cache it. Return the cache.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both use the current realm, which is undefined in a task and may not be the Tool's realm.

I don't think this is true. Does that render this comment moot?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the following make sense to you or is AI nitpicking me?

The argument

1. The current realm only exists while JS code is on the execution context stack. ECMA-262 §9.4:

"The running execution context is always the top element of this stack."
"The value of the Realm component of the running execution context is also called the current Realm Record."

So if the execution context stack is empty, there is no running execution context and no current realm.

2. Nothing in HTML puts anything on the stack when a task starts. The event loop just does: "Set the event loop's currently running task to oldestTask. Perform oldestTask's steps." Queue a global task uses global only to pick the event loop and document. It doesn't enter that global's realm. Realms are only entered by prepare to run script / prepare to run a callback. These are used around script and callback calls, not around task steps.

3. Both operations in the PR's getTools() task implicitly use the current realm:

  • Infra parse a JSON string to a JavaScript value is ? Call(%JSON.parse%, undefined, « string »).
  • WebIDL dictionary → JS conversion starts with OrdinaryObjectCreate(%Object.prototype%).
  • ECMA-262 §6.1.7.4 says: "a reference such as %name% means the intrinsic object, associated with the current realm".

So inside a task, %JSON.parse% and %Object.prototype% refer to nothing. 1 + 2 + 3 means the realm is undefined.

4. HTML says this itself. Its in-parallel template example captures realm before going in parallel. Inside the queued task it does "converting encryptedURLs to a JavaScript array, in realm" and explains:

"The creation of a JavaScript array object also happens during the queued task, and is careful to specify which realm it creates the array in since that is no longer obvious from context."

It also links whatwg/webidl#135 as the known open issue.

Two caveats for the reply

  • "May not be the Tool's realm" is the weaker half. The task is queued on this's relevant global. Any sensible implementation would therefore use that realm, so it's unlikely to be wrong in practice. The real problem is that the spec doesn't define it.
  • Step 2 is an argument from silence. HTML never says "the stack is empty when a task starts". It just never pushes anything. The supporting evidence is clean up after running script ("If the JavaScript execution context stack is now empty, perform a microtask checkpoint"), which shows the stack empties between scripts. Implementations match this: Blink must explicitly enter a ScriptState before creating JS objects in a task.

Fix

The PR already creates each Tool "in this's relevant realm". The smallest fix is to capture realm (this's relevant realm) before going in parallel and use it explicitly in the task:

  • Input schema: ! Call(realm.[[Intrinsics]].[[%JSON.parse%]], undefined, « input schema »)
  • Annotations: convert the dictionary "in realm", HTML-example style.

The lazy-getter approach from the original comment also works, because getters always run with a current realm.

Comment thread index.bs
</dl>

<h4 id="registered-tool">RegisteredTool Dictionary</h4>
<h4 id="tool-interface">Tool Interface</h4>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no built-in way to compare two Tools. Each getTools() call creates new objects, so toolFromFirstCall !== toolFromSecondCall even for the same registration. Agents that cache tools across toolchange events can't tell which tools are unchanged. We could either cache one Tool per (realm, tool id), or at lease have a note saying this is intentional.

Comment thread index.bs
</dl>

<h4 id="registered-tool">RegisteredTool Dictionary</h4>
<h4 id="tool-interface">Tool Interface</h4>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tool is a very generic global name. A more specific name such as ModelContextRegisteredTool would reduce web-compat risk. WDYT?

Comment thread index.bs
ToolAnnotations annotations;
readonly attribute DOMString title;
readonly attribute DOMString description;
readonly attribute object? inputSchema;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean we resolved #224 with this change and decided we should use null. If that's the case, shall we change to DOMString title to DOMString? title?

Comment thread index.bs
Promise<DOMString> executeTool(Tool tool, optional object inputObject, optional ModelContextExecuteToolOptions options = {});

attribute EventHandler ontoolchange;
attribute EventHandler ontoolactivated;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toolcancel and toolactivated still report only the tool name (pending tool execution/tool name). Unregistering doesn't cancel an execution that's already running, so after a same-name re-registration, the owner page can't tell whether a toolcancel refers to the old or the new tool. A note would help.

OR (better?) shall we fire ToolCancel and ToolActivated events with Tool objects instead of tool names?

Comment thread index.bs
1. Let |tool definition| be |targetToolMap|[|toolName|].
1. Let |tool definition| be |targetToolMap|[|toolId|].

1. [=Assert=]: |tool definition|'s [=tool definition/name=] is |tool|'s [=Tool/name=].

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that lookup is by tool id not tool name, the targetOrigin / expectedTargetOrigin check can't fail anymore from a spec perspective right?

I'd suggest replacing the "targetOrigin / expectedTargetOrigin check" with something like "Assert: targetOrigin is same origin with expectedTargetOrigin" after the id lookup, and updating the 'all three checks' note to two.

Comment thread index.bs

1. If [=this=]'s [=Tool/window=]'s [=Window/browsing context=] is null, then return null.

1. Return [=this=]'s [=Tool/window=].

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we add a note saying that once window's browsing context becomes null, neither this getter
nor executeTool() observes window again? This way, implementations can hold window weakly, so that a Tool does not keep a destroyed Document alive.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants