Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Initial discovery waits up to 500 ms for existing frames. Requests use `MessageC

## Implementation status

The target is `webmcp-types@0.1.9`: registration, discovery, execution, cancellation, and `toolchange`, including frame exposure and origin filtering. Declarative forms and `toolactivated`/`toolcancel` are not implemented. Browser agent integration requires browser support.
The target is `webmcp-types@0.1.10`: registration, discovery, execution, cancellation, `toolchange`, and the `toolactivated`/`toolcancel` lifecycle events, including frame exposure and origin filtering. Declarative forms are not implemented. Browser agent integration requires browser support.

Native contexts do not join the polyfill's channels. See [TESTING.md](https://github.com/webmachinelearning/webmcp-polyfill/blob/main/TESTING.md) for policy and frame limitations, results, commands, and tracked revisions.

Expand Down
28 changes: 15 additions & 13 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Results

**223 browser tests pass** across Chromium 153.0.8010.12, Firefox 155.0, and
**238 browser tests pass** across Chromium 153.0.8010.12, Firefox 155.0, and
Playwright WebKit 26.6. Package checks also pass: imports, types, SSR, and tarball contents.
CI runs these checks plus WPT in Chrome, Firefox, and actual Safari. Safari runs on
`macos-26`; Playwright WebKit is a separate build.
Expand All @@ -12,19 +12,19 @@ in Chrome and Firefox:

| Subtest result | Count |
| --- | ---: |
| PASS | 92 |
| Expected FAIL | 52 |
| Expected TIMEOUT | 26 |
| Expected NOTRUN | 20 |
| PASS | 116 |
| Expected FAIL | 33 |
| Expected TIMEOUT | 25 |
| Expected NOTRUN | 16 |

Tested with Chrome Canary 157.0.8080.0 and Firefox Nightly 159.0a1 (20260930214513).
Safari has not yet run at this pin; none of the expectations are browser-specific.
At the file level: 43 OK, 26 expected timeouts, one expected error.
At the file level: 44 OK, 25 expected timeouts, one expected error.

Expected failures are still failures. `NOTRUN` means an earlier timeout prevented
the test from running, including three abort cases. Passing declarative checks only
cover rejection or absence of tools. Of the 38 pinned IDL checks, the 16 for lifecycle
event handlers and interfaces fail. This is not full conformance.
the test from running, including one abort case. Passing declarative checks only
cover rejection or absence of tools. All 38 pinned IDL checks pass. This is not
full conformance.

## Run locally

Expand Down Expand Up @@ -66,13 +66,15 @@ other non-testharness files are outside this suite.
## Draft alignment and limitations

Checked against [draft `d61d0e6`](https://github.com/webmachinelearning/webmcp/blob/d61d0e6d297ddb6bff3510b1330dbb215c6ef43c/index.bs)
and `webmcp-types@0.1.9`.
and `webmcp-types@0.1.10`.

- **Missing APIs:** declarative forms, CSS states, and lifecycle events
(`toolactivated`/`toolcancel`, their handlers, and `ToolActivatedEvent`/`ToolCancelEvent`)
are not implemented.
- **Missing APIs:** declarative forms and CSS states are not implemented.
- **Draft differences:** results are JSON-serialized; some pinned tests expect raw
strings. Omitted or `undefined` input becomes `{}`; `null` and primitives reject.
- **Lifecycle events:** `toolactivated` fires before the callback is invoked, as the
draft specifies; the pinned `executeTool-abort` test and Chromium fire it after the
callback starts. Script-dispatched events cannot be
[trusted](https://dom.spec.whatwg.org/#dom-event-istrusted), so `isTrusted` is false.
- **Timing:** MessagePorts approximate native task ordering. Aborting before
dispatch skips the callback; the draft dispatches and then aborts its signal.
Delegated permission checks are asynchronous, so argument errors can precede
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"lint": "oxlint --deny-warnings"
},
"dependencies": {
"webmcp-types": "^0.1.9"
"webmcp-types": "^0.1.10"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 83 additions & 0 deletions src/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*!
* Copyright (c) 2026 WebMCP polyfill contributors
* SPDX-License-Identifier: MIT
*/

import type { WebMCP } from "webmcp-types";

/**
* Dispatched at a `ModelContext` when the execution of a tool begins.
*
* @see https://webmachinelearning.github.io/webmcp/#tool-activated-event
*/
export class ToolActivatedEvent extends Event implements WebMCP.ToolActivatedEvent {
readonly #toolName: string;

// A defaulted second parameter keeps Web IDL's required-argument count in function.length.
constructor(type: string, eventInitDict: WebMCP.ToolActivatedEventInit = {}) {
requireEventType(arguments.length);
// Event converts type, then bubbles, cancelable and composed; toolName sorts after them.
super(type, eventInitDict);
this.#toolName = readToolName(eventInitDict);
}

get toolName(): string {
return this.#toolName;
}
}

/**
* Dispatched at a `ModelContext` when the execution of a tool is cancelled.
*
* @see https://webmachinelearning.github.io/webmcp/#tool-cancel-event
*/
export class ToolCancelEvent extends Event implements WebMCP.ToolCancelEvent {
readonly #toolName: string;

constructor(type: string, eventInitDict: WebMCP.ToolCancelEventInit = {}) {
requireEventType(arguments.length);
super(type, eventInitDict);
this.#toolName = readToolName(eventInitDict);
}

get toolName(): string {
return this.#toolName;
}
}

// Web IDL interface objects keep their names through minification and expose enumerable
// attributes and a class string on the prototype.
for (const [eventInterface, name] of [
[ToolActivatedEvent, "ToolActivatedEvent"],
[ToolCancelEvent, "ToolCancelEvent"],
] as const) {
Object.defineProperty(eventInterface, "name", { value: name });
Object.defineProperties(eventInterface.prototype, {
toolName: { enumerable: true },
[Symbol.toStringTag]: { value: name, configurable: true },
});
}

// An explicit undefined type is converted to "undefined"; only a missing one throws.
function requireEventType(argumentCount: number): void {
if (argumentCount < 1) {
throw new TypeError("1 argument required, but only 0 present");
}
}

// Event has already rejected an eventInitDict that is neither an object nor nullish.
function readToolName(eventInitDict: unknown): string {
if (eventInitDict == null) {
return "";
}
// SAFETY: Event's dictionary conversion above proved this is an object.
const toolName = (eventInitDict as Record<PropertyKey, unknown>).toolName;
if (toolName === undefined) {
return "";
}
// https://webidl.spec.whatwg.org/#es-DOMString
if (typeof toolName === "symbol") {
throw new TypeError("Cannot convert a Symbol to a string");
}
return String(toolName);
}
110 changes: 81 additions & 29 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import type { WebMCP } from "webmcp-types";
import { ToolActivatedEvent, ToolCancelEvent } from "./events.js";
import {
FrameBridge,
activeWindow,
Expand Down Expand Up @@ -54,20 +55,25 @@ export function installWebMCP(): void {

const documentPrototype = Document.prototype;
const getDefaultView = Object.getOwnPropertyDescriptor(documentPrototype, "defaultView")!.get!;
const constructorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "ModelContext");
const interfaceObjects = {
ModelContext: modelContextConstructor,
ToolActivatedEvent,
ToolCancelEvent,
};
const canDefineInterface = (name: string): boolean => {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, name);
return descriptor ? descriptor.configurable === true : Object.isExtensible(globalThis);
};
if (
!Object.isExtensible(documentPrototype) ||
(constructorDescriptor && !constructorDescriptor.configurable) ||
(!constructorDescriptor && !Object.isExtensible(globalThis))
!Object.keys(interfaceObjects).every(canDefineInterface)
) {
throw new TypeError("Cannot install WebMCP on this realm");
}

Object.defineProperty(globalThis, "ModelContext", {
value: modelContextConstructor,
configurable: true,
writable: true,
});
for (const [name, value] of Object.entries(interfaceObjects)) {
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
}

// A method is non-constructible; defaultView supplies the native Document brand check.
const { getModelContext } = {
Expand Down Expand Up @@ -96,14 +102,20 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext {
readonly #tools = new Map<string, StoredTool>();
// Only a document that was active when its context was created has a bridge.
readonly #frames?: FrameBridge;
#toolchangeHandler: WebMCP.ModelContext["ontoolchange"] = null;
readonly #toolchangeListener = (event: Event): void => {
const handler = this.#toolchangeHandler;
readonly #eventHandlers: EventHandlers = {
ontoolchange: null,
ontoolactivated: null,
ontoolcancel: null,
};
// One listener serves every handler, so replacing a handler keeps its listener position.
readonly #eventHandlerListener = (event: Event): void => {
const name = eventHandlerName(event.type);
const handler: unknown = name ? this.#eventHandlers[name] : null;
// An EventHandler keeps a non-callable object but never invokes it.
if (typeof handler !== "function") {
return;
}
const result = Reflect.apply(handler, this, [event]);
const result: unknown = Reflect.apply(handler, this, [event]);
if (result === false) {
Event.prototype.preventDefault.call(event);
}
Expand All @@ -127,20 +139,27 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext {
}

get ontoolchange(): WebMCP.ModelContext["ontoolchange"] {
return this.#toolchangeHandler;
return this.#eventHandlers.ontoolchange;
}

set ontoolchange(handler: WebMCP.ModelContext["ontoolchange"]) {
// [LegacyTreatNonObjectAsNull]: only a non-object becomes null.
const nextHandler = isObject(handler) ? handler : null;
// Replacing a handler preserves its listener position; clearing it removes that position.
if (!this.#toolchangeHandler && nextHandler) {
this.addEventListener("toolchange", this.#toolchangeListener);
}
if (this.#toolchangeHandler && !nextHandler) {
this.removeEventListener("toolchange", this.#toolchangeListener);
}
this.#toolchangeHandler = nextHandler;
this.#setEventHandler("ontoolchange", handler);
}

get ontoolactivated(): WebMCP.ModelContext["ontoolactivated"] {
return this.#eventHandlers.ontoolactivated;
}

set ontoolactivated(handler: WebMCP.ModelContext["ontoolactivated"]) {
this.#setEventHandler("ontoolactivated", handler);
}

get ontoolcancel(): WebMCP.ModelContext["ontoolcancel"] {
return this.#eventHandlers.ontoolcancel;
}

set ontoolcancel(handler: WebMCP.ModelContext["ontoolcancel"]) {
this.#setEventHandler("ontoolcancel", handler);
}

// Default parameters preserve Web IDL's required-argument counts in function.length.
Expand Down Expand Up @@ -289,30 +308,34 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext {
return new Promise<string>((resolve, reject) => {
callerSignal?.throwIfAborted();
const callbackController = new AbortController();
// A callback that already finished must not be aborted by a late cancellation.
let callbackFinished = false;
// The draft's local pending tool execution: it exists from invocation until the callback
// settles or the call is cancelled, and a late cancellation must not abort or report it.
let callbackPending = false;

const onCallerAbort = (): void => {
reject(callerSignal!.reason);

// Reject the caller first; the running callback receives a default AbortError.
queueTask(() => {
if (!callbackFinished) {
callbackController.abort();
if (!callbackPending) {
return;
}
callbackPending = false;
callbackController.abort();
this.dispatchEvent(new ToolCancelEvent("toolcancel", { toolName: name }));
});
};

const rejectExecution = (): void => {
callbackFinished = true;
callbackPending = false;
queueTask(() => {
callerSignal?.removeEventListener("abort", onCallerAbort);
reject(executionError());
});
};

const completeExecution = (value: unknown): void => {
callbackFinished = true;
callbackPending = false;
// A cancelled call must not run the author's toJSON during serialization.
if (callerSignal?.aborted) {
return;
Expand Down Expand Up @@ -352,6 +375,10 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext {
return;
}

// Listener exceptions are reported, not thrown, and the callback still runs.
this.dispatchEvent(new ToolActivatedEvent("toolactivated", { toolName: name }));
callbackPending = true;

// The callback runs without the registration object as its receiver.
const execute = storedTool.execute;
const callbackResult = execute(input, { signal: callbackController.signal });
Expand Down Expand Up @@ -395,6 +422,29 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext {
});
});
}

#setEventHandler<Name extends EventHandlerName>(name: Name, handler: EventHandlers[Name]): void {
const type = name.slice("on".length);
const previousHandler = this.#eventHandlers[name];
// [LegacyTreatNonObjectAsNull]: only a non-object becomes null.
const nextHandler = isObject(handler) ? handler : null;
// Replacing a handler preserves its listener position; clearing it removes that position.
if (!previousHandler && nextHandler) {
this.addEventListener(type, this.#eventHandlerListener);
}
if (previousHandler && !nextHandler) {
this.removeEventListener(type, this.#eventHandlerListener);
}
this.#eventHandlers[name] = nextHandler;
}
}

const eventHandlerNames = ["ontoolchange", "ontoolactivated", "ontoolcancel"] as const;
type EventHandlerName = (typeof eventHandlerNames)[number];
type EventHandlers = Pick<WebMCP.ModelContext, EventHandlerName>;

function eventHandlerName(type: string): EventHandlerName | undefined {
return eventHandlerNames.find((name) => name === `on${type}`);
}

function isExposedTo(tool: StoredTool, ownerOrigin: string, callerOrigin: string): boolean {
Expand All @@ -419,6 +469,8 @@ Object.defineProperties(ModelContextPolyfill.prototype, {
getTools: { enumerable: true },
executeTool: { enumerable: true },
ontoolchange: { enumerable: true },
ontoolactivated: { enumerable: true },
ontoolcancel: { enumerable: true },
});

// Web IDL reads and converts dictionary members in lexicographical order.
Expand Down
Loading
Loading