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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ Native contexts do not join the polyfill's channels. See [TESTING.md](https://gi

`executeTool()` accepts an object and returns a JSON-serialized result. Omitted or `undefined` input defaults to a fresh empty object. Callbacks must validate their inputs; schema inference provides TypeScript checks only.

A failed execution rejects with the draft's `DOMException` name, such as `NotFoundError` or `OperationError`, rather than the callback's own error.

Breaking API changes ship with notes: in minor releases while the version is 0.x, in majors after 1.0.

## Development
Expand Down
19 changes: 14 additions & 5 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,22 @@ 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)
Checked against [draft `eada13b`](https://github.com/webmachinelearning/webmcp/blob/eada13b575187ba9a523adc0c52e294ebdb6b597/index.bs)
and `webmcp-types@0.1.9`.

- **Missing APIs:** declarative forms, CSS states, and lifecycle events
(`toolactivated`/`toolcancel`, their handlers, and `ToolActivatedEvent`/`ToolCancelEvent`)
are not implemented.
- **Missing APIs:** lifecycle events (`toolactivated`/`toolcancel`, their handlers, and
`ToolActivatedEvent`/`ToolCancelEvent`) are not implemented. Declarative forms and CSS
states are not implemented either; the draft removed them in `14ae813`, but pinned WPT
still tests them.
- **Draft differences:** results are JSON-serialized; some pinned tests expect raw
strings. Omitted or `undefined` input becomes `{}`; `null` and primitives reject.
- **Execution errors:** failures reject with the draft's `NotFoundError`, `DataError`,

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.

You may want to wait until https://chromium-review.googlesource.com/c/chromium/src/+/8546218 is merged so that we can check the polyfill works as intended.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I think it is a good idea to wait for Chromium changes here

`OperationError`, `AbortError`, or `InvalidStateError`, with Chromium's messages.
Chromium 158.0.8109.0 and later use the same names
([crbug.com/509555636](https://crbug.com/509555636)). Six pinned subtests still expect
`UnknownError` and are expected failures; upstream WPT updated or removed them by
`22b4a4d`. Failures of the polyfill's own frame messages, such as a malformed reply,
have no draft counterpart and reject with `OperationError`.
- **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 All @@ -82,7 +90,8 @@ and `webmcp-types@0.1.9`.
Opaque-origin handshakes and inaccessible cross-origin shadow frames are
unsupported; closed shadow trees limit discovery and ordering.
- **Deadlines:** initial discovery, handshakes, and discovery or permission replies
wait up to 500 ms. Late peers can be absent from results. Callbacks and
wait up to 500 ms. Late peers can be absent from results, and executing a tool in a
frame that misses the handshake deadline rejects with `NotFoundError`. Callbacks and
`toolchange` listeners have no deadline, so an unresponsive peer can leave a call
pending. Navigation cleanup depends on `pagehide`; removed frames are polled
every 100 ms. Background suspension affects timing.
Expand Down
103 changes: 76 additions & 27 deletions src/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ export class FrameBridge {
toRegisteredTool(readToolMetadata(value), peer, reply.origin),
);
} catch (error) {
if (!(error instanceof NativeDOMException && error.name === "UnknownError")) {
// Refusals and missing peers are expected; malformed replies and other errors are not.
if (!isExecutionError(error)) {
console.warn("WebMCP: could not read tools from a frame.", error);
}
return [];
Expand All @@ -143,7 +144,7 @@ export class FrameBridge {
signal,
);
if (typeof reply.value !== "string") {
throw executionError();
throw executionError("OperationError");
}
return reply.value;
}
Expand Down Expand Up @@ -173,8 +174,17 @@ export class FrameBridge {
): Promise<{ value: unknown; origin: string }> {
return new Promise((resolve, reject) => {
signal?.throwIfAborted();
if (this.#hidden || !this.#related(target)) {
reject(executionError());
if (this.#hidden) {
reject(new NativeDOMException("The document is not active.", "InvalidStateError"));
return;
}
// executeTool() rejects a target that is already closed, so one closed since has unloaded.
if (target.closed) {
reject(executionError("AbortError"));
return;
}
if (!this.#related(target)) {
reject(executionError("InvalidStateError"));
return;
}

Expand All @@ -189,7 +199,7 @@ export class FrameBridge {
this.#handshakes.delete(id);
port1.close();
};
const fail = (error: unknown = executionError()): void => {
const fail = (error: unknown): void => {
port1.postMessage({ kind: "cancel" });
close();
reject(error);
Expand All @@ -199,15 +209,16 @@ export class FrameBridge {
if (peerOrigin === undefined) {
this.#forget(target);
}
fail();
// A window that misses the deadline is treated as holding no polyfill, and so no tools.
fail(executionError("NotFoundError"));
};
const connected = (event: MessageEvent<unknown>): void => {
if (event.source !== target) {
return;
}
this.#handshakes.delete(id);
if (origins && !origins.includes(event.origin)) {
fail();
fail(executionError("NotFoundError"));
return;
}
peerOrigin = event.origin;
Expand All @@ -220,12 +231,12 @@ export class FrameBridge {
};

let timer = setTimeout(expire, deadline);
const stopWatching = this.#watch(target, fail);
const stopWatching = this.#watch(target, () => fail(executionError("AbortError")));
port1.onmessage = (event: MessageEvent<unknown>) => {
const reply = record(event.data);
// A peer that gave up may say so before its ready message is handled here.
if (reply?.kind === "error") {
fail();
fail(executionError(readExecutionFailure(reply.name)));
return;
}
if (reply?.kind !== "result" || peerOrigin === undefined) {
Expand All @@ -234,15 +245,15 @@ export class FrameBridge {
close();
resolve({ value: reply.value, origin: peerOrigin });
};
port1.onmessageerror = () => fail();
port1.onmessageerror = () => fail(executionError("OperationError"));
signal?.addEventListener("abort", abort, { once: true });
this.#handshakes.set(id, connected);
// The peer's origin is unknown until it answers, and this message carries only the id.
try {
target.postMessage(`${protocol}:connect:${id}`, "*", [port2]);
} catch {
port2.close();
fail();
fail(executionError("OperationError"));
}
});
}
Expand Down Expand Up @@ -319,15 +330,16 @@ export class FrameBridge {
controller.abort();
close();
};
const fail = (): void => {
port.postMessage({ kind: "error" });
const fail = (name: ExecutionFailure): void => {
port.postMessage({ kind: "error", name });
cancel();
};
// Tell a requester that was too slow to send its request; it may have no deadline left.
const timer = setTimeout(fail, deadline);
const stopWatching = this.#watch(source, fail);
const timer = setTimeout(() => fail("OperationError"), deadline);
// The target leaving the tree unloads its document; if the caller left, no one reads this.
const stopWatching = this.#watch(source, () => fail("AbortError"));

port.onmessageerror = fail;
port.onmessageerror = () => fail("OperationError");
port.onmessage = async (event: MessageEvent<unknown>) => {
const request = record(event.data);
if (request?.kind === "cancel") {
Expand All @@ -343,8 +355,8 @@ export class FrameBridge {
const value = await this.#respond(source, origin, request, controller.signal);
port.postMessage({ kind: "result", value });
close();
} catch {
fail();
} catch (error) {
fail(isExecutionError(error) ? error.name : "OperationError");
}
};
source.postMessage(`${protocol}:ready:${id}`, origin);
Expand All @@ -356,8 +368,11 @@ export class FrameBridge {
request: Record<string, unknown> | undefined,
signal: AbortSignal,
): Promise<ReplyValue> {
if (!request || !this.#related(source)) {
throw executionError();
if (!request) {
throw executionError("OperationError");
}
if (!this.#related(source)) {
throw executionError("InvalidStateError");
}
if (request.kind === "permission") {
const { childIndex, childOrigin } = request;
Expand All @@ -367,16 +382,20 @@ export class FrameBridge {
childIndex < 0 ||
typeof childOrigin !== "string"
) {
throw executionError();
throw executionError("OperationError");
}
return this.#childPermission(childIndex, childOrigin);
}
if (!(await this.allowed()) || !(await this.#sourceAllowed(source, origin))) {
throw executionError();
if (!(await this.allowed())) {
// A document that may not use the API cannot register tools, so it has none to find.
throw executionError(this.#hidden ? "AbortError" : "NotFoundError");
}
if (!(await this.#sourceAllowed(source, origin))) {
throw executionError("NotAllowedError");
}
// The checks above may have waited on other frames.
if (!this.#related(source)) {
throw executionError();
throw executionError("InvalidStateError");
}
signal.throwIfAborted();
if (request.kind === "getTools") {
Expand All @@ -393,7 +412,7 @@ export class FrameBridge {
await this.#handlers.changed();
return undefined;
}
throw executionError();
throw executionError("OperationError");
}

async #childPermission(childIndex: number, childOrigin: string): Promise<boolean> {
Expand Down Expand Up @@ -768,6 +787,36 @@ function isWindow(value: MessageEventSource): value is Window {
}
}

export function executionError(): DOMException {
return new NativeDOMException("Tool execution failed", "UnknownError");
// The first four names are the draft's tool execution errors:
// https://webmachinelearning.github.io/webmcp/#tool-execution-error
// InvalidStateError and NotAllowedError come from other executeTool() steps. Protocol failures
// without a draft counterpart, such as a malformed reply, are reported as OperationError.
// The messages are Chromium's: https://crrev.com/c/8546218
const executionFailures = {
NotFoundError: "The requested tool was not found or is not accessible.",
DataError: "Failed to parse tool input arguments.",
OperationError: "Tool execution failed.",
AbortError: "Tool execution aborted.",
InvalidStateError: "Target frame is not accessible.",
NotAllowedError: 'Access to the feature "tools" is disallowed by permissions policy.',
} as const;
Comment on lines +795 to +802

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks! It now uses Chromium's messages


export type ExecutionFailure = keyof typeof executionFailures;

export function executionError(name: ExecutionFailure): DOMException {
// Each name has one fixed message, so a message reveals nothing beyond its name.
return new NativeDOMException(executionFailures[name], name);
}

function isExecutionError(error: unknown): error is DOMException & { name: ExecutionFailure } {
return error instanceof NativeDOMException && Object.hasOwn(executionFailures, error.name);
}

// Another frame's reply is untrusted input.
function readExecutionFailure(value: unknown): ExecutionFailure {
if (typeof value === "string" && Object.hasOwn(executionFailures, value)) {
// SAFETY: the own-property check above limits the string to the table's names.
return value as ExecutionFailure;
}
return "OperationError";
}
Loading